├── .gitignore ├── .idea ├── .gitignore ├── inspectionProfiles │ ├── Project_Default.xml │ └── profiles_settings.xml ├── misc.xml ├── modules.xml ├── pyqt_bounding_box.iml └── vcs.xml ├── LICENSE ├── README.md ├── pyqt_bounding_box ├── __init__.py └── boundingBox.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # 디폴트 무시된 파일 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 29 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/pyqt_bounding_box.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jung Gyu Yoon 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyqt-bounding-box 2 | PyQt bounding box for graphic design software 3 | 4 | ## Requirements 5 | PyQt5 >= 5.8 6 | 7 | ## Setup 8 | `python -m pip install pyqt-bounding-box` 9 | 10 | ## Feature 11 | * Cursor shape changes properly for position (horizontal/vertical edge, etc.) 12 | * Being able to resize the box horizontally/vertically/diagonally 13 | * Being able to move the box with either mouse cursor or arrow keys 14 | * Being able to change the attribute of the box 15 | 16 | ## Methods Overview 17 | * setLineWidth(self, n: int) - Default width is 3 18 | * setColor(self, color: QColor) - Default color is black 19 | * setStyle(self, style: Qt.PenStyle) - Default style is Qt.DashLine (You can see more about this style in here) 20 | * setWidth(width: int) 21 | * setHeight(height: int) 22 | * setSize(width: int, height: int) 23 | 24 | You can use the standard function like `setPen(pen: QPen)` if you know how to use it, Here's the example: 25 | 26 | ```python 27 | item = BoundingBox() 28 | 29 | pen = QPen() 30 | pen.setStyle(Qt.DashLine) 31 | pen.setWidth(3) 32 | pen.setColor(QColor(0, 0, 0)) 33 | 34 | item.setPen(pen) 35 | ``` 36 | 37 | ## Example 38 | Code Sample 39 | 40 | ```python 41 | from PyQt5.QtWidgets import QWidget, QGraphicsView, QVBoxLayout, QApplication, QGraphicsScene 42 | 43 | from pyqt_bounding_box.boundingBox import BoundingBox 44 | 45 | 46 | class Example(QWidget): 47 | def __init__(self): 48 | super().__init__() 49 | self.__initUi() 50 | 51 | def __initUi(self): 52 | view = QGraphicsView() 53 | self.__scene = QGraphicsScene() 54 | self.__scene.setSceneRect(0, 0, 400, 400) 55 | 56 | item = BoundingBox() 57 | # item.setLineWidth(8) If you want to change the edge line width, add the code. 58 | # item.setColor(QColor(255, 255, 255)) If you want to change the color of the line to white, add the code. 59 | # item.setStyle(Qt.SolidLine) If you want to change the style of line from dashed to solid line, add the code. 60 | self.__scene.addItem(item) 61 | view.setScene(self.__scene) 62 | 63 | lay = QVBoxLayout() 64 | lay.addWidget(view) 65 | 66 | self.setLayout(lay) 67 | 68 | 69 | if __name__ == "__main__": 70 | import sys 71 | 72 | app = QApplication(sys.argv) 73 | example = Example() 74 | example.show() 75 | app.exec_() 76 | ``` 77 | 78 | Result 79 | 80 | https://user-images.githubusercontent.com/55078043/148708740-cd1f0765-7768-44b6-88bb-770e2d34fe12.mp4 81 | 82 | ## See Also 83 | * pyqt-hbounding-box 84 | * pyqt-vbounding-box 85 | -------------------------------------------------------------------------------- /pyqt_bounding_box/__init__.py: -------------------------------------------------------------------------------- 1 | from .boundingBox import BoundingBox -------------------------------------------------------------------------------- /pyqt_bounding_box/boundingBox.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtCore import * 2 | from PyQt5.QtGui import * 3 | from PyQt5.QtWidgets import * 4 | 5 | 6 | class BoundingBox(QGraphicsRectItem): 7 | def __init__(self): 8 | super().__init__() 9 | self.__resizeEnabled = False 10 | self.__resize_square_f = False 11 | self.__line_width = 3 12 | 13 | self.__default_width = 200.0 14 | self.__default_height = 200.0 15 | 16 | self.__min_width = 30 17 | self.__min_height = 30 18 | 19 | self.__cursor = QCursor() 20 | 21 | self.__initPosition() 22 | self.__initUi() 23 | 24 | def __initUi(self): 25 | self.setAcceptHoverEvents(True) 26 | self.setFlags(QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemIsFocusable) 27 | self.__setStyleOfBoundingBox() 28 | 29 | # init the edge direction for set correct reshape cursor based on it 30 | def __initPosition(self): 31 | self.__top = False 32 | self.__bottom = False 33 | self.__left = False 34 | self.__right = False 35 | 36 | # TODO need more refactoring 37 | # This is for preventing setting the width and height smaller than minimum size of each 38 | def __isAbleToSetTop(self, rect, y): 39 | return rect.bottom() - y > self.__min_height 40 | def __isAbleToSetBottom(self, y): 41 | return y > self.__min_height 42 | def __isAbleToSetLeft(self, rect, x): 43 | return rect.right() - x > self.__min_width 44 | def __isAbleToSetRight(self, x): 45 | return x > self.__min_width 46 | 47 | def __setStyleOfBoundingBox(self): 48 | pen = QPen() 49 | pen.setStyle(Qt.DashLine) 50 | pen.setWidth(self.__line_width) 51 | self.setRect(QRectF(0.0, 0.0, self.__default_width, self.__default_height)) 52 | self.setPen(pen) 53 | 54 | def __setCursorShapeForCurrentPoint(self, p): 55 | # allow mouse cursor to change shape for scale more easily 56 | rect = self.rect() 57 | rect.setX(self.rect().x() + self.__line_width) 58 | rect.setY(self.rect().y() + self.__line_width) 59 | rect.setWidth(self.rect().width() - self.__line_width * 2) 60 | rect.setHeight(self.rect().height() - self.__line_width * 2) 61 | 62 | if rect.contains(p): 63 | # move 64 | self.setFlags(self.flags() | QGraphicsItem.ItemIsMovable) 65 | self.__cursor.setShape(Qt.SizeAllCursor) 66 | self.setCursor(self.__cursor) 67 | self.__cursor = self.cursor() 68 | self.__resizeEnabled = False 69 | self.__initPosition() 70 | else: 71 | # scale 72 | x = p.x() 73 | y = p.y() 74 | 75 | def setResizeEnabled(): 76 | self.setFlags(self.flags() & ~QGraphicsItem.ItemIsMovable) 77 | self.setCursor(self.__cursor) 78 | self.__resizeEnabled = True 79 | 80 | x1 = self.rect().x() 81 | y1 = self.rect().y() 82 | x2 = self.rect().width() 83 | y2 = self.rect().height() 84 | 85 | self.__left = abs(x - x1) <= self.__line_width # if mouse cursor is at the almost far left 86 | self.__top = abs(y - y1) <= self.__line_width # far top 87 | self.__right = abs(x - (x2 + x1)) <= self.__line_width # far right 88 | self.__bottom = abs(y - (y2 + y1)) <= self.__line_width # far bottom 89 | 90 | # set the cursor shape based on flag above 91 | if self.__top or self.__left or self.__bottom or self.__right: 92 | if self.__top and self.__left: 93 | self.__cursor.setShape(Qt.SizeFDiagCursor) 94 | elif self.__top and self.__right: 95 | self.__cursor.setShape(Qt.SizeBDiagCursor) 96 | elif self.__bottom and self.__left: 97 | self.__cursor.setShape(Qt.SizeBDiagCursor) 98 | elif self.__bottom and self.__right: 99 | self.__cursor.setShape(Qt.SizeFDiagCursor) 100 | elif self.__left: 101 | self.__cursor.setShape(Qt.SizeHorCursor) 102 | elif self.__top: 103 | self.__cursor.setShape(Qt.SizeVerCursor) 104 | elif self.__right: 105 | self.__cursor.setShape(Qt.SizeHorCursor) 106 | elif self.__bottom: 107 | self.__cursor.setShape(Qt.SizeVerCursor) 108 | setResizeEnabled() 109 | 110 | def mouseMoveEvent(self, e): 111 | if self.__resizeEnabled: 112 | rect = self.rect() 113 | p = e.pos() 114 | x = p.x() 115 | y = p.y() 116 | 117 | if self.__resize_square_f: 118 | # TODO i'm still working on this, refactoring is necessary 119 | # get the average of width and height 120 | size = p.manhattanLength() // 2 121 | p = QPoint(int(size), int(size)) 122 | if x < 0 or y < 0: 123 | p = QPoint(int(size)*-1, int(size)*-1) 124 | if self.__cursor.shape() == Qt.SizeFDiagCursor: 125 | if self.__top and self.__left and self.__isAbleToSetTop(rect, y) and self.__isAbleToSetLeft(rect, x): 126 | rect.setTopLeft(p) 127 | elif self.__bottom and self.__right and self.__isAbleToSetBottom(y) and self.__isAbleToSetRight(x): 128 | rect.setBottomRight(p) 129 | else: 130 | if self.__cursor.shape() == Qt.SizeHorCursor: 131 | if self.__left and self.__isAbleToSetLeft(rect, x): 132 | rect.setLeft(x) 133 | elif self.__right and self.__isAbleToSetRight(x): 134 | rect.setRight(x) 135 | elif self.__cursor.shape() == Qt.SizeVerCursor: 136 | if self.__top and self.__isAbleToSetTop(rect, y): 137 | rect.setTop(y) 138 | elif self.__bottom and self.__isAbleToSetBottom(y): 139 | rect.setBottom(y) 140 | elif self.__cursor.shape() == Qt.SizeBDiagCursor: 141 | if self.__top and self.__right and self.__isAbleToSetTop(rect, y) and self.__isAbleToSetRight(x): 142 | rect.setTopRight(p) 143 | elif self.__bottom and self.__left and self.__isAbleToSetBottom(y) and self.__isAbleToSetLeft(rect, x): 144 | rect.setBottomLeft(p) 145 | elif self.__cursor.shape() == Qt.SizeFDiagCursor: 146 | if self.__top and self.__left and self.__isAbleToSetTop(rect, y) and self.__isAbleToSetLeft(rect, x): 147 | rect.setTopLeft(p) 148 | elif self.__bottom and self.__right and self.__isAbleToSetBottom(y) and self.__isAbleToSetRight(x): 149 | rect.setBottomRight(p) 150 | 151 | self.setRect(rect) 152 | 153 | return super().mouseMoveEvent(e) 154 | 155 | def hoverMoveEvent(self, e): 156 | p = e.pos() 157 | 158 | if self.boundingRect().contains(p) or self.rect().contains(p): 159 | self.__setCursorShapeForCurrentPoint(p) 160 | 161 | return super().hoverMoveEvent(e) 162 | 163 | # moving with arrow keys 164 | def keyPressEvent(self, e): 165 | tr = self.transform() 166 | if e.key() == Qt.Key_Up: 167 | tr.translate(0, -1) 168 | if e.key() == Qt.Key_Down: 169 | tr.translate(0, 1) 170 | if e.key() == Qt.Key_Left: 171 | tr.translate(-1, 0) 172 | if e.key() == Qt.Key_Right: 173 | tr.translate(1, 0) 174 | self.setTransform(tr) 175 | return super().keyPressEvent(e) 176 | 177 | def setLineWidth(self, n: int): 178 | self.__line_width = n 179 | self.__setStyleOfBoundingBox() 180 | 181 | def setColor(self, color: QColor): 182 | pen = self.pen() 183 | pen.setColor(color) 184 | self.setPen(pen) 185 | 186 | # https: // doc.qt.io / qt - 6 / qt.html # PenStyle-enum 187 | def setStyle(self, style: Qt.PenStyle): 188 | pen = self.pen() 189 | pen.setStyle(style) 190 | self.setPen(pen) 191 | 192 | def setWidth(self, width: int): 193 | rect = self.rect() 194 | rect.setWidth(width) 195 | self.setRect(rect) 196 | 197 | def setHeight(self, height: int): 198 | rect = self.rect() 199 | rect.setHeight(height) 200 | self.setRect(rect) 201 | 202 | def setSize(self, width: int, height: int): 203 | rect = self.rect() 204 | rect.setSize(QSizeF(width, height)) 205 | self.setRect(rect) 206 | 207 | def setResizeAsSquare(self, f: bool): 208 | self.__resize_square_f = f 209 | 210 | def setMinimumSize(self, width, height): 211 | self.__min_width = width 212 | self.__min_height = height 213 | # TODO set width or height of the current rect larger than minimum size after at least one of them is set by 214 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | 4 | from setuptools import setup, find_packages 5 | 6 | here = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | with codecs.open(os.path.join(here, "README.md"), encoding="utf-8") as fh: 9 | long_description = "\n" + fh.read() 10 | 11 | 12 | setup( 13 | name='pyqt-bounding-box', 14 | version='0.0.16', 15 | author='Jung Gyu Yoon', 16 | author_email='yjg30737@gmail.com', 17 | license='MIT', 18 | packages=find_packages(), 19 | description='PyQt bounding box for graphic design software', 20 | url='https://github.com/yjg30737/pyqt-bounding-box.git', 21 | long_description_content_type='text/markdown', 22 | long_description=long_description, 23 | install_requires=[ 24 | 'PyQt5>=5.8' 25 | ] 26 | ) --------------------------------------------------------------------------------