Search code examples
python-3.xqtpyqt6

How to achieve a "Breadcrumb navigation" for a Address Bar using PyQt6


I've been working on a personal project for a file explorer that is very similar to the how for Ubuntu works. I currently have a problem with the address bar that would show the current directories path

import sys

from PyQt6.QtCore import QDir, QSize, Qt, QFile
from PyQt6.QtGui import QFileSystemModel, QGuiApplication
from PyQt6.QtWidgets import QMainWindow, QApplication, QListView, QHBoxLayout, QWidget, QLabel, QFrame, \
    QToolBar, QPushButton





class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        self.clipboard = QGuiApplication.clipboard()
        self.homePath = QDir.homePath()

        self.initUI()

    def initUI(self):
        self.setWindowTitle("Tanz")
        self.setFixedSize(800, 500)

        self.setupActions()
        self.setupMainWindow()

        self.show()

    def setupMainWindow(self):
        """ Toolabr """

        self.core_toolbar = QToolBar()
        self.core_toolbar.setContextMenuPolicy(Qt.ContextMenuPolicy.PreventContextMenu)
        self.core_toolbar.setParent(self)
        self.core_toolbar.setFixedSize(800, 45)
        self.core_toolbar.setMovable(False)
        self.core_toolbar.toggleViewAction().setEnabled(False)
        self.core_toolbar.setIconSize(QSize(25, 25))
        self.core_toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)

        back = QPushButton("<")
        back.setFixedSize(25,25)
        forward = QPushButton(">")
        forward.setFixedSize(25,25)


        self.core_toolbar.addWidget(back)
        self.core_toolbar.addWidget(forward)

        self.frame = QFrame()
        self.frame.setFixedSize(600, 35)
        self.h_box = QHBoxLayout()
        self.h_box.setAlignment(Qt.AlignmentFlag.AlignLeft)
        self.frame.setLayout(self.h_box)
        self.core_toolbar.addWidget(self.frame)

        menu = QPushButton("Menu")
        menu.setFixedSize(50,25)
        search = QPushButton("Srch")
        search.setFixedSize(75,25)
        self.core_toolbar.addWidget(menu)
        self.core_toolbar.addWidget(search)
        self.addToolBar(self.core_toolbar)
        self.addToolBarBreak()

        self.core_file_model = QFileSystemModel()
        self.core_file_model.setFilter(QDir.Filter.NoDotAndDotDot | QDir.Filter.AllEntries)
        self.core_file_model.sort(0, Qt.SortOrder.AscendingOrder)
        self.core_file_model.setRootPath(self.homePath)

        self.core_f_list_view = QListView()
        self.core_f_list_view.setModel(self.core_file_model)
        self.core_f_list_view.setRootIndex(self.core_file_model.index(self.homePath))
        self.core_f_list_view.setViewMode(QListView.ViewMode.IconMode)
        self.core_f_list_view.setIconSize(QSize(60, 60))
        self.core_f_list_view.setSpacing(5)
        self.core_f_list_view.setWordWrap(True)
        self.core_f_list_view.setFrameStyle(QListView.Shape.NoFrame)
        self.core_f_list_view.setGridSize(QSize(100, 100))
        self.core_f_list_view.doubleClicked.connect(self.load)

        layout = QHBoxLayout()
        layout.addWidget(self.core_f_list_view)

        wid = QWidget()
        wid.setLayout(layout)
        self.setCentralWidget(wid)

    def setupActions(self):
        pass

    def load(self):
        cur_ind = self.core_f_list_view.currentIndex()
        cur_ind_path = self.core_file_model.filePath(cur_ind)

        if QDir(cur_ind_path).exists():
            self.core_f_list_view.setRootIndex(self.core_file_model.index(cur_ind_path))
            self.core_file_model.setRootPath(cur_ind_path)
            self.updateAddressBar()
        elif QFile(cur_ind_path).exists():
            # Open the file.
            pass
        else:
            pass

    def updateAddressBar(self):
        curr_path = self.core_file_model.filePath(self.core_f_list_view.rootIndex())
        spl_curr_path = curr_path.split("/")[1:]
        print(spl_curr_path)
        for i in reversed(range(self.h_box.count())):
            item = self.h_box.itemAt(i)
            if item.widget():
                self.h_box.removeWidget(item.widget())
                # item.widget().deleteLater()
            elif item.spacerItem():
                self.h_box.removeItem(item.spacerItem())
            else:
                pass

        for sub_dir in spl_curr_path:
            sub_dir_l = QLabel(sub_dir)
            self.h_box.addWidget(sub_dir_l)

            if sub_dir == spl_curr_path[-1]:
                pass
            else:
                sep = QLabel("/")
                self.h_box.addWidget(sep)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = MainWindow()
    sys.exit(app.exec())

I have been unable to find a solution to the problem of:

  • When the path is longer then the available view space: Remove the farthest path to the left -- first directory in full path
  • if user clicks on the parent directory then load said directory; adjust the address bar accordingly

self.h_box.setAlignment(Qt.AlignmentFlag.AlignLeft)

This line allows the QLabels to align to the left in the QHBoxLayout. Which is along the lines of how it would look. Though once the path becomes to long it starts to cut the 2nd QLabel directory at the end. I've looked into adding a QScrollView to allow the horizontal scroll and align the current directory in view to be visible. I was unable to have this work out.

any help is minimizing the code and figuring this problem out would be grateful.


Solution

  • After spending some time figuring this out, on how to achieve this effect; An Address Bar or Breadcrumb Navigation for a file explorer that operates similarly to the Default (Nautilus) File Explorer on Ubuntu Jammy

    To achieve this I had to create two custom classes:

    AddressBarLabel(QLabel) & AddressBar(QFrame)

    The original problem being, and now figured out is.

    • When the path is longer then the available view space: Remove the farthest path to the left (1st directory in full path)
    • if user clicks on the parent directory then load said directory; adjust the address bar accordingly

    Along the way I did manage to have the QMenu that is triggered by a right click on the final QLabel in the address bar to be displayed on the QPushButton that is located on the QToolBar.

    Full code to achieve this in the RME I provided in my original question is

    import sys
    
    from PyQt6.QtCore import QDir, QSize, Qt, QFile, QPoint, pyqtSignal
    from PyQt6.QtGui import QFileSystemModel, QGuiApplication, QFont, QFontMetrics, QFontDatabase, QAction
    from PyQt6.QtWidgets import QMainWindow, QApplication, QListView, QHBoxLayout, QWidget, QLabel, QFrame, \
        QToolBar, QPushButton, QMenu
    
    
    class AddressBarLabel(QLabel):
        clicked = pyqtSignal(str)
    
        def __init__(self, txt, font):
            super().__init__(txt)
            self.fontMetrics = QFontMetrics(font)
            self.setAlignment(Qt.AlignmentFlag.AlignCenter)
            self.setFont(font)
            self.setStyleSheet("""
                QLabel{ border-radius: 5px; }
                QLabel:hover{ background-color: #ededed; }
            """)
            self.setFixedWidth(self.fontMetrics.horizontalAdvance(txt) + 15)
            self.setFixedHeight(25)
    
        def mousePressEvent(self, ev):
            if ev.button() == Qt.MouseButton.LeftButton:
                self.clicked.emit(self.text())
    
    
    class AddressBar(QFrame):
        directoryClicked = pyqtSignal(str)  # New signal
    
        def __init__(self, menu, menu_btn):
            super().__init__()
            self.actions()
            self.menu_btn = menu_btn
    
            # Set the font for the address bar
            font_family = QFontDatabase.systemFont(QFontDatabase.SystemFont.GeneralFont).family()
            self.font = QFont(font_family, 11)
    
            self.fontMetrics = QFontMetrics(self.font)
    
            # Set the size and margins for the address bar
            self.setFixedSize(QSize(600, 35))
            self.setContentsMargins(0, 0, 0, 0)
            self.setObjectName("main_frame")
            self.setStyleSheet("""
                QFrame#main_frame { border: 1px solid black;
                                    border-radius: 2px;}
            """)
    
            # Create the layout and subframe for the address bar
            self.layout = QHBoxLayout()
            self.layout.setContentsMargins(0, 0, 0, 0)
            self.layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
            self.sub_frame = QFrame()
    
            # Create the sub-layout for the address bar
            self.sub_layout = QHBoxLayout()
            self.sub_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
            self.sub_layout.setContentsMargins(0, 0, 0, 0)
    
            # Set the sub-layout for the subframe
            self.sub_frame.setLayout(self.sub_layout)
            self.layout.addWidget(self.sub_frame)
            self.setLayout(self.layout)
    
            # Store the menu object as an instance variable
            self.menu = menu
    
        def stripAddressBar(self):
            # Remove all widgets and spacer items from the sub-layout
            for i in reversed(range(self.sub_layout.count())):
                item = self.sub_layout.itemAt(i)
                if item.widget():
                    self.sub_layout.removeWidget(item.widget())
                else:
                    pass
    
        def updateAddressBar(self, path):
            # Remove any existing content from the address bar
            self.stripAddressBar()
    
            if path.startswith("/"):
                path = path[1:]
            # Split the path into subdirectories
            self.sub_path = path.split("/")
    
            total_width = 0
            self.sub_dirs = []
    
            # Add a QLabel widget for each subdirectory and a separator after each one
            for i1, sub_dir in enumerate(self.sub_path):
                sub_dir_l = AddressBarLabel(sub_dir, self.font)
                sub_dir_width = self.fontMetrics.horizontalAdvance(sub_dir) + 15
                total_width += sub_dir_width + 8  # Eight compensates for the /
    
                if total_width > 600:
                    # Remove the first subdirectory and separator until the total width is less than or equal to 600
                    while total_width > 600 and self.sub_layout.count() > 1:
                        item = self.sub_layout.takeAt(0)
                        widget = item.widget()
                        if widget is not None:
                            widget.deleteLater()
                        else:
                            spacer = item.spacerItem()
                            if spacer is not None:
                                spacer.deleteLater()
                        self.sub_layout.takeAt(
                            0).widget().deleteLater()  # This deletes the "/" form the beginning of the path
                        total_width -= sub_dir_width + 10
    
                self.sub_dirs.append(sub_dir_l)
                sub_dir_l.clicked.connect(self.onSubDirectoryClicked)  # Connect the label's clicked signal
                self.sub_layout.addWidget(sub_dir_l)
    
                if i1 < len(self.sub_path) - 1:
                    sep = QLabel("/")
                    sep.setFixedWidth(self.fontMetrics.horizontalAdvance("/"))
                    # self.sub_dirs.append(sep)
                    self.sub_layout.addWidget(sep)
    
                # Enable context menus for each QLabel that is not a separator
                sub_dir_l.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
                sub_dir_l.customContextMenuRequested.connect(lambda pos, i=i1: self.showContextMenu(pos, i))
    
            # Set the font weight of the last subdirectory to bold and adjust its width to fit the text
            self.sub_dirs[-1].setStyleSheet("font-weight: bold")
            w = self.fontMetrics.horizontalAdvance(
                self.sub_dirs[-1].text()) + 10  # +10 adds 10 pixels to the bolded directory
            self.sub_dirs[-1].setFixedWidth(w)
    
            # Set the spacing and width for the sub-layout and subframe
            self.sub_layout.setSpacing(0)
            self.sub_frame.setFixedWidth(total_width)
    
        def onSubDirectoryClicked(self, index):
            # Emit the directoryClicked signal with the directory name and path at the clicked index
            clicked_directory_path = None
            for i, directory in enumerate(self.sub_path):
                if directory.endswith(index):
                    clicked_directory_path = '/'.join(self.sub_path[:i + 1])
                    break
            if clicked_directory_path is not None:
                self.directoryClicked.emit("/" + clicked_directory_path)
    
        def showContextMenu(self, pos, index):  # Do I need pos? ---- Create a QMenu object and add actions to it
            menu = self.menu
            menu.addAction(self.new_folder_act)
            menu.addAction(self.open_new_tab_act)
            menu.addAction(self.properties_act)
            if index == len(self.sub_dirs) - 1:
                # If the last directory in the path --> display on QPushButton
                # Calculate the position of the context menu based on the position of the QPushButton
                btn_pos = self.menu_btn.mapToGlobal(QPoint(0, 0))
                btn_width = self.menu_btn.width()
                btn_height = self.menu_btn.height()
                menu_width = menu.sizeHint().width()
                menu_height = menu.sizeHint().height()
                x = int(btn_pos.x() + (btn_width / 2) - (menu_width / 2))
                y = int(btn_pos.y() + btn_height)
                action = menu.exec(QPoint(x, y))
            else:
                # else display on the QLabel
                # Calculates the position of the context menu based on the position of the QLabel
                label_pos = self.sub_dirs[index].mapToGlobal(QPoint(0, 0))
                label_width = self.sub_dirs[index].width()
                label_height = self.sub_dirs[index].height()
                menu_width = menu.sizeHint().width()
                menu_height = menu.sizeHint().height()
                x = int(label_pos.x() + (label_width / 2) - (menu_width / 2))
                y = int(label_pos.y() + label_height)
                action = menu.exec(QPoint(x, y))
            # Handle the selected action (currently, just print)
            if action == self.new_folder_act:
                print("New Folder selected")
            elif action == self.open_new_tab_act:
                print("Open in New Tab selected")
            elif action == self.properties_act:
                print("Properties selected")
    
        def actions(self):
            # Create Actions
            self.new_folder_act = QAction("New Folder")
            self.open_new_tab_act = QAction("Open in New Tab")
            self.properties_act = QAction("Properties")
    
    
    class MainWindow(QMainWindow):
        def __init__(self):
            super().__init__()
    
            self.clipboard = QGuiApplication.clipboard()
            self.homePath = QDir.homePath()
    
            self.initUI()
    
        def initUI(self):
    
            self.setWindowTitle("Tanz")
            self.setFixedSize(800, 500)
    
            self.setupActions()
            self.setupMainWindow()
            self.adr_bar.directoryClicked.connect(self.updateFileView)
    
            self.show()
    
        def setupMainWindow(self):
            """ Toolbar """
            self.core_toolbar = QToolBar()
            self.core_toolbar.setContextMenuPolicy(Qt.ContextMenuPolicy.PreventContextMenu)
            self.core_toolbar.setParent(self)
            self.core_toolbar.setFixedSize(800, 45)
            self.core_toolbar.setMovable(False)
            self.core_toolbar.toggleViewAction().setEnabled(False)
            self.core_toolbar.setIconSize(QSize(25, 25))
            self.core_toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
    
            back = QPushButton("<")
            back.setFixedSize(25, 25)
            forward = QPushButton(">")
            forward.setFixedSize(25, 25)
    
            self.core_toolbar.addWidget(back)
            self.core_toolbar.addWidget(forward)
    
            # Create a QMenu object for the address bar context menu
            self.address_bar_menu = QMenu()
            # Create the menu QPushButton for the QToolbar
            menu = QPushButton("Menu")
            menu.setFixedSize(50, 25)
    
            # Pass the menu object to the AddressBar constructor
            self.adr_bar = AddressBar(self.address_bar_menu, menu)
            self.core_toolbar.addWidget(self.adr_bar)
            self.adr_bar.new_folder_act.triggered.connect(self.newDirectory)
    
            search = QPushButton("Srch")
            search.setFixedSize(75, 25)
            self.core_toolbar.addWidget(menu)
            self.core_toolbar.addWidget(search)
            self.addToolBar(self.core_toolbar)
            self.addToolBarBreak()
    
            self.core_file_model = QFileSystemModel()
            self.core_file_model.setFilter(QDir.Filter.NoDotAndDotDot | QDir.Filter.AllEntries)
            self.core_file_model.sort(0, Qt.SortOrder.AscendingOrder)
            self.core_file_model.setRootPath(self.homePath)
    
            self.core_f_list_view = QListView()
            self.core_f_list_view.setModel(self.core_file_model)
            self.core_f_list_view.setRootIndex(self.core_file_model.index(self.homePath))
            self.core_f_list_view.setViewMode(QListView.ViewMode.IconMode)
            self.core_f_list_view.setIconSize(QSize(60, 60))
            self.core_f_list_view.setSpacing(5)
            self.core_f_list_view.setWordWrap(True)
            self.core_f_list_view.setFrameStyle(QListView.Shape.NoFrame)
            self.core_f_list_view.setGridSize(QSize(100, 100))
            self.core_f_list_view.doubleClicked.connect(self.load)
    
            layout = QHBoxLayout()
            layout.addWidget(self.core_f_list_view)
    
            wid = QWidget()
            wid.setLayout(layout)
            self.setCentralWidget(wid)
    
        def updateFileView(self, directory_path):
            index = self.core_file_model.index(directory_path)
            self.adr_bar.updateAddressBar(directory_path)
            self.core_f_list_view.setRootIndex(index)
            self.core_file_model.setRootPath(directory_path)
    
        def setupActions(self):
            pass
    
        def newDirectory(self):
            curr_path = self.core_file_model.filePath(self.core_f_list_view.rootIndex())
            new_direc_path = curr_path + "/New Folder"
            QDir(curr_path).mkdir(new_direc_path)
    
        def load(self):
            cur_ind = self.core_f_list_view.currentIndex()
            cur_ind_path = self.core_file_model.filePath(cur_ind)
            if QDir(cur_ind_path).exists():
                self.adr_bar.updateAddressBar(cur_ind_path)
                self.core_f_list_view.setRootIndex(self.core_file_model.index(cur_ind_path))
                self.core_file_model.setRootPath(cur_ind_path)
            elif QFile(cur_ind_path).exists():
                # Open the file.
                pass
            else:
                pass
    
    
    if __name__ == '__main__':
        app = QApplication(sys.argv)
        window = MainWindow()
        sys.exit(app.exec())
    

    I encourage that if anyone is to use this and find a simpler or cleaner way of achieving this, please add your answers below. This will be kept on GitHub for future reference at: https://github.com/ZoidEee/PyQT6-Address-Bar