addressbar.py 20 KB

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