├── .python-version ├── src └── breadcrumbsaddressbar │ ├── py.typed │ ├── __init__.py │ ├── iconfinder_icon-ios7-arrow-left_211689.png │ ├── iconfinder_icon-ios7-arrow-right_211607.png │ ├── stylesheet.py │ ├── platform │ ├── common.py │ └── windows.py │ ├── layouts.py │ ├── models_views.py │ └── breadcrumbsaddressbar.py ├── .gitignore ├── preview.gif ├── README.md ├── .dockerignore ├── pyproject.toml ├── LICENSE ├── .vscode ├── launch.json └── tasks.json ├── docker_import.py ├── example.py ├── Dockerfile └── uv.lock /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /src/breadcrumbsaddressbar/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.egg-info 3 | -------------------------------------------------------------------------------- /src/breadcrumbsaddressbar/__init__.py: -------------------------------------------------------------------------------- 1 | from .breadcrumbsaddressbar import * 2 | -------------------------------------------------------------------------------- /preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Winand/breadcrumbsaddressbar/HEAD/preview.gif -------------------------------------------------------------------------------- /src/breadcrumbsaddressbar/iconfinder_icon-ios7-arrow-left_211689.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Winand/breadcrumbsaddressbar/HEAD/src/breadcrumbsaddressbar/iconfinder_icon-ios7-arrow-left_211689.png -------------------------------------------------------------------------------- /src/breadcrumbsaddressbar/iconfinder_icon-ios7-arrow-right_211607.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Winand/breadcrumbsaddressbar/HEAD/src/breadcrumbsaddressbar/iconfinder_icon-ios7-arrow-right_211607.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Address bar Qt widget with breadcrumb navigation 2 | ![](preview.gif) 3 | 4 | Depends on [QtPy](https://github.com/spyder-ide/qtpy) abstraction layer library. 5 | 6 | Tested in Windows 10 only. 7 | 8 | 9 | ## Assets 10 | * [Right Arrow](https://www.iconfinder.com/icons/211607/right_arrow_icon), MIT 11 | * [Left Arrow](https://www.iconfinder.com/icons/211689/left_arrow_icon), MIT 12 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | **/.classpath 3 | **/.dockerignore 4 | **/.env 5 | **/.git 6 | **/.gitignore 7 | **/.project 8 | **/.settings 9 | **/.toolstarget 10 | **/.vs 11 | **/.vscode 12 | **/*.*proj.user 13 | **/*.dbmdl 14 | **/*.jfm 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/compose* 19 | **/Dockerfile* 20 | **/node_modules 21 | **/npm-debug.log 22 | **/obj 23 | **/secrets.dev.yaml 24 | **/values.dev.yaml 25 | -------------------------------------------------------------------------------- /src/breadcrumbsaddressbar/stylesheet.py: -------------------------------------------------------------------------------- 1 | "Stylesheets to customize Qt controls" 2 | 3 | from pathlib import Path 4 | 5 | assets_path = Path(__file__).parent.as_posix() 6 | 7 | style_root_toolbutton = f""" 8 | QToolButton::right-arrow {{ 9 | image: url({assets_path}/iconfinder_icon-ios7-arrow-right_211607.png); 10 | }} 11 | QToolButton::left-arrow {{ 12 | image: url({assets_path}/iconfinder_icon-ios7-arrow-left_211689.png); 13 | }} 14 | QToolButton::menu-indicator {{ 15 | image: none; /* https://stackoverflow.com/a/19993662 */ 16 | }} 17 | """ 18 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "breadcrumbsaddressbar" 3 | version = "0.1.0-dev" 4 | description = "Address bar Qt widget with breadcrumb navigation" 5 | readme = "README.md" 6 | authors = [ 7 | { name = "Andrey Makarov", email = "winandfx@gmail.com" } 8 | ] 9 | requires-python = ">=3.12" 10 | dependencies = [ 11 | "pywin32>=308 ; sys_platform == 'win32'", 12 | "qtpy>=2.4.3", 13 | ] 14 | 15 | [build-system] 16 | requires = ["hatchling"] 17 | build-backend = "hatchling.build" 18 | 19 | [dependency-groups] 20 | dev = [ 21 | "pyside6-essentials>=6.8.2.1", 22 | ] 23 | 24 | [tool.ruff] 25 | select = ["ALL"] 26 | ignore = [ 27 | "Q000", # single quotes 28 | "ERA001", # commented-out code 29 | ] 30 | -------------------------------------------------------------------------------- /src/breadcrumbsaddressbar/platform/common.py: -------------------------------------------------------------------------------- 1 | """ 2 | Define platform-specific functions with decorators 3 | https://stackoverflow.com/a/60244993 4 | """ 5 | 6 | import platform 7 | import sys 8 | from typing import Union as U 9 | 10 | 11 | class _Not_Implemented: 12 | def __init__(self, func_name): 13 | self.func_name = func_name 14 | 15 | def __call__(self, *args, **kwargs): 16 | raise NotImplementedError( 17 | f"Function {self.func_name} is not defined " 18 | f"for platform {platform.system()}." 19 | ) 20 | 21 | 22 | def if_platform(*plat: U[str, list[str], tuple[str]]): 23 | frame = sys._getframe().f_back 24 | def _ifdef(func=None): 25 | nonlocal plat 26 | if isinstance(plat, str): 27 | plat = plat, 28 | if platform.system() in plat: 29 | return func or True 30 | if func.__name__ in frame.f_locals: 31 | return frame.f_locals[func.__name__] 32 | return _Not_Implemented(func.__name__) 33 | return _ifdef 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Makarov Andrey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Python: Current file", 5 | "type": "debugpy", 6 | "request": "launch", 7 | "program": "${file}", 8 | "console": "integratedTerminal" 9 | }, 10 | { 11 | "name": "Docker-WSLg: Python - Current file", 12 | "type": "docker", 13 | "request": "launch", 14 | "preLaunchTask": "docker-run: debug", 15 | "python": { 16 | "pathMappings": [ 17 | { 18 | "localRoot": "${workspaceFolder}", 19 | "remoteRoot": "/app" 20 | } 21 | ], 22 | "projectType": "general" 23 | } 24 | // https://github.com/microsoft/vscode-docker/issues/3200 25 | // , "removeContainerAfterDebug": false 26 | }, 27 | { 28 | "name": "Docker-XServer: Python - Current file", 29 | "type": "docker", 30 | "request": "launch", 31 | "preLaunchTask": "docker-run: debug (XServer)", 32 | "python": { 33 | "pathMappings": [ 34 | { 35 | "localRoot": "${workspaceFolder}", 36 | "remoteRoot": "/app" 37 | } 38 | ], 39 | "projectType": "general" 40 | } 41 | } 42 | ] 43 | } -------------------------------------------------------------------------------- /docker_import.py: -------------------------------------------------------------------------------- 1 | """ 2 | Proxy module which executes Python file passed in arguments. Backslashes in 3 | file path are converted to forward slashs. 4 | Path variables in VS Code use system path separator so they cannot be used 5 | to start debugging in Docker. 6 | 7 | To debug current file in Docker "docker-run" task should be modified like this: 8 | ... 9 | "python": { 10 | "args": ["${relativeFile}", "arg1", "arg2", ...], 11 | "file": "docker_import.py" 12 | } 13 | See also: 14 | VS Code command "Docker: Add Docker Files to Workspace..." 15 | https://code.visualstudio.com/docs/containers/debug-common 16 | https://code.visualstudio.com/docs/containers/debug-python 17 | Related: https://stackoverflow.com/questions/63910258/vscode-copy-relative-path-with-posix-forward-slashes 18 | """ 19 | 20 | import importlib.util 21 | import os 22 | import sys 23 | from pathlib import Path 24 | 25 | sys.argv.pop(0) # remove docker_import.py from argv 26 | sys.argv[0] = sys.argv[0].replace("\\", "/") # convert path separator 27 | file_path = Path(sys.argv[0]) 28 | if Path(__file__) == file_path: 29 | raise RuntimeError("Cannot start docker_import.py file (recursion)") 30 | 31 | os.chdir(file_path.parent) 32 | sys.path.insert(0, '') # import modules from current directory 33 | 34 | spec = importlib.util.spec_from_file_location('__main__', file_path.name) 35 | mod = importlib.util.module_from_spec(spec) 36 | sys.modules["__main__"] = mod 37 | spec.loader.exec_module(mod) 38 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | from qtpy import QtWidgets 4 | 5 | from breadcrumbsaddressbar import BreadcrumbsAddressBar 6 | from breadcrumbsaddressbar.platform.common import if_platform 7 | 8 | if platform.system() == "Windows": 9 | from breadcrumbsaddressbar.platform.windows import ( 10 | event_device_connection, parse_message) 11 | 12 | 13 | if __name__ == '__main__': 14 | class Form(QtWidgets.QDialog): 15 | def perm_err(self, path): 16 | print('perm err', path) 17 | 18 | def path_err(self, path): 19 | print('path err', path) 20 | 21 | def __init__(self): # pylint: disable=super-init-not-called 22 | print(QtWidgets.QStyleFactory.keys()) 23 | # style = QtWidgets.QStyleFactory.create("fusion") 24 | # self.app.setStyle(style) 25 | super().__init__() 26 | self.setLayout(QtWidgets.QHBoxLayout()) 27 | self.address = BreadcrumbsAddressBar() 28 | self.layout().addWidget(self.address) 29 | self.address.listdir_error.connect(self.perm_err) 30 | self.address.path_error.connect(self.path_err) 31 | 32 | @if_platform('Windows') 33 | def nativeEvent(self, eventType, message): 34 | msg = parse_message(message) 35 | devices = event_device_connection(msg) 36 | if devices: 37 | print("insert/remove device") 38 | self.address.update_rootmenu_devices() 39 | return False, 0 40 | 41 | 42 | app = QtWidgets.QApplication([]) 43 | form = Form() 44 | form.exec_() 45 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "docker-build", 6 | "label": "docker-build", 7 | "platform": "python", 8 | "dockerBuild": { 9 | "tag": "breadcrumbsaddressbar:latest", 10 | "dockerfile": "${workspaceFolder}/Dockerfile", 11 | "context": "${workspaceFolder}", 12 | "pull": true 13 | } 14 | }, 15 | { 16 | // WSLg https://github.com/microsoft/wslg 17 | // https://github.com/microsoft/wslg/blob/main/samples/container/Containers.md 18 | "type": "docker-run", 19 | "label": "docker-run: debug", 20 | "dependsOn": [ 21 | "docker-build" 22 | ], 23 | "python": { 24 | "args": ["${relativeFile}"], 25 | "file": "docker_import.py" 26 | }, 27 | "dockerRun": { 28 | // https://code.visualstudio.com/docs/containers/reference#_dockerrun-object-properties 29 | "volumes": [ 30 | { 31 | "localPath": "/run/desktop/mnt/host/wslg", 32 | "containerPath": "/mnt/wslg" 33 | }, 34 | { 35 | // https://unix.stackexchange.com/questions/196677/what-is-tmp-x11-unix 36 | "localPath": "/run/desktop/mnt/host/wslg/.X11-unix", 37 | "containerPath": "/tmp/.X11-unix" 38 | }, 39 | { 40 | "localPath": "/usr/lib/wsl", 41 | "containerPath": "/usr/lib/wsl" 42 | }, 43 | { 44 | "localPath": "${workspaceFolder}", 45 | "containerPath": "/app" 46 | } 47 | ], 48 | "env": { 49 | "DISPLAY": ":0", 50 | "WAYLAND_DISPLAY": "wayland-0", 51 | "XDG_RUNTIME_DIR": "/mnt/wslg/runtime-dir", 52 | "PULSE_SERVER": "/mnt/wslg/PulseServer", 53 | "LD_LIBRARY_PATH": "/usr/lib/wsl/lib", 54 | // "QT_DEBUG_PLUGINS": "1", 55 | } 56 | // https://github.com/microsoft/vscode-docker/issues/3200 57 | // , "remove": false 58 | } 59 | }, 60 | { 61 | // XServer 62 | "type": "docker-run", 63 | "label": "docker-run: debug (XServer)", 64 | "dependsOn": [ 65 | "docker-build" 66 | ], 67 | "python": { 68 | "args": ["${relativeFile}"], 69 | "file": "docker_import.py" 70 | }, 71 | "dockerRun": { 72 | "volumes": [ 73 | { 74 | "localPath": "${workspaceFolder}", 75 | "containerPath": "/app" 76 | } 77 | ], 78 | "env": { 79 | "DISPLAY": "host.docker.internal:0.0" 80 | } 81 | } 82 | } 83 | ] 84 | } -------------------------------------------------------------------------------- /src/breadcrumbsaddressbar/platform/windows.py: -------------------------------------------------------------------------------- 1 | import ctypes, pythoncom 2 | from ctypes import POINTER, Structure, byref 3 | from ctypes.wintypes import DWORD, WORD, USHORT, BYTE, MSG 4 | shell32 = ctypes.windll.shell32 5 | ole32 = ctypes.windll.ole32 6 | 7 | 8 | WM_DEVICECHANGE = 0x219 # Notifies an application of a change to the hardware configuration of a device or the computer. 9 | DBT_DEVICEARRIVAL = 0x8000 # A device or piece of media has been inserted and is now available. 10 | DBT_DEVICEREMOVECOMPLETE = 0x8004 # A device or piece of media has been removed. 11 | DBT_DEVTYP_VOLUME = 2 # Logical volume 12 | class DEV_BROADCAST_HDR(Structure): 13 | _fields_ = [ 14 | ("dbch_size", DWORD), 15 | ("dbch_devicetype", DWORD), 16 | ("dbch_reserved", DWORD) 17 | ] 18 | class DEV_BROADCAST_VOLUME(Structure): 19 | _fields_ = [ 20 | ("dbcv_size", DWORD), 21 | ("dbcv_devicetype", DWORD), 22 | ("dbcv_reserved", DWORD), 23 | ("dbcv_unitmask", DWORD), 24 | ("dbcv_flags", WORD) 25 | ] 26 | 27 | 28 | def parse_message(message): 29 | "Parse Windows message" 30 | return MSG.from_address(message.__int__()) 31 | 32 | 33 | def event_device_connection(message) -> tuple: 34 | """ 35 | Device insert/remove message 36 | 37 | Returns: list of drive letters, True (insert) / False (remove) 38 | """ 39 | if (message.message != WM_DEVICECHANGE or 40 | message.wParam not in (DBT_DEVICEARRIVAL, DBT_DEVICEREMOVECOMPLETE)): 41 | return 42 | devvol = DEV_BROADCAST_HDR.from_address(message.lParam) 43 | if devvol.dbch_devicetype == DBT_DEVTYP_VOLUME: 44 | mask = DEV_BROADCAST_VOLUME.from_address(message.lParam).dbcv_unitmask 45 | letters = [chr(65+sh) for sh in range(26) if mask>>sh&1] 46 | return letters, message.wParam == DBT_DEVICEARRIVAL 47 | 48 | 49 | class SHITEMID(Structure): 50 | _fields_ = [ 51 | ("cb", USHORT), 52 | ("abID", BYTE * 1), 53 | ] 54 | class ITEMIDLIST(Structure): 55 | _fields_ = [ 56 | ("mkid", SHITEMID)] 57 | NORMALDISPLAY = 0x00000000 # Samsung Evo 850 (C:) 58 | PARENTRELATIVEPARSING = 0x80018001 # C: 59 | DESKTOPABSOLUTEPARSING = 0x80028000 # C:\ 60 | PARENTRELATIVEEDITING = 0x80031001 # Samsung Evo 850 61 | DESKTOPABSOLUTEEDITING = 0x8004c000 # C:\ 62 | FILESYSPATH = 0x80058000 # C:\ 63 | URL = 0x80068000 # file:///C:/ 64 | PARENTRELATIVEFORADDRESSBAR = 0x8007c001 # C: 65 | PARENTRELATIVE = 0x80080001 # Samsung Evo 850 (C:) 66 | 67 | 68 | def get_path_label(path): 69 | "Get label for path (https://stackoverflow.com/a/29198314)" 70 | idlist = POINTER(ITEMIDLIST)() 71 | ret = shell32.SHParseDisplayName(path, 0, byref(idlist), 0, 0) 72 | if ret: 73 | raise Exception("Exception %d in SHParseDisplayName" % ret) 74 | # x = (BYTE * (idlist.contents.mkid.cb-2)).from_address(addressof(idlist.contents)+2) 75 | # print(bytes(x)) 76 | name = ctypes.c_wchar_p() 77 | ret = shell32.SHGetNameFromIDList(idlist, PARENTRELATIVEEDITING, 78 | byref(name)) 79 | if ret: 80 | raise Exception("Exception %d in SHGetNameFromIDList" % ret) 81 | return name.value 82 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/astral-sh/uv:0.6.2 AS uv 2 | COPY pyproject.toml uv.lock / 3 | 4 | # For more information, please refer to https://aka.ms/vscode-docker-python 5 | # FROM python:slim 6 | FROM python:3.12-slim 7 | 8 | # Keeps Python from generating .pyc files in the container 9 | ENV PYTHONDONTWRITEBYTECODE=1 10 | 11 | # Turns off buffering for easier container logging 12 | ENV PYTHONUNBUFFERED=1 13 | 14 | ############################################################################### 15 | # Requirements: https://doc.qt.io/qt-5/linux-requirements.html | https://doc.qt.io/qt-6/linux-requirements.html 16 | # Additional libraries for Qt5: libxcb-xinerama0 libxcursor1 17 | # Check module load: ldd $(python -c "from PySide6 import QtWidgets as m; print(m.__file__)") 18 | # Check Qt plugins load: QT_DEBUG_PLUGINS=1 19 | # Check all libs: find /usr/local/lib/python3.12/site-packages/PySide6 -type f -executable ! -name "*.py" ! -path "*/scripts/*" | xargs -I {} sh -c 'echo {}; ldd "{}" | grep "not found"' 20 | # Force Wayland or XCB: QT_QPA_PLATFORM=wayland | wayland-egl | xcb 21 | RUN apt update && apt install -y \ 22 | libgl1 \ 23 | libxkbcommon0 \ 24 | libegl1 \ 25 | libfontconfig1 \ 26 | libglib2.0-0 \ 27 | libdbus-1-3 \ 28 | libxcb-cursor0 \ 29 | libxkbcommon-x11-0 \ 30 | libxcb-icccm4 \ 31 | libxcb-keysyms1 \ 32 | libxcb-shape0 \ 33 | libwayland-cursor0 \ 34 | libatomic1 \ 35 | libwayland-egl1 \ 36 | libxrender1 \ 37 | libice6 \ 38 | libsm6 \ 39 | && rm -rf /var/lib/apt/lists/* 40 | 41 | ############################################################################### 42 | 43 | WORKDIR /app 44 | 45 | # Creates a non-root user with an explicit UID and adds permission to access the /app folder 46 | # For more info, please refer to https://aka.ms/vscode-docker-python-configure-containers 47 | # 48 | # Wayland requires $XDG_RUNTIME_DIR to be accessible for current user, so create 49 | # user with uid 1000 because /run/desktop/mnt/host/wslg/runtime-dir ($XDG_RUNTIME_DIR) 50 | # is mounted with that uid, see also https://github.com/microsoft/WSL/issues/9689 51 | RUN adduser -u 1000 --disabled-password --gecos "" appuser && chown -R appuser /app 52 | 53 | # Install project dependencies 54 | # https://docs.astral.sh/uv/concepts/python-versions/#disabling-automatic-python-downloads 55 | ENV UV_PYTHON_DOWNLOADS=never 56 | # for uv sync https://docs.astral.sh/uv/concepts/projects/config/#project-environment-path 57 | ENV UV_PROJECT_ENVIRONMENT=/usr/local 58 | # Do not buffer stdout/stderr https://stackoverflow.com/a/59812588 59 | ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 60 | RUN --mount=from=uv,source=/uv,target=/bin/uv \ 61 | --mount=from=uv,source=/pyproject.toml,target=/app/pyproject.toml \ 62 | --mount=from=uv,source=/uv.lock,target=/app/uv.lock \ 63 | # Install dependencies from an existing uv.lock: uv sync --frozen 64 | uv sync --frozen --no-cache --no-install-project 65 | 66 | # Install project in a separate layer for faster rebuilds 67 | RUN --mount=from=uv,source=/uv,target=/bin/uv \ 68 | --mount=target=/app,type=bind,source=. \ 69 | cd /app && \ 70 | uv sync --frozen --no-cache 71 | 72 | USER appuser 73 | 74 | # During debugging, this entry point will be overridden. For more information, please refer to https://aka.ms/vscode-docker-python-debug 75 | CMD ["python", "breadcrumbsaddressbar\breadcrumbsaddressbar.py"] 76 | -------------------------------------------------------------------------------- /src/breadcrumbsaddressbar/layouts.py: -------------------------------------------------------------------------------- 1 | from qtpy import QtCore, QtWidgets 2 | from qtpy.QtCore import Qt 3 | 4 | class LeftHBoxLayout(QtWidgets.QHBoxLayout): 5 | ''' 6 | Left aligned horizontal layout. 7 | Hides items similar to Windows Explorer address bar. 8 | ''' 9 | # Signal is emitted when an item is hidden/shown or removed with `takeAt` 10 | widget_state_changed = QtCore.Signal(object, bool) 11 | 12 | def __init__(self, parent=None, minimal_space=0.1): 13 | super().__init__(parent) 14 | self.first_visible = 0 15 | self.set_space_widget() 16 | self.set_minimal_space(minimal_space) 17 | 18 | def set_space_widget(self, widget=None, stretch=1): 19 | """ 20 | Set widget to be used to fill empty space to the right 21 | If `widget`=None the stretch item is used (by default) 22 | """ 23 | super().takeAt(self.count()) 24 | if widget: 25 | super().addWidget(widget, stretch) 26 | else: 27 | self.addStretch(stretch) 28 | 29 | def space_widget(self): 30 | "Widget used to fill free space" 31 | return self[self.count()] 32 | 33 | def setGeometry(self, rc:QtCore.QRect): 34 | "`rc` - layout's rectangle w/o margins" 35 | super().setGeometry(rc) # perform the layout 36 | min_sp = self.minimal_space() 37 | if min_sp < 1: # percent 38 | min_sp *= rc.width() 39 | free_space = self[self.count()].geometry().width() - min_sp 40 | if free_space < 0 and self.count_visible() > 1: # hide more items 41 | widget = self[self.first_visible].widget() 42 | widget.hide() 43 | self.first_visible += 1 44 | self.widget_state_changed.emit(widget, False) 45 | elif free_space > 0 and self.count_hidden(): # show more items 46 | widget = self[self.first_visible-1].widget() 47 | w_width = widget.width() + self.spacing() 48 | if w_width <= free_space: # enough space to show next item 49 | # setGeometry is called after show 50 | QtCore.QTimer.singleShot(0, widget.show) 51 | self.first_visible -= 1 52 | self.widget_state_changed.emit(widget, True) 53 | 54 | def count_visible(self): 55 | "Count of visible widgets" 56 | return self.count(visible=True) 57 | 58 | def count_hidden(self): 59 | "Count of hidden widgets" 60 | return self.count(visible=False) 61 | 62 | def minimumSize(self): 63 | margins = self.contentsMargins() 64 | return QtCore.QSize(margins.left() + margins.right(), 65 | margins.top() + 24 + margins.bottom()) 66 | 67 | def addWidget(self, widget, stretch=0, alignment=None): 68 | "Append widget to layout, make its width fixed" 69 | # widget.setMinimumSize(widget.minimumSizeHint()) # FIXME: 70 | super().insertWidget(self.count(), widget, stretch, 71 | alignment or Qt.Alignment(0)) 72 | 73 | def count(self, visible=None): 74 | "Count of items in layout: `visible`=True|False(hidden)|None(all)" 75 | cnt = super().count() - 1 # w/o last stretchable item 76 | if visible is None: # all items 77 | return cnt 78 | if visible: # visible items 79 | return cnt - self.first_visible 80 | return self.first_visible # hidden items 81 | 82 | def widgets(self, state='all'): 83 | "Iterate over child widgets" 84 | for i in range(self.first_visible if state=='visible' else 0, 85 | self.first_visible if state=='hidden' else self.count() 86 | ): 87 | yield self[i].widget() 88 | 89 | def set_minimal_space(self, value): 90 | """ 91 | Set minimal size of space area to the right: 92 | [0.0, 1.0) - % of the full width 93 | [1, ...) - size in pixels 94 | """ 95 | self._minimal_space = value 96 | self.invalidate() 97 | 98 | def minimal_space(self): 99 | "See `set_minimal_space`" 100 | return self._minimal_space 101 | 102 | def __getitem__(self, index): 103 | "`itemAt` slices wrapper" 104 | if index < 0: 105 | index = self.count() + index 106 | return self.itemAt(index) 107 | 108 | def takeAt(self, index): 109 | "Return an item at the specified `index` and remove it from layout" 110 | if index < self.first_visible: 111 | self.first_visible -= 1 112 | item = super().takeAt(index) 113 | self.widget_state_changed.emit(item.widget(), False) 114 | return item 115 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 1 3 | requires-python = ">=3.12" 4 | 5 | [[package]] 6 | name = "breadcrumbsaddressbar" 7 | version = "0.1.0.dev0" 8 | source = { editable = "." } 9 | dependencies = [ 10 | { name = "pywin32", marker = "sys_platform == 'win32'" }, 11 | { name = "qtpy" }, 12 | ] 13 | 14 | [package.dev-dependencies] 15 | dev = [ 16 | { name = "pyside6-essentials" }, 17 | ] 18 | 19 | [package.metadata] 20 | requires-dist = [ 21 | { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=308" }, 22 | { name = "qtpy", specifier = ">=2.4.3" }, 23 | ] 24 | 25 | [package.metadata.requires-dev] 26 | dev = [{ name = "pyside6-essentials", specifier = ">=6.8.2.1" }] 27 | 28 | [[package]] 29 | name = "packaging" 30 | version = "24.2" 31 | source = { registry = "https://pypi.org/simple" } 32 | sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } 33 | wheels = [ 34 | { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, 35 | ] 36 | 37 | [[package]] 38 | name = "pyside6-essentials" 39 | version = "6.8.2.1" 40 | source = { registry = "https://pypi.org/simple" } 41 | dependencies = [ 42 | { name = "shiboken6" }, 43 | ] 44 | wheels = [ 45 | { url = "https://files.pythonhosted.org/packages/01/bb/0127a53530cec0f9e7268e2fe235322b7b6e592caeb36c558b64da6ec52c/PySide6_Essentials-6.8.2.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:ae5cc48f7e9a08e73e3ec2387ce245c8150e620b8d5a87548ebd4b8e3aeae49b", size = 134909713 }, 46 | { url = "https://files.pythonhosted.org/packages/d2/f9/aa4ff511ff1f3dd177f7e8f5a635e03fe578fa2045c8d6be4577e7db3b28/PySide6_Essentials-6.8.2.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5ab31e5395a4724102edd6e8ff980fa3f7cde2aa79050763a1dcc30bb914195a", size = 95331575 }, 47 | { url = "https://files.pythonhosted.org/packages/fd/69/595002d860ee58431fe7add081d6f54fff94ae9680f2eb8cd355c1649bb6/PySide6_Essentials-6.8.2.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:7aed46f91d44399b4c713cf7387f5fb6f0114413fbcdbde493a528fb8e19f6ed", size = 93200219 }, 48 | { url = "https://files.pythonhosted.org/packages/5b/54/28a8b03f327e2c1d27d4a1ccf1a44997afc73c00ad07125d889640367194/PySide6_Essentials-6.8.2.1-cp39-abi3-win_amd64.whl", hash = "sha256:18de224f09108998d194e60f2fb8a1e86367dd525dd8a6192598e80e6ada649e", size = 72502927 }, 49 | ] 50 | 51 | [[package]] 52 | name = "pywin32" 53 | version = "308" 54 | source = { registry = "https://pypi.org/simple" } 55 | wheels = [ 56 | { url = "https://files.pythonhosted.org/packages/00/7c/d00d6bdd96de4344e06c4afbf218bc86b54436a94c01c71a8701f613aa56/pywin32-308-cp312-cp312-win32.whl", hash = "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897", size = 5939729 }, 57 | { url = "https://files.pythonhosted.org/packages/21/27/0c8811fbc3ca188f93b5354e7c286eb91f80a53afa4e11007ef661afa746/pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47", size = 6543015 }, 58 | { url = "https://files.pythonhosted.org/packages/9d/0f/d40f8373608caed2255781a3ad9a51d03a594a1248cd632d6a298daca693/pywin32-308-cp312-cp312-win_arm64.whl", hash = "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091", size = 7976033 }, 59 | { url = "https://files.pythonhosted.org/packages/a9/a4/aa562d8935e3df5e49c161b427a3a2efad2ed4e9cf81c3de636f1fdddfd0/pywin32-308-cp313-cp313-win32.whl", hash = "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed", size = 5938579 }, 60 | { url = "https://files.pythonhosted.org/packages/c7/50/b0efb8bb66210da67a53ab95fd7a98826a97ee21f1d22949863e6d588b22/pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4", size = 6542056 }, 61 | { url = "https://files.pythonhosted.org/packages/26/df/2b63e3e4f2df0224f8aaf6d131f54fe4e8c96400eb9df563e2aae2e1a1f9/pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd", size = 7974986 }, 62 | ] 63 | 64 | [[package]] 65 | name = "qtpy" 66 | version = "2.4.3" 67 | source = { registry = "https://pypi.org/simple" } 68 | dependencies = [ 69 | { name = "packaging" }, 70 | ] 71 | sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982 } 72 | wheels = [ 73 | { url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045 }, 74 | ] 75 | 76 | [[package]] 77 | name = "shiboken6" 78 | version = "6.8.2.1" 79 | source = { registry = "https://pypi.org/simple" } 80 | wheels = [ 81 | { url = "https://files.pythonhosted.org/packages/d8/8f/71ccc3642edb59efaca35d4ba974248b1d7847f5e4d87d3ea323e73b2cab/shiboken6-6.8.2.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:d3dedeb3732ecfc920c9f97da769c0022a1c3bda99346a9eba56fbf093deaa75", size = 401266 }, 82 | { url = "https://files.pythonhosted.org/packages/7b/ff/ab4f287b9573e50b5a47c10e2af8feb5abecc3c7431bd5deec135efc969e/shiboken6-6.8.2.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c83e90056f13d0872cc4d2b7bf60b6d6e3b1b172f1f91910c0ba5b641af01758", size = 204273 }, 83 | { url = "https://files.pythonhosted.org/packages/a6/b0/4fb102eb5260ee06d379769f3c4f0b82ef397c15f1cbbbbb3f6dceb86d5d/shiboken6-6.8.2.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8592401423acc693f51dbbfae5e7493cc3ed6738be79daaf90afa07f4da5bb25", size = 200909 }, 84 | { url = "https://files.pythonhosted.org/packages/ae/88/b56bdb38a11066e4eecd1da6be4205bb406398b733b392b11c5aaf9547f7/shiboken6-6.8.2.1-cp39-abi3-win_amd64.whl", hash = "sha256:1b751d47b759762b7ca31bad278d52eca4105d3028880d93979261ebbfba810c", size = 1150270 }, 85 | ] 86 | -------------------------------------------------------------------------------- /src/breadcrumbsaddressbar/models_views.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | from pathlib import Path 3 | from qtpy import QtCore, QtWidgets 4 | from qtpy.QtCore import Qt 5 | 6 | class FilenameModel(QtCore.QStringListModel): 7 | """ 8 | Model used by QCompleter for file name completions. 9 | Constructor options: 10 | `filter_` (None, 'dirs') - include all entries or folders only 11 | `fs_engine` ('qt', 'pathlib') - enumerate files using `QDir` or `pathlib` 12 | `icon_provider` (func, 'internal', None) - a function which gets path 13 | and returns QIcon 14 | """ 15 | def __init__(self, filter_=None, fs_engine='qt', icon_provider='internal'): 16 | super().__init__() 17 | self.current_path = None 18 | self.fs_engine = fs_engine 19 | self.filter = filter_ 20 | if icon_provider == 'internal': 21 | self.icons = QtWidgets.QFileIconProvider() 22 | self.icon_provider = self.get_icon 23 | else: 24 | self.icon_provider = icon_provider 25 | 26 | def data(self, index, role): 27 | "Get names/icons of files" 28 | default = super().data(index, role) 29 | if role == Qt.DecorationRole and self.icon_provider: 30 | # self.setData(index, dat, role) 31 | return self.icon_provider(super().data(index, Qt.DisplayRole)) 32 | if role == Qt.DisplayRole: 33 | return Path(default).name 34 | return default 35 | 36 | def get_icon(self, path): 37 | "Internal icon provider" 38 | return self.icons.icon(QtCore.QFileInfo(path)) 39 | 40 | def get_file_list(self, path): 41 | "List entries in `path` directory" 42 | lst = None 43 | if self.fs_engine == 'pathlib': 44 | lst = self.sort_paths([i for i in path.iterdir() 45 | if self.filter != 'dirs' or i.is_dir()]) 46 | elif self.fs_engine == 'qt': 47 | qdir = QtCore.QDir(str(path)) 48 | qdir.setFilter(qdir.Filter.NoDotAndDotDot | qdir.Filter.Hidden | 49 | (qdir.Filter.Dirs if self.filter == 'dirs' else qdir.Filter.AllEntries)) 50 | names = qdir.entryList(sort=QtCore.QDir.SortFlag.DirsFirst | 51 | QtCore.QDir.SortFlag.LocaleAware) 52 | lst = [str(path / i) for i in names] 53 | return lst 54 | 55 | @staticmethod 56 | def sort_paths(paths): 57 | "Windows-Explorer-like filename sorting (for 'pathlib' engine)" 58 | dirs, files = [], [] 59 | for i in paths: 60 | if i.is_dir(): 61 | dirs.append(str(i)) 62 | else: 63 | files.append(str(i)) 64 | return sorted(dirs, key=str.lower) + sorted(files, key=str.lower) 65 | 66 | def setPathPrefix(self, prefix): 67 | path = Path(prefix) 68 | if not prefix.endswith(os.path.sep): 69 | path = path.parent 70 | if path == self.current_path: 71 | return # already listed 72 | if not path.exists(): 73 | return # wrong path 74 | self.setStringList(self.get_file_list(path)) 75 | self.current_path = path 76 | 77 | 78 | class MenuListView(QtWidgets.QMenu): 79 | """ 80 | QMenu with QListView. 81 | Supports `activated`, `clicked`, `setModel`. 82 | """ 83 | max_visible_items = 16 84 | 85 | def __init__(self, parent=None): 86 | super().__init__(parent) 87 | self.listview = lv = QtWidgets.QListView() 88 | lv.setFrameShape(lv.Shape.NoFrame) 89 | lv.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 90 | pal = lv.palette() 91 | pal.setColor(pal.ColorRole.Base, self.palette().color(pal.ColorRole.Window)) 92 | lv.setPalette(pal) 93 | 94 | act_wgt = QtWidgets.QWidgetAction(self) 95 | act_wgt.setDefaultWidget(lv) 96 | self.addAction(act_wgt) 97 | 98 | self.activated = lv.activated 99 | self.clicked = lv.clicked 100 | self.setModel = lv.setModel 101 | 102 | lv.sizeHint = self.size_hint 103 | lv.minimumSizeHint = self.size_hint 104 | lv.mousePressEvent = self.mouse_press_event 105 | lv.mouseMoveEvent = self.update_current_index 106 | lv.setMouseTracking(True) # receive mouse move events 107 | lv.leaveEvent = self.clear_selection 108 | lv.mouseReleaseEvent = self.mouse_release_event 109 | lv.keyPressEvent = self.key_press_event 110 | lv.setFocusPolicy(Qt.NoFocus) # no focus rect 111 | lv.setFocus() 112 | 113 | self.last_index = QtCore.QModelIndex() # selected index 114 | self.flag_mouse_l_pressed = False 115 | 116 | def key_press_event(self, event): 117 | key = event.key() 118 | if key in (Qt.Key_Return, Qt.Key_Enter): 119 | if self.last_index.isValid(): 120 | self.activated.emit(self.last_index) 121 | self.close() 122 | elif key == Qt.Key_Escape: 123 | self.close() 124 | elif key in (Qt.Key_Down, Qt.Key_Up): 125 | model = self.listview.model() 126 | row_from, row_to = 0, model.rowCount()-1 127 | if key == Qt.Key_Down: 128 | row_from, row_to = row_to, row_from 129 | if self.last_index.row() in (-1, row_from): # no index=-1 130 | index = model.index(row_to, 0) 131 | else: 132 | shift = 1 if key == Qt.Key_Down else -1 133 | index = model.index(self.last_index.row()+shift, 0) 134 | self.listview.setCurrentIndex(index) 135 | self.last_index = index 136 | 137 | def update_current_index(self, event): 138 | self.last_index = self.listview.indexAt(event.pos()) 139 | self.listview.setCurrentIndex(self.last_index) 140 | 141 | def clear_selection(self, event=None): 142 | self.listview.clearSelection() 143 | # selectionModel().clear() leaves selected item in Fusion theme 144 | self.listview.setCurrentIndex(QtCore.QModelIndex()) 145 | self.last_index = QtCore.QModelIndex() 146 | 147 | def mouse_press_event(self, event): 148 | if event.button() == Qt.LeftButton: 149 | self.flag_mouse_l_pressed = True 150 | self.update_current_index(event) 151 | 152 | def mouse_release_event(self, event): 153 | """ 154 | When item is clicked w/ left mouse button close menu, emit `clicked`. 155 | Check if there was left button press event inside this widget. 156 | """ 157 | if event.button() == Qt.LeftButton and self.flag_mouse_l_pressed: 158 | self.flag_mouse_l_pressed = False 159 | if self.last_index.isValid(): 160 | self.clicked.emit(self.last_index) 161 | self.close() 162 | 163 | def size_hint(self): 164 | lv = self.listview 165 | width = lv.sizeHintForColumn(0) 166 | width += lv.verticalScrollBar().sizeHint().width() 167 | if isinstance(self.parent(), QtWidgets.QToolButton): 168 | width = max(width, self.parent().width()) 169 | visible_rows = min(self.max_visible_items, lv.model().rowCount()) 170 | return QtCore.QSize(width, visible_rows * lv.sizeHintForRow(0)) 171 | -------------------------------------------------------------------------------- /src/breadcrumbsaddressbar/breadcrumbsaddressbar.py: -------------------------------------------------------------------------------- 1 | """ 2 | Qt navigation bar with breadcrumbs 3 | Andrey Makarov, 2019 4 | """ 5 | 6 | import os 7 | import platform 8 | from pathlib import Path 9 | from typing import Union 10 | 11 | from qtpy import QtCore, QtGui, QtWidgets 12 | from qtpy.QtCore import Qt 13 | 14 | from .layouts import LeftHBoxLayout 15 | from .models_views import FilenameModel, MenuListView 16 | from .stylesheet import style_root_toolbutton 17 | 18 | if platform.system() == "Windows": 19 | from .platform.windows import get_path_label 20 | 21 | TRANSP_ICON_SIZE = 40, 40 # px, size of generated semi-transparent icons 22 | cwd_path = Path() # working dir (.) https://stackoverflow.com/q/51330297 23 | 24 | 25 | class BreadcrumbsAddressBar(QtWidgets.QFrame): 26 | "Windows Explorer-like address bar" 27 | listdir_error = QtCore.Signal(Path) # failed to list a directory 28 | path_error = QtCore.Signal(Path) # entered path does not exist 29 | path_selected = QtCore.Signal(Path) 30 | 31 | def __init__(self, parent=None): 32 | super().__init__(parent) 33 | self.os_type = platform.system() 34 | 35 | self.style_crumbs = StyleProxy( 36 | QtWidgets.QStyleFactory.create( 37 | QtWidgets.QApplication.instance().style().objectName() 38 | ), 39 | QtGui.QPixmap("iconfinder_icon-ios7-arrow-right_211607.png") 40 | ) 41 | 42 | layout = QtWidgets.QHBoxLayout(self) 43 | 44 | self.file_ico_prov = QtWidgets.QFileIconProvider() 45 | self.fs_model = FilenameModel('dirs', icon_provider=self.get_icon) 46 | 47 | pal = self.palette() 48 | pal.setColor(QtGui.QPalette.ColorRole.Window, 49 | pal.color(QtGui.QPalette.ColorRole.Base)) 50 | self.setPalette(pal) 51 | self.setAutoFillBackground(True) 52 | self.setFrameShape(self.Shape.StyledPanel) 53 | self.layout().setContentsMargins(4, 0, 0, 0) 54 | self.layout().setSpacing(0) 55 | 56 | self.path_icon = QtWidgets.QLabel(self) 57 | layout.addWidget(self.path_icon) 58 | 59 | # Edit presented path textually 60 | self.line_address = QtWidgets.QLineEdit(self) 61 | self.line_address.setFrame(False) 62 | self.line_address.hide() 63 | self.line_address.keyPressEvent = self.line_address_keyPressEvent 64 | self.line_address.focusOutEvent = self.line_address_focusOutEvent 65 | self.line_address.contextMenuEvent = self.line_address_contextMenuEvent 66 | layout.addWidget(self.line_address) 67 | # Add QCompleter to address line 68 | completer = self.init_completer(self.line_address, self.fs_model) 69 | completer.activated.connect(self.set_path) 70 | 71 | # Container for `btn_crumbs_hidden`, `crumbs_panel`, `switch_space` 72 | self.crumbs_container = QtWidgets.QWidget(self) 73 | crumbs_cont_layout = QtWidgets.QHBoxLayout(self.crumbs_container) 74 | crumbs_cont_layout.setContentsMargins(0, 0, 0, 0) 75 | crumbs_cont_layout.setSpacing(0) 76 | layout.addWidget(self.crumbs_container) 77 | 78 | # Monitor breadcrumbs under cursor and switch popup menus 79 | self.mouse_pos_timer = QtCore.QTimer(self) 80 | self.mouse_pos_timer.timeout.connect(self.mouse_pos_timer_event) 81 | 82 | # Hidden breadcrumbs menu button 83 | self.btn_root_crumb = QtWidgets.QToolButton(self) 84 | self.btn_root_crumb.setAutoRaise(True) 85 | self.btn_root_crumb.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) 86 | self.btn_root_crumb.setArrowType(Qt.ArrowType.RightArrow) 87 | self.btn_root_crumb.setStyleSheet(style_root_toolbutton) 88 | self.btn_root_crumb.setMinimumSize(self.btn_root_crumb.minimumSizeHint()) 89 | crumbs_cont_layout.addWidget(self.btn_root_crumb) 90 | menu = QtWidgets.QMenu(self.btn_root_crumb) # FIXME: 91 | menu.aboutToShow.connect(self._hidden_crumbs_menu_show) 92 | menu.aboutToHide.connect(self.mouse_pos_timer.stop) 93 | self.btn_root_crumb.setMenu(menu) 94 | self.init_rootmenu_places(menu) # Desktop, Home, Downloads... 95 | self.update_rootmenu_devices() # C:, D:... 96 | 97 | # Container for breadcrumbs 98 | self.crumbs_panel = QtWidgets.QWidget(self) 99 | crumbs_layout = LeftHBoxLayout(self.crumbs_panel) 100 | crumbs_layout.widget_state_changed.connect(self.crumb_hide_show) 101 | crumbs_layout.setContentsMargins(0, 0, 0, 0) 102 | crumbs_layout.setSpacing(0) 103 | crumbs_cont_layout.addWidget(self.crumbs_panel) 104 | 105 | # Clicking on empty space to the right puts the bar into edit mode 106 | self.switch_space = QtWidgets.QWidget(self) 107 | # s_policy = self.switch_space.sizePolicy() 108 | # s_policy.setHorizontalStretch(1) 109 | # self.switch_space.setSizePolicy(s_policy) 110 | self.switch_space.mouseReleaseEvent = self.switch_space_mouse_up 111 | # crumbs_cont_layout.addWidget(self.switch_space) 112 | crumbs_layout.set_space_widget(self.switch_space) 113 | 114 | self.btn_browse = QtWidgets.QToolButton(self) 115 | self.btn_browse.setAutoRaise(True) 116 | self.btn_browse.setText("...") 117 | self.btn_browse.setToolTip("Browse for folder") 118 | self.btn_browse.clicked.connect(self._browse_for_folder) 119 | layout.addWidget(self.btn_browse) 120 | 121 | self.setMaximumHeight(self.line_address.height()) # FIXME: 122 | 123 | self.ignore_resize = False 124 | self.path_ = None 125 | self.set_path(Path()) 126 | 127 | @staticmethod 128 | def init_completer(edit_widget, model): 129 | "Init QCompleter to work with filesystem" 130 | completer = QtWidgets.QCompleter(edit_widget) 131 | completer.setCaseSensitivity(Qt.CaseInsensitive) 132 | completer.setModel(model) 133 | # Optimize performance https://stackoverflow.com/a/33454284/1119602 134 | popup = completer.popup() 135 | popup.setUniformItemSizes(True) 136 | popup.setLayoutMode(QtWidgets.QListView.Batched) 137 | edit_widget.setCompleter(completer) 138 | edit_widget.textEdited.connect(model.setPathPrefix) 139 | return completer 140 | 141 | def get_icon(self, path: Union[str, Path]): 142 | "Path -> QIcon" 143 | fileinfo = QtCore.QFileInfo(str(path)) 144 | dat = self.file_ico_prov.icon(fileinfo) 145 | if fileinfo.isHidden(): 146 | pmap = QtGui.QPixmap(*TRANSP_ICON_SIZE) 147 | pmap.fill(Qt.transparent) 148 | painter = QtGui.QPainter(pmap) 149 | painter.setOpacity(0.5) 150 | dat.paint(painter, 0, 0, *TRANSP_ICON_SIZE) 151 | painter.end() 152 | dat = QtGui.QIcon(pmap) 153 | return dat 154 | 155 | def line_address_contextMenuEvent(self, event): 156 | self.line_address_context_menu_flag = True 157 | QtWidgets.QLineEdit.contextMenuEvent(self.line_address, event) 158 | 159 | def line_address_focusOutEvent(self, event): 160 | if getattr(self, 'line_address_context_menu_flag', False): 161 | self.line_address_context_menu_flag = False 162 | return # do not cancel edit on context menu 163 | self._cancel_edit() 164 | 165 | def _hidden_crumbs_menu_show(self): 166 | "SLOT: fill menu with hidden breadcrumbs list" 167 | self.mouse_pos_timer.start(100) 168 | menu = self.sender() 169 | if hasattr(self, 'actions_hidden_crumbs'): 170 | for action in self.actions_hidden_crumbs: 171 | menu.removeAction(action) 172 | self.actions_hidden_crumbs = [] 173 | 174 | first_action = menu.actions()[0] # places section separator 175 | for i in self.crumbs_panel.layout().widgets('hidden'): 176 | action = QtWidgets.QAction(self.get_icon(i.path), i.text(), menu) 177 | action.path = i.path 178 | action.triggered.connect(self.set_path) 179 | menu.insertAction(first_action, action) 180 | self.actions_hidden_crumbs.append(action) 181 | first_action = action 182 | 183 | def init_rootmenu_places(self, menu): 184 | "Init common places actions in menu" 185 | menu.addSeparator() 186 | QSP = QtCore.QStandardPaths 187 | uname = os.environ.get('USER') or os.environ.get('USERNAME') or "Home" 188 | for name, path in ( 189 | ("Desktop", QSP.writableLocation(QSP.DesktopLocation)), 190 | (uname, QSP.writableLocation(QSP.HomeLocation)), 191 | ("Documents", QSP.writableLocation(QSP.DocumentsLocation)), 192 | ("Downloads", QSP.writableLocation(QSP.DownloadLocation)), 193 | ): 194 | if self.os_type == "Windows": 195 | name = self.get_path_label(path) 196 | action = menu.addAction(self.get_icon(path), name) 197 | action.path = path 198 | action.triggered.connect(self.set_path) 199 | 200 | def get_path_label(self, drive_path): 201 | "Try to get path label using Shell32 on Windows" 202 | return get_path_label(drive_path.replace("/", "\\")) 203 | 204 | @staticmethod 205 | def list_network_locations(): 206 | "List (name, path) locations in Network Shortcuts folder on Windows" 207 | HOME = QtCore.QStandardPaths.HomeLocation 208 | user_folder = QtCore.QStandardPaths.writableLocation(HOME) 209 | network_shortcuts = user_folder + "/AppData/Roaming/Microsoft/Windows/Network Shortcuts" 210 | for i in Path(network_shortcuts).iterdir(): 211 | if not i.is_dir(): 212 | continue 213 | link = Path(i) / "target.lnk" 214 | if not link.exists(): 215 | continue 216 | path = QtCore.QFileInfo(str(link)).symLinkTarget() 217 | if path: # `symLinkTarget` doesn't read e.g. FTP links 218 | yield i.name, path 219 | 220 | def update_rootmenu_devices(self): 221 | "Init or rebuild device actions in menu" 222 | menu = self.btn_root_crumb.menu() 223 | if hasattr(self, 'actions_devices'): 224 | for action in self.actions_devices: 225 | menu.removeAction(action) 226 | self.actions_devices = [menu.addSeparator()] 227 | for i in QtCore.QStorageInfo.mountedVolumes(): # QDir.drives(): 228 | path, label = i.rootPath(), i.displayName() 229 | if label == path and self.os_type == "Windows": 230 | label = self.get_path_label(path) 231 | elif self.os_type == "Linux" and not path.startswith("/media"): 232 | # Add to list only volumes in /media 233 | continue 234 | caption = "%s (%s)" % (label, path.rstrip(r"\/")) 235 | action = menu.addAction(self.get_icon(path), caption) 236 | action.path = path 237 | action.triggered.connect(self.set_path) 238 | self.actions_devices.append(action) 239 | if self.os_type == "Windows": # Network locations 240 | for label, path in self.list_network_locations(): 241 | action = menu.addAction(self.get_icon(path), label) 242 | action.path = path 243 | action.triggered.connect(self.set_path) 244 | self.actions_devices.append(action) 245 | 246 | def _browse_for_folder(self): 247 | path = QtWidgets.QFileDialog.getExistingDirectory( 248 | self, "Choose folder", str(self.path())) 249 | if path: 250 | self.set_path(path) 251 | 252 | def line_address_keyPressEvent(self, event): 253 | "Actions to take after a key press in text address field" 254 | if event.key() == Qt.Key_Escape: 255 | self._cancel_edit() 256 | elif event.key() in (Qt.Key_Return, Qt.Key_Enter): 257 | self.set_path(self.line_address.text()) 258 | self._show_address_field(False) 259 | # elif event.text() == os.path.sep: # FIXME: separator cannot be pasted 260 | # print('fill completer data here') 261 | # paths = [str(i) for i in 262 | # Path(self.line_address.text()).iterdir() if i.is_dir()] 263 | # self.completer.model().setStringList(paths) 264 | else: 265 | QtWidgets.QLineEdit.keyPressEvent(self.line_address, event) 266 | 267 | def _clear_crumbs(self): 268 | layout = self.crumbs_panel.layout() 269 | while layout.count(): 270 | widget = layout.takeAt(0).widget() 271 | if widget: 272 | # Unset style or `StyleProxy.drawPrimitive` is called once with 273 | # mysterious `QWidget` instead of `QToolButton` (Windows 7) 274 | widget.setStyle(None) 275 | widget.deleteLater() 276 | 277 | @staticmethod 278 | def path_title(path: Path): 279 | "Get folder name or drive name" 280 | # FIXME: C:\ has no name. Use rstrip on Windows only? 281 | return path.name or str(path).upper().rstrip(os.path.sep) 282 | 283 | def _insert_crumb(self, path): 284 | btn = QtWidgets.QToolButton(self.crumbs_panel) 285 | btn.setAutoRaise(True) 286 | btn.setPopupMode(btn.ToolButtonPopupMode.MenuButtonPopup) 287 | btn.setStyle(self.style_crumbs) 288 | btn.mouseMoveEvent = self.crumb_mouse_move 289 | btn.setMouseTracking(True) 290 | btn.setText(self.path_title(path)) 291 | btn.path = path 292 | btn.clicked.connect(self.crumb_clicked) 293 | menu = MenuListView(btn) 294 | menu.aboutToShow.connect(self.crumb_menu_show) 295 | menu.setModel(self.fs_model) 296 | menu.clicked.connect(self.crumb_menuitem_clicked) 297 | menu.activated.connect(self.crumb_menuitem_clicked) 298 | menu.aboutToHide.connect(self.mouse_pos_timer.stop) 299 | btn.setMenu(menu) 300 | self.crumbs_panel.layout().insertWidget(0, btn) 301 | btn.setMinimumSize(btn.minimumSizeHint()) # fixed size breadcrumbs 302 | sp = btn.sizePolicy() 303 | sp.setVerticalPolicy(sp.Policy.Minimum) 304 | btn.setSizePolicy(sp) 305 | # print(self._check_space_width(btn.minimumWidth())) 306 | # print(btn.size(), btn.sizeHint(), btn.minimumSizeHint()) 307 | 308 | def crumb_mouse_move(self, event): 309 | ... 310 | # print('move!') 311 | 312 | def crumb_menuitem_clicked(self, index): 313 | "SLOT: breadcrumb menu item was clicked" 314 | self.set_path(index.data(Qt.EditRole)) 315 | 316 | def crumb_clicked(self): 317 | "SLOT: breadcrumb was clicked" 318 | self.set_path(self.sender().path) 319 | 320 | def crumb_menu_show(self): 321 | "SLOT: fill subdirectory list on menu open" 322 | menu = self.sender() 323 | self.fs_model.setPathPrefix(str(menu.parent().path) + os.path.sep) 324 | menu.clear_selection() # clear currentIndex after applying new model 325 | self.mouse_pos_timer.start(100) 326 | 327 | def set_path(self, path=None): 328 | """ 329 | Set path displayed in this BreadcrumbsAddressBar 330 | Returns `False` if path does not exist or permission error. 331 | Can be used as a SLOT: `sender().path` is used if `path` is `None`) 332 | """ 333 | path, emit_err = Path(path or self.sender().path), None 334 | try: # C: -> C:\, folder\..\folder -> folder 335 | path = path.resolve() 336 | except PermissionError: 337 | emit_err = self.listdir_error 338 | if not path.exists(): 339 | emit_err = self.path_error 340 | self._cancel_edit() # exit edit mode 341 | if emit_err: # permission error or path does not exist 342 | emit_err.emit(path) 343 | return False 344 | self._clear_crumbs() 345 | self.path_ = path 346 | self.line_address.setText(str(path)) 347 | self._insert_crumb(path) 348 | for i in path.parents: 349 | if i == cwd_path: 350 | break 351 | self._insert_crumb(i) 352 | self.path_icon.setPixmap(self.get_icon(self.path_).pixmap(16, 16)) 353 | self.path_selected.emit(self.path_) 354 | return True 355 | 356 | def _cancel_edit(self): 357 | "Set edit line text back to current path and switch to view mode" 358 | self.line_address.setText(str(self.path())) # revert path 359 | self._show_address_field(False) # switch back to breadcrumbs view 360 | 361 | def path(self): 362 | "Get path displayed in this BreadcrumbsAddressBar" 363 | return self.path_ 364 | 365 | def switch_space_mouse_up(self, event): 366 | "EVENT: switch_space mouse clicked" 367 | if event.button() != Qt.LeftButton: # left click only 368 | return 369 | self._show_address_field(True) 370 | 371 | def _show_address_field(self, b_show): 372 | "Show text address field" 373 | if b_show: 374 | self.crumbs_container.hide() 375 | self.line_address.show() 376 | self.line_address.setFocus() 377 | self.line_address.selectAll() 378 | else: 379 | self.line_address.hide() 380 | self.crumbs_container.show() 381 | 382 | def crumb_hide_show(self, widget, state:bool): 383 | "SLOT: a breadcrumb is hidden/removed or shown" 384 | layout = self.crumbs_panel.layout() 385 | arrow = Qt.LeftArrow if layout.count_hidden() > 0 else Qt.RightArrow 386 | self.btn_root_crumb.setArrowType(arrow) 387 | # if layout.count_hidden() > 0: 388 | # ico = QtGui.QIcon("iconfinder_icon-ios7-arrow-left_211689.png") 389 | # else: 390 | # ico = QtGui.QIcon("iconfinder_icon-ios7-arrow-right_211607.png") 391 | # self.btn_root_crumb.setIcon(ico) 392 | 393 | def minimumSizeHint(self): 394 | # print(self.layout().minimumSize().width()) 395 | return QtCore.QSize(150, self.line_address.height()) 396 | 397 | def mouse_pos_timer_event(self): 398 | "Monitor breadcrumbs under cursor and switch popup menus" 399 | pos = QtGui.QCursor.pos() 400 | app = QtCore.QCoreApplication.instance() 401 | w = app.widgetAt(pos) 402 | active_menu = app.activePopupWidget() 403 | if (w and isinstance(w, QtWidgets.QToolButton) and 404 | w is not active_menu.parent() and 405 | (w is self.btn_root_crumb or w.parent() is self.crumbs_panel) 406 | ): 407 | active_menu.close() 408 | w.showMenu() 409 | 410 | class StyleProxy(QtWidgets.QProxyStyle): 411 | win_modern = ("windowsxp", "windowsvista") 412 | 413 | def __init__(self, style, arrow_pix): 414 | super().__init__(style) 415 | self.arrow_pix = arrow_pix 416 | self.stylename = self.baseStyle().objectName() 417 | 418 | def drawPrimitive(self, pe, opt, p: QtGui.QPainter, widget): 419 | # QToolButton elements: 420 | # 13: PE_PanelButtonCommand (Fusion) - Fusion button background, called from 15 and 24 calls 421 | # 15: PE_PanelButtonTool (Windows, Fusion) - left part background (XP/Vista styles do not draw it with `drawPrimitive`) 422 | # 19: PE_IndicatorArrowDown (Windows, Fusion) - right part down arrow (XP/Vista styles draw it in 24 call) 423 | # 24: PE_IndicatorButtonDropDown (Windows, XP, Vista, Fusion) - right part background (+arrow for XP/Vista) 424 | # 425 | # Arrow is drawn along with PE_IndicatorButtonDropDown (XP/Vista) 426 | # https://github.com/qt/qtbase/blob/0c51a8756377c40180619046d07b35718fcf1784/src/plugins/styles/windowsvista/qwindowsxpstyle.cpp#L1406 427 | # https://github.com/qt/qtbase/blob/0c51a8756377c40180619046d07b35718fcf1784/src/plugins/styles/windowsvista/qwindowsxpstyle.cpp#L666 428 | # drawBackground paints with DrawThemeBackgroundEx WinApi function 429 | # https://docs.microsoft.com/en-us/windows/win32/api/uxtheme/nf-uxtheme-drawthemebackgroundex 430 | if (self.stylename in self.win_modern and 431 | pe == self.PrimitiveElement.PE_IndicatorButtonDropDown 432 | ): 433 | pe = self.PrimitiveElement.PE_IndicatorArrowDown # see below 434 | if pe == self.PrimitiveElement.PE_IndicatorArrowDown: 435 | opt_ = QtWidgets.QStyleOptionToolButton() 436 | widget.initStyleOption(opt_) 437 | rc = super().subControlRect(self.ComplexControl.CC_ToolButton, opt_, 438 | self.SubControl.SC_ToolButtonMenu, widget) 439 | if self.stylename in self.win_modern: 440 | # By default PE_IndicatorButtonDropDown draws arrow along 441 | # with right button art. Draw 2px clipped left part instead 442 | path = QtGui.QPainterPath() 443 | path.addRect(QtCore.QRectF(rc)) 444 | p.setClipPath(path) 445 | super().drawPrimitive(self.PrimitiveElement.PE_PanelButtonTool, 446 | opt, p, widget) 447 | # centered square 448 | rc.moveTop(int((rc.height() - rc.width()) / 2)) 449 | rc.setHeight(rc.width()) 450 | # p.setRenderHint(p.Antialiasing) 451 | p.drawPixmap(rc, self.arrow_pix, QtCore.QRect()) 452 | else: 453 | super().drawPrimitive(pe, opt, p, widget) 454 | 455 | def subControlRect(self, cc, opt, sc, widget): 456 | rc = super().subControlRect(cc, opt, sc, widget) 457 | if (self.stylename in self.win_modern and 458 | sc == self.SubControl.SC_ToolButtonMenu 459 | ): 460 | rc.adjust(-2, 0, 0, 0) # cut 2 left pixels to create flat edge 461 | return rc 462 | --------------------------------------------------------------------------------