| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460 |
- """
- Qt navigation bar with breadcrumbs
- Andrey Makarov, 2019
- """
- import os
- import platform
- from pathlib import Path
- from typing import Union
- from qtpy import QtCore, QtGui, QtWidgets
- from qtpy.QtCore import Qt
- from .layouts import LeftHBoxLayout
- from .models_views import FilenameModel, MenuListView
- from .stylesheet import style_root_toolbutton
- if platform.system() == "Windows":
- from .platform.windows import get_path_label
- TRANSP_ICON_SIZE = 40, 40 # px, size of generated semi-transparent icons
- cwd_path = Path() # working dir (.) https://stackoverflow.com/q/51330297
- class BreadcrumbsAddressBar(QtWidgets.QFrame):
- "Windows Explorer-like address bar"
- listdir_error = QtCore.Signal(Path) # failed to list a directory
- path_error = QtCore.Signal(Path) # entered path does not exist
- path_selected = QtCore.Signal(Path)
- def __init__(self, parent=None):
- super().__init__(parent)
- self.os_type = platform.system()
- self.style_crumbs = StyleProxy(
- QtWidgets.QStyleFactory.create(
- QtWidgets.QApplication.instance().style().objectName()
- ),
- QtGui.QPixmap("iconfinder_icon-ios7-arrow-right_211607.png")
- )
- layout = QtWidgets.QHBoxLayout(self)
- self.file_ico_prov = QtWidgets.QFileIconProvider()
- self.fs_model = FilenameModel('dirs')
- pal = self.palette()
- pal.setColor(QtGui.QPalette.Background,
- pal.color(QtGui.QPalette.Base))
- self.setPalette(pal)
- self.setAutoFillBackground(True)
- self.setFrameShape(self.StyledPanel)
- self.layout().setContentsMargins(4, 0, 0, 0)
- self.layout().setSpacing(0)
- self.path_icon = QtWidgets.QLabel(self)
- layout.addWidget(self.path_icon)
- # Edit presented path textually
- self.line_address = QtWidgets.QLineEdit(self)
- self.line_address.setFrame(False)
- self.line_address.hide()
- self.line_address.keyPressEvent = self.line_address_keyPressEvent
- self.line_address.focusOutEvent = self.line_address_focusOutEvent
- self.line_address.contextMenuEvent = self.line_address_contextMenuEvent
- layout.addWidget(self.line_address)
- # Add QCompleter to address line
- completer = self.init_completer(self.line_address, self.fs_model)
- completer.activated.connect(self.set_path)
- # Container for `btn_crumbs_hidden`, `crumbs_panel`, `switch_space`
- self.crumbs_container = QtWidgets.QWidget(self)
- crumbs_cont_layout = QtWidgets.QHBoxLayout(self.crumbs_container)
- crumbs_cont_layout.setContentsMargins(0, 0, 0, 0)
- crumbs_cont_layout.setSpacing(0)
- layout.addWidget(self.crumbs_container)
- # Monitor breadcrumbs under cursor and switch popup menus
- self.mouse_pos_timer = QtCore.QTimer(self)
- self.mouse_pos_timer.timeout.connect(self.mouse_pos_timer_event)
- # Hidden breadcrumbs menu button
- self.btn_root_crumb = QtWidgets.QToolButton(self)
- self.btn_root_crumb.setAutoRaise(True)
- self.btn_root_crumb.setPopupMode(QtWidgets.QToolButton.InstantPopup)
- self.btn_root_crumb.setArrowType(Qt.RightArrow)
- self.btn_root_crumb.setStyleSheet(style_root_toolbutton)
- self.btn_root_crumb.setMinimumSize(self.btn_root_crumb.minimumSizeHint())
- crumbs_cont_layout.addWidget(self.btn_root_crumb)
- menu = QtWidgets.QMenu(self.btn_root_crumb) # FIXME:
- menu.aboutToShow.connect(self._hidden_crumbs_menu_show)
- menu.aboutToHide.connect(self.mouse_pos_timer.stop)
- self.btn_root_crumb.setMenu(menu)
- self.init_rootmenu_places(menu) # Desktop, Home, Downloads...
- self.update_rootmenu_devices() # C:, D:...
- # Container for breadcrumbs
- self.crumbs_panel = QtWidgets.QWidget(self)
- crumbs_layout = LeftHBoxLayout(self.crumbs_panel)
- crumbs_layout.widget_state_changed.connect(self.crumb_hide_show)
- crumbs_layout.setContentsMargins(0, 0, 0, 0)
- crumbs_layout.setSpacing(0)
- crumbs_cont_layout.addWidget(self.crumbs_panel)
- # Clicking on empty space to the right puts the bar into edit mode
- self.switch_space = QtWidgets.QWidget(self)
- # s_policy = self.switch_space.sizePolicy()
- # s_policy.setHorizontalStretch(1)
- # self.switch_space.setSizePolicy(s_policy)
- self.switch_space.mouseReleaseEvent = self.switch_space_mouse_up
- # crumbs_cont_layout.addWidget(self.switch_space)
- crumbs_layout.set_space_widget(self.switch_space)
- self.btn_browse = QtWidgets.QToolButton(self)
- self.btn_browse.setAutoRaise(True)
- self.btn_browse.setText("...")
- self.btn_browse.setToolTip("Browse for folder")
- self.btn_browse.clicked.connect(self._browse_for_folder)
- layout.addWidget(self.btn_browse)
- self.setMaximumHeight(self.line_address.height()) # FIXME:
- self.ignore_resize = False
- self.path_ = None
- self.set_path(Path())
- @staticmethod
- def init_completer(edit_widget, model):
- "Init QCompleter to work with filesystem"
- completer = QtWidgets.QCompleter(edit_widget)
- completer.setCaseSensitivity(Qt.CaseInsensitive)
- completer.setModel(model)
- # Optimize performance https://stackoverflow.com/a/33454284/1119602
- popup = completer.popup()
- popup.setUniformItemSizes(True)
- popup.setLayoutMode(QtWidgets.QListView.Batched)
- edit_widget.setCompleter(completer)
- edit_widget.textEdited.connect(model.setPathPrefix)
- return completer
- def get_icon(self, path: Union[str, Path]):
- "Path -> QIcon"
- fileinfo = QtCore.QFileInfo(str(path))
- dat = self.file_ico_prov.icon(fileinfo)
- if fileinfo.isHidden():
- pmap = QtGui.QPixmap(*TRANSP_ICON_SIZE)
- pmap.fill(Qt.transparent)
- painter = QtGui.QPainter(pmap)
- painter.setOpacity(0.5)
- dat.paint(painter, 0, 0, *TRANSP_ICON_SIZE)
- painter.end()
- dat = QtGui.QIcon(pmap)
- return dat
- def line_address_contextMenuEvent(self, event):
- self.line_address_context_menu_flag = True
- QtWidgets.QLineEdit.contextMenuEvent(self.line_address, event)
- def line_address_focusOutEvent(self, event):
- if getattr(self, 'line_address_context_menu_flag', False):
- self.line_address_context_menu_flag = False
- return # do not cancel edit on context menu
- self._cancel_edit()
- def _hidden_crumbs_menu_show(self):
- "SLOT: fill menu with hidden breadcrumbs list"
- self.mouse_pos_timer.start(100)
- menu = self.sender()
- if hasattr(self, 'actions_hidden_crumbs'):
- for action in self.actions_hidden_crumbs:
- menu.removeAction(action)
- self.actions_hidden_crumbs = []
- first_action = menu.actions()[0] # places section separator
- for i in self.crumbs_panel.layout().widgets('hidden'):
- action = QtWidgets.QAction(self.get_icon(i.path), i.text(), menu)
- action.path = i.path
- action.triggered.connect(self.set_path)
- menu.insertAction(first_action, action)
- self.actions_hidden_crumbs.append(action)
- first_action = action
- def init_rootmenu_places(self, menu):
- "Init common places actions in menu"
- menu.addSeparator()
- QSP = QtCore.QStandardPaths
- uname = os.environ.get('USER') or os.environ.get('USERNAME') or "Home"
- for name, path in (
- ("Desktop", QSP.writableLocation(QSP.DesktopLocation)),
- (uname, QSP.writableLocation(QSP.HomeLocation)),
- ("Documents", QSP.writableLocation(QSP.DocumentsLocation)),
- ("Downloads", QSP.writableLocation(QSP.DownloadLocation)),
- ):
- if self.os_type == "Windows":
- name = self.get_path_label(path)
- action = menu.addAction(self.get_icon(path), name)
- action.path = path
- action.triggered.connect(self.set_path)
- def get_path_label(self, drive_path):
- "Try to get path label using Shell32 on Windows"
- return get_path_label(drive_path.replace("/", "\\"))
- @staticmethod
- def list_network_locations():
- "List (name, path) locations in Network Shortcuts folder on Windows"
- HOME = QtCore.QStandardPaths.HomeLocation
- user_folder = QtCore.QStandardPaths.writableLocation(HOME)
- network_shortcuts = user_folder + "/AppData/Roaming/Microsoft/Windows/Network Shortcuts"
- for i in Path(network_shortcuts).iterdir():
- if not i.is_dir():
- continue
- link = Path(i) / "target.lnk"
- if not link.exists():
- continue
- path = QtCore.QFileInfo(str(link)).symLinkTarget()
- if path: # `symLinkTarget` doesn't read e.g. FTP links
- yield i.name, path
- def update_rootmenu_devices(self):
- "Init or rebuild device actions in menu"
- menu = self.btn_root_crumb.menu()
- if hasattr(self, 'actions_devices'):
- for action in self.actions_devices:
- menu.removeAction(action)
- self.actions_devices = [menu.addSeparator()]
- for i in QtCore.QStorageInfo.mountedVolumes(): # QDir.drives():
- path, label = i.rootPath(), i.displayName()
- if label == path and self.os_type == "Windows":
- label = self.get_path_label(path)
- elif self.os_type == "Linux" and not path.startswith("/media"):
- # Add to list only volumes in /media
- continue
- caption = "%s (%s)" % (label, path.rstrip(r"\/"))
- action = menu.addAction(self.get_icon(path), caption)
- action.path = path
- action.triggered.connect(self.set_path)
- self.actions_devices.append(action)
- if self.os_type == "Windows": # Network locations
- for label, path in self.list_network_locations():
- action = menu.addAction(self.get_icon(path), label)
- action.path = path
- action.triggered.connect(self.set_path)
- self.actions_devices.append(action)
- def _browse_for_folder(self):
- path = QtWidgets.QFileDialog.getExistingDirectory(
- self, "Choose folder", str(self.path()))
- if path:
- self.set_path(path)
- def line_address_keyPressEvent(self, event):
- "Actions to take after a key press in text address field"
- if event.key() == Qt.Key_Escape:
- self._cancel_edit()
- elif event.key() in (Qt.Key_Return, Qt.Key_Enter):
- self.set_path(self.line_address.text())
- self._show_address_field(False)
- # elif event.text() == os.path.sep: # FIXME: separator cannot be pasted
- # print('fill completer data here')
- # paths = [str(i) for i in
- # Path(self.line_address.text()).iterdir() if i.is_dir()]
- # self.completer.model().setStringList(paths)
- else:
- QtWidgets.QLineEdit.keyPressEvent(self.line_address, event)
- def _clear_crumbs(self):
- layout = self.crumbs_panel.layout()
- while layout.count():
- widget = layout.takeAt(0).widget()
- if widget:
- # Unset style or `StyleProxy.drawPrimitive` is called once with
- # mysterious `QWidget` instead of `QToolButton` (Windows 7)
- widget.setStyle(None)
- widget.deleteLater()
-
- @staticmethod
- def path_title(path: Path):
- "Get folder name or drive name"
- # FIXME: C:\ has no name. Use rstrip on Windows only?
- return path.name or str(path).upper().rstrip(os.path.sep)
- def _insert_crumb(self, path):
- btn = QtWidgets.QToolButton(self.crumbs_panel)
- btn.setAutoRaise(True)
- btn.setPopupMode(btn.MenuButtonPopup)
- btn.setStyle(self.style_crumbs)
- btn.mouseMoveEvent = self.crumb_mouse_move
- btn.setMouseTracking(True)
- btn.setText(self.path_title(path))
- btn.path = path
- btn.clicked.connect(self.crumb_clicked)
- menu = MenuListView(btn)
- menu.aboutToShow.connect(self.crumb_menu_show)
- menu.setModel(self.fs_model)
- menu.clicked.connect(self.crumb_menuitem_clicked)
- menu.activated.connect(self.crumb_menuitem_clicked)
- menu.aboutToHide.connect(self.mouse_pos_timer.stop)
- btn.setMenu(menu)
- self.crumbs_panel.layout().insertWidget(0, btn)
- btn.setMinimumSize(btn.minimumSizeHint()) # fixed size breadcrumbs
- sp = btn.sizePolicy()
- sp.setVerticalPolicy(sp.Minimum)
- btn.setSizePolicy(sp)
- # print(self._check_space_width(btn.minimumWidth()))
- # print(btn.size(), btn.sizeHint(), btn.minimumSizeHint())
- def crumb_mouse_move(self, event):
- ...
- # print('move!')
- def crumb_menuitem_clicked(self, index):
- "SLOT: breadcrumb menu item was clicked"
- self.set_path(index.data(Qt.EditRole))
- def crumb_clicked(self):
- "SLOT: breadcrumb was clicked"
- self.set_path(self.sender().path)
- def crumb_menu_show(self):
- "SLOT: fill subdirectory list on menu open"
- menu = self.sender()
- self.fs_model.setPathPrefix(str(menu.parent().path) + os.path.sep)
- menu.clear_selection() # clear currentIndex after applying new model
- self.mouse_pos_timer.start(100)
- def set_path(self, path=None):
- """
- Set path displayed in this BreadcrumbsAddressBar
- Returns `False` if path does not exist or permission error.
- Can be used as a SLOT: `sender().path` is used if `path` is `None`)
- """
- path, emit_err = Path(path or self.sender().path), None
- try: # C: -> C:\, folder\..\folder -> folder
- path = path.resolve()
- except PermissionError:
- emit_err = self.listdir_error
- if not path.exists():
- emit_err = self.path_error
- self._cancel_edit() # exit edit mode
- if emit_err: # permission error or path does not exist
- emit_err.emit(path)
- return False
- self._clear_crumbs()
- self.path_ = path
- self.line_address.setText(str(path))
- self._insert_crumb(path)
- for i in path.parents:
- if i == cwd_path:
- break
- self._insert_crumb(i)
- self.path_icon.setPixmap(self.get_icon(self.path_).pixmap(16, 16))
- self.path_selected.emit(self.path_)
- return True
- def _cancel_edit(self):
- "Set edit line text back to current path and switch to view mode"
- self.line_address.setText(str(self.path())) # revert path
- self._show_address_field(False) # switch back to breadcrumbs view
- def path(self):
- "Get path displayed in this BreadcrumbsAddressBar"
- return self.path_
- def switch_space_mouse_up(self, event):
- "EVENT: switch_space mouse clicked"
- if event.button() != Qt.LeftButton: # left click only
- return
- self._show_address_field(True)
- def _show_address_field(self, b_show):
- "Show text address field"
- if b_show:
- self.crumbs_container.hide()
- self.line_address.show()
- self.line_address.setFocus()
- self.line_address.selectAll()
- else:
- self.line_address.hide()
- self.crumbs_container.show()
- def crumb_hide_show(self, widget, state:bool):
- "SLOT: a breadcrumb is hidden/removed or shown"
- layout = self.crumbs_panel.layout()
- arrow = Qt.LeftArrow if layout.count_hidden() > 0 else Qt.RightArrow
- self.btn_root_crumb.setArrowType(arrow)
- # if layout.count_hidden() > 0:
- # ico = QtGui.QIcon("iconfinder_icon-ios7-arrow-left_211689.png")
- # else:
- # ico = QtGui.QIcon("iconfinder_icon-ios7-arrow-right_211607.png")
- # self.btn_root_crumb.setIcon(ico)
- def minimumSizeHint(self):
- # print(self.layout().minimumSize().width())
- return QtCore.QSize(150, self.line_address.height())
- def mouse_pos_timer_event(self):
- "Monitor breadcrumbs under cursor and switch popup menus"
- pos = QtGui.QCursor.pos()
- app = QtCore.QCoreApplication.instance()
- w = app.widgetAt(pos)
- active_menu = app.activePopupWidget()
- if (w and isinstance(w, QtWidgets.QToolButton) and
- w is not active_menu.parent() and
- (w is self.btn_root_crumb or w.parent() is self.crumbs_panel)
- ):
- active_menu.close()
- w.showMenu()
- class StyleProxy(QtWidgets.QProxyStyle):
- win_modern = ("windowsxp", "windowsvista")
- def __init__(self, style, arrow_pix):
- super().__init__(style)
- self.arrow_pix = arrow_pix
- self.stylename = self.baseStyle().objectName()
- def drawPrimitive(self, pe, opt, p: QtGui.QPainter, widget):
- # QToolButton elements:
- # 13: PE_PanelButtonCommand (Fusion) - Fusion button background, called from 15 and 24 calls
- # 15: PE_PanelButtonTool (Windows, Fusion) - left part background (XP/Vista styles do not draw it with `drawPrimitive`)
- # 19: PE_IndicatorArrowDown (Windows, Fusion) - right part down arrow (XP/Vista styles draw it in 24 call)
- # 24: PE_IndicatorButtonDropDown (Windows, XP, Vista, Fusion) - right part background (+arrow for XP/Vista)
- #
- # Arrow is drawn along with PE_IndicatorButtonDropDown (XP/Vista)
- # https://github.com/qt/qtbase/blob/0c51a8756377c40180619046d07b35718fcf1784/src/plugins/styles/windowsvista/qwindowsxpstyle.cpp#L1406
- # https://github.com/qt/qtbase/blob/0c51a8756377c40180619046d07b35718fcf1784/src/plugins/styles/windowsvista/qwindowsxpstyle.cpp#L666
- # drawBackground paints with DrawThemeBackgroundEx WinApi function
- # https://docs.microsoft.com/en-us/windows/win32/api/uxtheme/nf-uxtheme-drawthemebackgroundex
- if (self.stylename in self.win_modern and
- pe == self.PE_IndicatorButtonDropDown
- ):
- pe = self.PE_IndicatorArrowDown # see below
- if pe == self.PE_IndicatorArrowDown:
- opt_ = QtWidgets.QStyleOptionToolButton()
- widget.initStyleOption(opt_)
- rc = super().subControlRect(self.CC_ToolButton, opt_,
- self.SC_ToolButtonMenu, widget)
- if self.stylename in self.win_modern:
- # By default PE_IndicatorButtonDropDown draws arrow along
- # with right button art. Draw 2px clipped left part instead
- path = QtGui.QPainterPath()
- path.addRect(QtCore.QRectF(rc))
- p.setClipPath(path)
- super().drawPrimitive(self.PE_PanelButtonTool, opt, p, widget)
- # centered square
- rc.moveTop(int((rc.height() - rc.width()) / 2))
- rc.setHeight(rc.width())
- # p.setRenderHint(p.Antialiasing)
- p.drawPixmap(rc, self.arrow_pix, QtCore.QRect())
- else:
- super().drawPrimitive(pe, opt, p, widget)
- def subControlRect(self, cc, opt, sc, widget):
- rc = super().subControlRect(cc, opt, sc, widget)
- if (self.stylename in self.win_modern and
- sc == self.SC_ToolButtonMenu
- ):
- rc.adjust(-2, 0, 0, 0) # cut 2 left pixels to create flat edge
- return rc
|