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

72 views Asked by At

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.

1

There are 1 answers

0
ZoidEEE On BEST ANSWER

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