addressbar.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. """
  2. Qt navigation bar with breadcrumbs
  3. Andrey Makarov, 2019
  4. """
  5. import os
  6. import platform
  7. from pathlib import Path
  8. from typing import Union
  9. from qtpy import QtCore, QtGui, QtWidgets
  10. from qtpy.QtCore import Qt
  11. from .layouts import LeftHBoxLayout
  12. from .models_views import FilenameModel, MenuListView
  13. from .stylesheet import style_root_toolbutton
  14. if platform.system() == "Windows":
  15. from .platform.windows import get_path_label
  16. TRANSP_ICON_SIZE = 40, 40 # px, size of generated semi-transparent icons
  17. cwd_path = Path() # working dir (.) https://stackoverflow.com/q/51330297
  18. class BreadcrumbsAddressBar(QtWidgets.QFrame):
  19. "Windows Explorer-like address bar"
  20. listdir_error = QtCore.Signal(Path) # failed to list a directory
  21. path_error = QtCore.Signal(Path) # entered path does not exist
  22. path_selected = QtCore.Signal(Path)
  23. def __init__(self, parent=None):
  24. super().__init__(parent)
  25. self.os_type = platform.system()
  26. self.style_crumbs = StyleProxy(
  27. QtWidgets.QStyleFactory.create(
  28. QtWidgets.QApplication.instance().style().objectName()
  29. ),
  30. QtGui.QPixmap("iconfinder_icon-ios7-arrow-right_211607.png")
  31. )
  32. layout = QtWidgets.QHBoxLayout(self)
  33. self.file_ico_prov = QtWidgets.QFileIconProvider()
  34. self.fs_model = FilenameModel('dirs')
  35. pal = self.palette()
  36. pal.setColor(QtGui.QPalette.Background,
  37. pal.color(QtGui.QPalette.Base))
  38. self.setPalette(pal)
  39. self.setAutoFillBackground(True)
  40. self.setFrameShape(self.StyledPanel)
  41. self.layout().setContentsMargins(4, 0, 0, 0)
  42. self.layout().setSpacing(0)
  43. self.path_icon = QtWidgets.QLabel(self)
  44. layout.addWidget(self.path_icon)
  45. # Edit presented path textually
  46. self.line_address = QtWidgets.QLineEdit(self)
  47. self.line_address.setFrame(False)
  48. self.line_address.hide()
  49. self.line_address.keyPressEvent = self.line_address_keyPressEvent
  50. self.line_address.focusOutEvent = self.line_address_focusOutEvent
  51. self.line_address.contextMenuEvent = self.line_address_contextMenuEvent
  52. layout.addWidget(self.line_address)
  53. # Add QCompleter to address line
  54. completer = self.init_completer(self.line_address, self.fs_model)
  55. completer.activated.connect(self.set_path)
  56. # Container for `btn_crumbs_hidden`, `crumbs_panel`, `switch_space`
  57. self.crumbs_container = QtWidgets.QWidget(self)
  58. crumbs_cont_layout = QtWidgets.QHBoxLayout(self.crumbs_container)
  59. crumbs_cont_layout.setContentsMargins(0, 0, 0, 0)
  60. crumbs_cont_layout.setSpacing(0)
  61. layout.addWidget(self.crumbs_container)
  62. # Monitor breadcrumbs under cursor and switch popup menus
  63. self.mouse_pos_timer = QtCore.QTimer(self)
  64. self.mouse_pos_timer.timeout.connect(self.mouse_pos_timer_event)
  65. # Hidden breadcrumbs menu button
  66. self.btn_root_crumb = QtWidgets.QToolButton(self)
  67. self.btn_root_crumb.setAutoRaise(True)
  68. self.btn_root_crumb.setPopupMode(QtWidgets.QToolButton.InstantPopup)
  69. self.btn_root_crumb.setArrowType(Qt.RightArrow)
  70. self.btn_root_crumb.setStyleSheet(style_root_toolbutton)
  71. self.btn_root_crumb.setMinimumSize(self.btn_root_crumb.minimumSizeHint())
  72. crumbs_cont_layout.addWidget(self.btn_root_crumb)
  73. menu = QtWidgets.QMenu(self.btn_root_crumb) # FIXME:
  74. menu.aboutToShow.connect(self._hidden_crumbs_menu_show)
  75. menu.aboutToHide.connect(self.mouse_pos_timer.stop)
  76. self.btn_root_crumb.setMenu(menu)
  77. self.init_rootmenu_places(menu) # Desktop, Home, Downloads...
  78. self.update_rootmenu_devices() # C:, D:...
  79. # Container for breadcrumbs
  80. self.crumbs_panel = QtWidgets.QWidget(self)
  81. crumbs_layout = LeftHBoxLayout(self.crumbs_panel)
  82. crumbs_layout.widget_state_changed.connect(self.crumb_hide_show)
  83. crumbs_layout.setContentsMargins(0, 0, 0, 0)
  84. crumbs_layout.setSpacing(0)
  85. crumbs_cont_layout.addWidget(self.crumbs_panel)
  86. # Clicking on empty space to the right puts the bar into edit mode
  87. self.switch_space = QtWidgets.QWidget(self)
  88. # s_policy = self.switch_space.sizePolicy()
  89. # s_policy.setHorizontalStretch(1)
  90. # self.switch_space.setSizePolicy(s_policy)
  91. self.switch_space.mouseReleaseEvent = self.switch_space_mouse_up
  92. # crumbs_cont_layout.addWidget(self.switch_space)
  93. crumbs_layout.set_space_widget(self.switch_space)
  94. self.btn_browse = QtWidgets.QToolButton(self)
  95. self.btn_browse.setAutoRaise(True)
  96. self.btn_browse.setText("...")
  97. self.btn_browse.setToolTip("Browse for folder")
  98. self.btn_browse.clicked.connect(self._browse_for_folder)
  99. layout.addWidget(self.btn_browse)
  100. self.setMaximumHeight(self.line_address.height()) # FIXME:
  101. self.ignore_resize = False
  102. self.path_ = None
  103. self.set_path(Path())
  104. @staticmethod
  105. def init_completer(edit_widget, model):
  106. "Init QCompleter to work with filesystem"
  107. completer = QtWidgets.QCompleter(edit_widget)
  108. completer.setCaseSensitivity(Qt.CaseInsensitive)
  109. completer.setModel(model)
  110. # Optimize performance https://stackoverflow.com/a/33454284/1119602
  111. popup = completer.popup()
  112. popup.setUniformItemSizes(True)
  113. popup.setLayoutMode(QtWidgets.QListView.Batched)
  114. edit_widget.setCompleter(completer)
  115. edit_widget.textEdited.connect(model.setPathPrefix)
  116. return completer
  117. def get_icon(self, path: Union[str, Path]):
  118. "Path -> QIcon"
  119. fileinfo = QtCore.QFileInfo(str(path))
  120. dat = self.file_ico_prov.icon(fileinfo)
  121. if fileinfo.isHidden():
  122. pmap = QtGui.QPixmap(*TRANSP_ICON_SIZE)
  123. pmap.fill(Qt.transparent)
  124. painter = QtGui.QPainter(pmap)
  125. painter.setOpacity(0.5)
  126. dat.paint(painter, 0, 0, *TRANSP_ICON_SIZE)
  127. painter.end()
  128. dat = QtGui.QIcon(pmap)
  129. return dat
  130. def line_address_contextMenuEvent(self, event):
  131. self.line_address_context_menu_flag = True
  132. QtWidgets.QLineEdit.contextMenuEvent(self.line_address, event)
  133. def line_address_focusOutEvent(self, event):
  134. if getattr(self, 'line_address_context_menu_flag', False):
  135. self.line_address_context_menu_flag = False
  136. return # do not cancel edit on context menu
  137. self._cancel_edit()
  138. def _hidden_crumbs_menu_show(self):
  139. "SLOT: fill menu with hidden breadcrumbs list"
  140. self.mouse_pos_timer.start(100)
  141. menu = self.sender()
  142. if hasattr(self, 'actions_hidden_crumbs'):
  143. for action in self.actions_hidden_crumbs:
  144. menu.removeAction(action)
  145. self.actions_hidden_crumbs = []
  146. first_action = menu.actions()[0] # places section separator
  147. for i in self.crumbs_panel.layout().widgets('hidden'):
  148. action = QtWidgets.QAction(self.get_icon(i.path), i.text(), menu)
  149. action.path = i.path
  150. action.triggered.connect(self.set_path)
  151. menu.insertAction(first_action, action)
  152. self.actions_hidden_crumbs.append(action)
  153. first_action = action
  154. def init_rootmenu_places(self, menu):
  155. "Init common places actions in menu"
  156. menu.addSeparator()
  157. QSP = QtCore.QStandardPaths
  158. uname = os.environ.get('USER') or os.environ.get('USERNAME') or "Home"
  159. for name, path in (
  160. ("Desktop", QSP.writableLocation(QSP.DesktopLocation)),
  161. (uname, QSP.writableLocation(QSP.HomeLocation)),
  162. ("Documents", QSP.writableLocation(QSP.DocumentsLocation)),
  163. ("Downloads", QSP.writableLocation(QSP.DownloadLocation)),
  164. ):
  165. if self.os_type == "Windows":
  166. name = self.get_path_label(path)
  167. action = menu.addAction(self.get_icon(path), name)
  168. action.path = path
  169. action.triggered.connect(self.set_path)
  170. def get_path_label(self, drive_path):
  171. "Try to get path label using Shell32 on Windows"
  172. return get_path_label(drive_path.replace("/", "\\"))
  173. @staticmethod
  174. def list_network_locations():
  175. "List (name, path) locations in Network Shortcuts folder on Windows"
  176. HOME = QtCore.QStandardPaths.HomeLocation
  177. user_folder = QtCore.QStandardPaths.writableLocation(HOME)
  178. network_shortcuts = user_folder + "/AppData/Roaming/Microsoft/Windows/Network Shortcuts"
  179. for i in Path(network_shortcuts).iterdir():
  180. if not i.is_dir():
  181. continue
  182. link = Path(i) / "target.lnk"
  183. if not link.exists():
  184. continue
  185. path = QtCore.QFileInfo(str(link)).symLinkTarget()
  186. if path: # `symLinkTarget` doesn't read e.g. FTP links
  187. yield i.name, path
  188. def update_rootmenu_devices(self):
  189. "Init or rebuild device actions in menu"
  190. menu = self.btn_root_crumb.menu()
  191. if hasattr(self, 'actions_devices'):
  192. for action in self.actions_devices:
  193. menu.removeAction(action)
  194. self.actions_devices = [menu.addSeparator()]
  195. for i in QtCore.QStorageInfo.mountedVolumes(): # QDir.drives():
  196. path, label = i.rootPath(), i.displayName()
  197. if label == path and self.os_type == "Windows":
  198. label = self.get_path_label(path)
  199. elif self.os_type == "Linux" and not path.startswith("/media"):
  200. # Add to list only volumes in /media
  201. continue
  202. caption = "%s (%s)" % (label, path.rstrip(r"\/"))
  203. action = menu.addAction(self.get_icon(path), caption)
  204. action.path = path
  205. action.triggered.connect(self.set_path)
  206. self.actions_devices.append(action)
  207. if self.os_type == "Windows": # Network locations
  208. for label, path in self.list_network_locations():
  209. action = menu.addAction(self.get_icon(path), label)
  210. action.path = path
  211. action.triggered.connect(self.set_path)
  212. self.actions_devices.append(action)
  213. def _browse_for_folder(self):
  214. path = QtWidgets.QFileDialog.getExistingDirectory(
  215. self, "Choose folder", str(self.path()))
  216. if path:
  217. self.set_path(path)
  218. def line_address_keyPressEvent(self, event):
  219. "Actions to take after a key press in text address field"
  220. if event.key() == Qt.Key_Escape:
  221. self._cancel_edit()
  222. elif event.key() in (Qt.Key_Return, Qt.Key_Enter):
  223. self.set_path(self.line_address.text())
  224. self._show_address_field(False)
  225. # elif event.text() == os.path.sep: # FIXME: separator cannot be pasted
  226. # print('fill completer data here')
  227. # paths = [str(i) for i in
  228. # Path(self.line_address.text()).iterdir() if i.is_dir()]
  229. # self.completer.model().setStringList(paths)
  230. else:
  231. QtWidgets.QLineEdit.keyPressEvent(self.line_address, event)
  232. def _clear_crumbs(self):
  233. layout = self.crumbs_panel.layout()
  234. while layout.count():
  235. widget = layout.takeAt(0).widget()
  236. if widget:
  237. # Unset style or `StyleProxy.drawPrimitive` is called once with
  238. # mysterious `QWidget` instead of `QToolButton` (Windows 7)
  239. widget.setStyle(None)
  240. widget.deleteLater()
  241. @staticmethod
  242. def path_title(path: Path):
  243. "Get folder name or drive name"
  244. # FIXME: C:\ has no name. Use rstrip on Windows only?
  245. return path.name or str(path).upper().rstrip(os.path.sep)
  246. def _insert_crumb(self, path):
  247. btn = QtWidgets.QToolButton(self.crumbs_panel)
  248. btn.setAutoRaise(True)
  249. btn.setPopupMode(btn.MenuButtonPopup)
  250. btn.setStyle(self.style_crumbs)
  251. btn.mouseMoveEvent = self.crumb_mouse_move
  252. btn.setMouseTracking(True)
  253. btn.setText(self.path_title(path))
  254. btn.path = path
  255. btn.clicked.connect(self.crumb_clicked)
  256. menu = MenuListView(btn)
  257. menu.aboutToShow.connect(self.crumb_menu_show)
  258. menu.setModel(self.fs_model)
  259. menu.clicked.connect(self.crumb_menuitem_clicked)
  260. menu.activated.connect(self.crumb_menuitem_clicked)
  261. menu.aboutToHide.connect(self.mouse_pos_timer.stop)
  262. btn.setMenu(menu)
  263. self.crumbs_panel.layout().insertWidget(0, btn)
  264. btn.setMinimumSize(btn.minimumSizeHint()) # fixed size breadcrumbs
  265. sp = btn.sizePolicy()
  266. sp.setVerticalPolicy(sp.Minimum)
  267. btn.setSizePolicy(sp)
  268. # print(self._check_space_width(btn.minimumWidth()))
  269. # print(btn.size(), btn.sizeHint(), btn.minimumSizeHint())
  270. def crumb_mouse_move(self, event):
  271. ...
  272. # print('move!')
  273. def crumb_menuitem_clicked(self, index):
  274. "SLOT: breadcrumb menu item was clicked"
  275. self.set_path(index.data(Qt.EditRole))
  276. def crumb_clicked(self):
  277. "SLOT: breadcrumb was clicked"
  278. self.set_path(self.sender().path)
  279. def crumb_menu_show(self):
  280. "SLOT: fill subdirectory list on menu open"
  281. menu = self.sender()
  282. self.fs_model.setPathPrefix(str(menu.parent().path) + os.path.sep)
  283. menu.clear_selection() # clear currentIndex after applying new model
  284. self.mouse_pos_timer.start(100)
  285. def set_path(self, path=None):
  286. """
  287. Set path displayed in this BreadcrumbsAddressBar
  288. Returns `False` if path does not exist or permission error.
  289. Can be used as a SLOT: `sender().path` is used if `path` is `None`)
  290. """
  291. path, emit_err = Path(path or self.sender().path), None
  292. try: # C: -> C:\, folder\..\folder -> folder
  293. path = path.resolve()
  294. except PermissionError:
  295. emit_err = self.listdir_error
  296. if not path.exists():
  297. emit_err = self.path_error
  298. self._cancel_edit() # exit edit mode
  299. if emit_err: # permission error or path does not exist
  300. emit_err.emit(path)
  301. return False
  302. self._clear_crumbs()
  303. self.path_ = path
  304. self.line_address.setText(str(path))
  305. self._insert_crumb(path)
  306. for i in path.parents:
  307. if i == cwd_path:
  308. break
  309. self._insert_crumb(i)
  310. self.path_icon.setPixmap(self.get_icon(self.path_).pixmap(16, 16))
  311. self.path_selected.emit(self.path_)
  312. return True
  313. def _cancel_edit(self):
  314. "Set edit line text back to current path and switch to view mode"
  315. self.line_address.setText(str(self.path())) # revert path
  316. self._show_address_field(False) # switch back to breadcrumbs view
  317. def path(self):
  318. "Get path displayed in this BreadcrumbsAddressBar"
  319. return self.path_
  320. def switch_space_mouse_up(self, event):
  321. "EVENT: switch_space mouse clicked"
  322. if event.button() != Qt.LeftButton: # left click only
  323. return
  324. self._show_address_field(True)
  325. def _show_address_field(self, b_show):
  326. "Show text address field"
  327. if b_show:
  328. self.crumbs_container.hide()
  329. self.line_address.show()
  330. self.line_address.setFocus()
  331. self.line_address.selectAll()
  332. else:
  333. self.line_address.hide()
  334. self.crumbs_container.show()
  335. def crumb_hide_show(self, widget, state:bool):
  336. "SLOT: a breadcrumb is hidden/removed or shown"
  337. layout = self.crumbs_panel.layout()
  338. arrow = Qt.LeftArrow if layout.count_hidden() > 0 else Qt.RightArrow
  339. self.btn_root_crumb.setArrowType(arrow)
  340. # if layout.count_hidden() > 0:
  341. # ico = QtGui.QIcon("iconfinder_icon-ios7-arrow-left_211689.png")
  342. # else:
  343. # ico = QtGui.QIcon("iconfinder_icon-ios7-arrow-right_211607.png")
  344. # self.btn_root_crumb.setIcon(ico)
  345. def minimumSizeHint(self):
  346. # print(self.layout().minimumSize().width())
  347. return QtCore.QSize(150, self.line_address.height())
  348. def mouse_pos_timer_event(self):
  349. "Monitor breadcrumbs under cursor and switch popup menus"
  350. pos = QtGui.QCursor.pos()
  351. app = QtCore.QCoreApplication.instance()
  352. w = app.widgetAt(pos)
  353. active_menu = app.activePopupWidget()
  354. if (w and isinstance(w, QtWidgets.QToolButton) and
  355. w is not active_menu.parent() and
  356. (w is self.btn_root_crumb or w.parent() is self.crumbs_panel)
  357. ):
  358. active_menu.close()
  359. w.showMenu()
  360. class StyleProxy(QtWidgets.QProxyStyle):
  361. win_modern = ("windowsxp", "windowsvista")
  362. def __init__(self, style, arrow_pix):
  363. super().__init__(style)
  364. self.arrow_pix = arrow_pix
  365. self.stylename = self.baseStyle().objectName()
  366. def drawPrimitive(self, pe, opt, p: QtGui.QPainter, widget):
  367. # QToolButton elements:
  368. # 13: PE_PanelButtonCommand (Fusion) - Fusion button background, called from 15 and 24 calls
  369. # 15: PE_PanelButtonTool (Windows, Fusion) - left part background (XP/Vista styles do not draw it with `drawPrimitive`)
  370. # 19: PE_IndicatorArrowDown (Windows, Fusion) - right part down arrow (XP/Vista styles draw it in 24 call)
  371. # 24: PE_IndicatorButtonDropDown (Windows, XP, Vista, Fusion) - right part background (+arrow for XP/Vista)
  372. #
  373. # Arrow is drawn along with PE_IndicatorButtonDropDown (XP/Vista)
  374. # https://github.com/qt/qtbase/blob/0c51a8756377c40180619046d07b35718fcf1784/src/plugins/styles/windowsvista/qwindowsxpstyle.cpp#L1406
  375. # https://github.com/qt/qtbase/blob/0c51a8756377c40180619046d07b35718fcf1784/src/plugins/styles/windowsvista/qwindowsxpstyle.cpp#L666
  376. # drawBackground paints with DrawThemeBackgroundEx WinApi function
  377. # https://docs.microsoft.com/en-us/windows/win32/api/uxtheme/nf-uxtheme-drawthemebackgroundex
  378. if (self.stylename in self.win_modern and
  379. pe == self.PE_IndicatorButtonDropDown
  380. ):
  381. pe = self.PE_IndicatorArrowDown # see below
  382. if pe == self.PE_IndicatorArrowDown:
  383. opt_ = QtWidgets.QStyleOptionToolButton()
  384. widget.initStyleOption(opt_)
  385. rc = super().subControlRect(self.CC_ToolButton, opt_,
  386. self.SC_ToolButtonMenu, widget)
  387. if self.stylename in self.win_modern:
  388. # By default PE_IndicatorButtonDropDown draws arrow along
  389. # with right button art. Draw 2px clipped left part instead
  390. path = QtGui.QPainterPath()
  391. path.addRect(QtCore.QRectF(rc))
  392. p.setClipPath(path)
  393. super().drawPrimitive(self.PE_PanelButtonTool, opt, p, widget)
  394. # centered square
  395. rc.moveTop(int((rc.height() - rc.width()) / 2))
  396. rc.setHeight(rc.width())
  397. # p.setRenderHint(p.Antialiasing)
  398. p.drawPixmap(rc, self.arrow_pix, QtCore.QRect())
  399. else:
  400. super().drawPrimitive(pe, opt, p, widget)
  401. def subControlRect(self, cc, opt, sc, widget):
  402. rc = super().subControlRect(cc, opt, sc, widget)
  403. if (self.stylename in self.win_modern and
  404. sc == self.SC_ToolButtonMenu
  405. ):
  406. rc.adjust(-2, 0, 0, 0) # cut 2 left pixels to create flat edge
  407. return rc