├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md └── src ├── Files ├── johnwacenwa.png ├── logo.png ├── randomize.png ├── redo.png ├── tess.gif └── undo.png ├── ImageWindow.py ├── MainWindow.py ├── MultiWindowTest.py └── requirements.txt /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 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 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | #.idea/ 153 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Aniket Rajnish 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 | # MultiWindowSync-PyQt 2 | * Windows GUI application developed using `PyQt5`. 3 | * It demonstrates the synchronization of multiple windows using `pyqtSignal`.
4 | * Inspired by the [work](https://twitter.com/_nonfigurativ_/status/1727322594570027343) of Bjørn Staal. 5 | 6 | ![placeholder_mws](https://github.com/aniketrajnish/MultiWindowSync-PyQt/assets/58925008/3cc4e449-2636-42ba-b66a-2c666a3917fc) 7 | 8 | https://github.com/aniketrajnish/MultiWindowSync-PyQt/assets/58925008/e00e10e9-6373-46d8-9669-77466ee8bd90 9 | 10 | ## Usage 11 | * Clone the repository 12 | ``` 13 | git clone https://github.com/aniketrajnish/MultiWindowSync-PyQt.git 14 | ``` 15 | * Open Terminal and change directory to the script's folder. 16 | ``` 17 | cd \src 18 | ``` 19 | * Install Dependencies 20 | ``` 21 | pip install -r requirements.txt 22 | ``` 23 | * Run the main script 24 | ``` 25 | python MultiWindowTest.py 26 | ``` 27 | * Use your own image/GIF. 28 | ``` 29 | File -> Open Image/GIF 30 | ``` 31 | * In case you don't wanna go through all of this hassle, I've added an executable file in the [Releases Section](https://github.com/aniketrajnish/MultiWindowSync-PyQt/releases/tag/v001) that you can directly try on your machine. 32 | 33 | ## Contributing 34 | Contributions to the project are welcome. Currently working on: 35 | * Expanding the environment to 3D using PyOpenGL. 36 | * Fix the bug where the parent image window always moves the image along with it for ref to other windows even if `Move With Window` is unchecked. 37 | 38 | ## License 39 | MIT License 40 | -------------------------------------------------------------------------------- /src/Files/johnwacenwa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aniketrajnish/MultiWindowSync-PyQt/e844ca90f6c0f330889c2acb512b5cab259be145/src/Files/johnwacenwa.png -------------------------------------------------------------------------------- /src/Files/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aniketrajnish/MultiWindowSync-PyQt/e844ca90f6c0f330889c2acb512b5cab259be145/src/Files/logo.png -------------------------------------------------------------------------------- /src/Files/randomize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aniketrajnish/MultiWindowSync-PyQt/e844ca90f6c0f330889c2acb512b5cab259be145/src/Files/randomize.png -------------------------------------------------------------------------------- /src/Files/redo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aniketrajnish/MultiWindowSync-PyQt/e844ca90f6c0f330889c2acb512b5cab259be145/src/Files/redo.png -------------------------------------------------------------------------------- /src/Files/tess.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aniketrajnish/MultiWindowSync-PyQt/e844ca90f6c0f330889c2acb512b5cab259be145/src/Files/tess.gif -------------------------------------------------------------------------------- /src/Files/undo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aniketrajnish/MultiWindowSync-PyQt/e844ca90f6c0f330889c2acb512b5cab259be145/src/Files/undo.png -------------------------------------------------------------------------------- /src/ImageWindow.py: -------------------------------------------------------------------------------- 1 | import random 2 | from PyQt5.QtWidgets import * 3 | from PyQt5.QtGui import * 4 | from PyQt5.QtGui import * 5 | from PyQt5.QtCore import * 6 | 7 | class ImageWindow(QMainWindow): 8 | ''' 9 | Each window instance is used to display images and GIFs. 10 | ''' 11 | imageMoved = pyqtSignal(QPoint) # Signals b/w windows when an image is moved 12 | windowClosing = pyqtSignal(object) # Signals b/w windows when any window closes 13 | 14 | def __init__(self, parent=None, imagePath=None): 15 | ''' 16 | The settings and window is intialized as well the path of the image to be shown is assigned. 17 | ''' 18 | super().__init__(parent) 19 | self.initUI() 20 | self.initSettings(imagePath) 21 | 22 | def setMoveWithWindow(self, state): 23 | self.moveWithWindow = state 24 | 25 | def setKeepCentered(self, state): 26 | self.keepCentered = state 27 | 28 | def initUI(self): 29 | ''' 30 | The window is initialized randomly in a bounding box that's relative to the screen. 31 | The Image Label to hold the image is given the same size as the window. 32 | ''' 33 | screen = QApplication.primaryScreen().size() 34 | screenWidth = screen.width() 35 | screenHeight = screen.height() 36 | 37 | self.setGeometry(random.randint(screenWidth//4, screenWidth//2), 38 | random.randint(0, screenHeight//2), 39 | int(screenWidth//2.5), int(screenHeight//2)) 40 | 41 | self.setWindowIcon(QIcon('Files/logo.png')) 42 | self.setWindowTitle('Image Window') 43 | self.imageLabel = QLabel(self) 44 | self.imageLabel.setGeometry(self.rect()) 45 | 46 | def initSettings(self, imagePath): 47 | ''' 48 | The image path is assigned to the window as well as the update function to update the image position is initialized. 49 | The settings from the main window are initialized as well. 50 | ''' 51 | self.currentImagePath = imagePath 52 | self.movie = None 53 | 54 | self.moveWithWindow = False 55 | self.keepCentered = False 56 | 57 | if imagePath: 58 | self.loadImage(imagePath, 1) 59 | self.updateFunction(1) 60 | 61 | def updateFunction(self, timeStep): 62 | ''' 63 | A timer to keep track and update the image's position until the window is moved. 64 | timeStep determines how often the update function is called and can be controlled by radio buttons in the main window. 65 | ''' 66 | self.timer = QTimer(self) 67 | self.timer.timeout.connect(self.updateImagePosition) 68 | self.timer.start(timeStep) 69 | self.isImageMoved = False # Flag to check if the image window has been moved 70 | 71 | def moveEvent(self, event): 72 | ''' 73 | Override the default move event to communicate the positon between different windows when an Image window is moved. 74 | It ensures that image remains at a consistent position relative to the other windows. 75 | ''' 76 | if not self.moveWithWindow: # Return if 'Move With Window' has not been checked in the main window 77 | return 78 | super().moveEvent(event) 79 | if self.currentImagePath: 80 | globalPos = self.mapToGlobal(self.imageLabel.pos()) 81 | self.imageMoved.emit(globalPos) # Communicate the image position to different windows 82 | self.isImageMoved = True # Indicated that window has been moved manually 83 | 84 | def loadImage(self, imagePath, scaleFactor): 85 | ''' 86 | Load the Image/GIF into the window and apply the scaleFactor. 87 | Decides between QMovie and QPixmap based on the type of image. 88 | If .gif it uses QMovie, else QPixmap. 89 | ''' 90 | self.currentImagePath = imagePath 91 | if imagePath.lower().endswith('.gif'): 92 | self.movie = QMovie(imagePath) 93 | self.movie.setScaledSize(self.imageLabel.size() * scaleFactor) 94 | self.imageLabel.setMovie(self.movie) 95 | self.movie.start() 96 | self.initScale = self.imageLabel.size() # Storing intial scale 97 | else: 98 | self.movie = None 99 | pixmap = QPixmap(imagePath) 100 | if not pixmap.isNull(): 101 | scaledPixmap = pixmap.scaled(self.size() * scaleFactor, Qt.KeepAspectRatio, Qt.SmoothTransformation) 102 | self.imageLabel.setPixmap(scaledPixmap) 103 | self.initScale = self.size() # Storing initial scale 104 | 105 | def centerImage(self): 106 | ''' 107 | Move the image/GIF to the center of the window. 108 | ''' 109 | if self.movie: 110 | rect = QRect(0, 0, self.movie.scaledSize().width(), self.movie.scaledSize().height()) 111 | elif self.imageLabel.pixmap() and not self.imageLabel.pixmap().isNull(): 112 | pixmap = self.imageLabel.pixmap() 113 | rect = pixmap.rect() 114 | else: 115 | return 116 | 117 | rect.moveCenter(self.rect().center()) 118 | self.imageLabel.setGeometry(rect) 119 | 120 | def updateImagePosition(self): 121 | ''' 122 | Used to update the position of image ensuring that the image remains at a consistent position relative to the other windows. 123 | If the user has "Keep Centered" checked it aligns the images of the active window to the center and aligns images of other windows around it. 124 | ''' 125 | if self.isImageMoved: # Skip if window was moved, then moveEvent governs the image position 126 | return 127 | 128 | if self.currentImagePath: 129 | activeWindow = QApplication.activeWindow() 130 | 131 | if self == activeWindow and self.keepCentered: 132 | self.centerImage() 133 | return 134 | 135 | if self.parent().imageWindows: 136 | if isinstance(activeWindow, ImageWindow) and self.keepCentered: 137 | refWindow = activeWindow # Active window is the one that's selected and it governs the image positions 138 | else: 139 | refWindow = self.parent().imageWindows[0] # If no window has been selected the first one governs the image positions 140 | globalPos = refWindow.mapToGlobal(refWindow.imageLabel.pos()) 141 | localPos = self.mapFromGlobal(globalPos) 142 | self.imageLabel.move(localPos) 143 | self.update() 144 | 145 | 146 | def restartGif(self): 147 | '''Used to sync the GIF animation across all windows.''' 148 | if self.currentImagePath and self.currentImagePath.lower().endswith('.gif'): 149 | if self.movie: 150 | self.movie.stop() 151 | self.movie.start() 152 | 153 | def setScale(self, scaleFactor): 154 | ''' 155 | Scale the image and its label based on the user input by using the scale slider. 156 | ''' 157 | if self.movie and self.initScale: 158 | newSize = self.initScale * scaleFactor 159 | self.movie.setScaledSize(newSize) 160 | elif self.imageLabel.pixmap() and not self.imageLabel.pixmap().isNull() and self.initScale: 161 | newSize = self.initScale * scaleFactor 162 | scaledPixmap = QPixmap(self.currentImagePath).scaled(newSize, Qt.KeepAspectRatio, Qt.SmoothTransformation) 163 | self.imageLabel.setPixmap(scaledPixmap) 164 | self.imageLabel.setFixedSize(newSize) 165 | self.imageLabel.adjustSize() 166 | 167 | def closeEvent(self, event): 168 | ''' 169 | Communicate to the main window that one image has been closed to remove it from the list of the imageWindows. 170 | ''' 171 | self.windowClosing.emit(self) 172 | super().closeEvent(event) -------------------------------------------------------------------------------- /src/MainWindow.py: -------------------------------------------------------------------------------- 1 | import random 2 | from fileinput import filename 3 | from PyQt5.QtWidgets import * 4 | from PyQt5.QtCore import * 5 | from PyQt5.QtGui import * 6 | from ImageWindow import ImageWindow 7 | 8 | class MainWindow(QMainWindow): 9 | ''' 10 | This class manages all the image windows and governs various settings, window count, and the image to be applied on the image windows. 11 | ''' 12 | def __init__(self): 13 | ''' 14 | Initialize the UI of the window, assign the default path of the images, and intialize an array to store all the image windows. 15 | ''' 16 | super().__init__() 17 | self.imageWindows = [] 18 | self.currentImagePath = 'Files/tess.gif' 19 | self.initUI() 20 | 21 | def initUI(self): 22 | ''' 23 | Initialize the window settings, layout and the UI components. 24 | ''' 25 | self.initWindow() 26 | self.initLayout() 27 | self.initComponents() 28 | 29 | def initWindow(self): 30 | ''' 31 | The main window settings and properties are assigned here. 32 | The winow location is kept relative to the screen size and the size of the window is goverened by the layout itself. 33 | ''' 34 | screen = QApplication.primaryScreen().size() 35 | self.screenWidth = screen.width() 36 | self.screenHeight = screen.height() 37 | 38 | self.move(self.screenWidth // 10, self.screenHeight // 10) 39 | 40 | self.setWindowIcon(QIcon('Files/logo.png')) 41 | self.setWindowTitle('Image Window Manager') 42 | 43 | def initLayout(self): 44 | ''' 45 | The layouts that make up the window are intialized here. This includes: 46 | - Central Layout (Widget) 47 | - Left Side Layout (Controls) 48 | - Right Side Layout (Image Display) 49 | - Button Layout (Bottom Buttons) 50 | ''' 51 | self.centralWidget = QWidget(self) 52 | self.setCentralWidget(self.centralWidget) 53 | self.mainLayout = QHBoxLayout(self.centralWidget) 54 | 55 | self.leftLayout = QVBoxLayout() 56 | self.leftLayout.setSizeConstraint(QLayout.SetFixedSize) 57 | 58 | self.rightLayout = QVBoxLayout() 59 | 60 | self.btnLayout = QHBoxLayout() 61 | self.btnLayout.addStretch(1) 62 | 63 | def initComponents(self): 64 | ''' 65 | Initialize all the UI components that are defined below. 66 | ''' 67 | self.initMenuBar() 68 | self.initStatusBar() 69 | self.initToolBar() 70 | self.initScaleSlider() 71 | self.initCheckBoxes() 72 | self.initRadioBtns() 73 | self.initImageDisplay() 74 | self.initBottomButtons() 75 | 76 | def initMenuBar(self): 77 | ''' 78 | Initializes a Menu Bar with options to load an image/gif and close all the windows that are open. 79 | ''' 80 | menuBar = self.menuBar() 81 | fileMenu = menuBar.addMenu('&File') 82 | 83 | loadAction = QAction('&Open Image/GIF', self) 84 | loadAction.triggered.connect(self.openFileDialog) 85 | fileMenu.addAction(loadAction) 86 | 87 | closeAllAction = QAction('&Close All Windows', self) 88 | closeAllAction.triggered.connect(self.closeAllImageWindows) 89 | fileMenu.addAction(closeAllAction) 90 | 91 | def initStatusBar(self): 92 | ''' 93 | Initalize a Status Bar to message about display different operations in the bottom of the screen. 94 | ''' 95 | self.statusBar = QStatusBar() 96 | self.setStatusBar(self.statusBar) 97 | self.statusBar.showMessage('Open a new window!', 5000) 98 | 99 | def initToolBar(self): 100 | ''' 101 | Initialize a Tool Bar with options to randomize the settings and open multiple windows based on the number of window specified. 102 | ''' 103 | toolBar = QToolBar('Tool Bar', self) 104 | self.addToolBar(Qt.TopToolBarArea, toolBar) 105 | 106 | randomizeAction = QAction(QIcon('Files/randomize.png'), 'Randomize', self) 107 | randomizeAction.triggered.connect(self.randomizeSettings) 108 | toolBar.addAction(randomizeAction) 109 | 110 | self.numWindowsInput = QLineEdit(self) 111 | self.numWindowsInput.setValidator(QIntValidator(1, 99)) 112 | self.numWindowsInput.setText('5') 113 | self.numWindowsInput.setFixedWidth(self.screenWidth // 50) 114 | toolBar.addWidget(self.numWindowsInput) 115 | 116 | openWindowsBtn = QPushButton('Open Windows', self) 117 | openWindowsBtn.clicked.connect(self.openMultipleWindows) 118 | toolBar.addWidget(openWindowsBtn) 119 | 120 | def initScaleSlider(self): 121 | ''' 122 | Initialize a Slider for scaling the image. 123 | The values are scaled 100 folds to enable fine control and each step for a slider is an integer. 124 | ''' 125 | self.scaleLabel = QLabel('Scale Image') 126 | self.scaleSlider = QSlider(Qt.Horizontal) 127 | 128 | self.scaleSlider.setFixedSize(self.screenWidth // 15, self.screenHeight // 50) 129 | self.scaleSlider.setRange(25, 400) # Represents .25 to 4 130 | self.scaleSlider.setValue(100) # Represents 1 131 | self.scaleSlider.valueChanged.connect(self.updateImageScale) 132 | 133 | self.leftLayout.addWidget(self.scaleLabel) 134 | self.leftLayout.addWidget(self.scaleSlider) 135 | 136 | def initCheckBoxes(self): 137 | ''' 138 | Initilize checkboxes for Image Settings. 139 | ''' 140 | self.imageSettLabel = QLabel('Image Settings') 141 | self.moveWWindowCb = QCheckBox('Move with window') 142 | self.keepCenteredCb = QCheckBox('Keep Centered') 143 | 144 | self.leftLayout.addWidget(self.imageSettLabel) 145 | self.leftLayout.addWidget(self.moveWWindowCb) 146 | self.leftLayout.addWidget(self.keepCenteredCb) 147 | 148 | self.moveWWindowCb.clicked.connect(self.updateImageSettings) 149 | self.keepCenteredCb.clicked.connect(self.updateImageSettings) 150 | self.moveWWindowCb.setChecked(True) # Default 151 | 152 | def initRadioBtns(self): 153 | ''' 154 | Initialize radio buttons to control the refresh rate of the update function of Image Windows. 155 | ''' 156 | self.refreshRateLabel = QLabel('Refresh Rate') 157 | self.slowR = QRadioButton('Slow') 158 | self.medR = QRadioButton('Medium') 159 | self.fastR = QRadioButton('Fast') 160 | self.fastR.setChecked(True) # Default 161 | 162 | self.slowR.clicked.connect(self.updateTimeStep) 163 | self.medR.clicked.connect(self.updateTimeStep) 164 | self.fastR.clicked.connect(self.updateTimeStep) 165 | 166 | self.leftLayout.addWidget(self.refreshRateLabel) 167 | self.leftLayout.addWidget(self.slowR) 168 | self.leftLayout.addWidget(self.medR) 169 | self.leftLayout.addWidget(self.fastR) 170 | 171 | def initImageDisplay(self): 172 | ''' 173 | Initialize area for displaying image that will be diplayed in all the Image Windows. 174 | ''' 175 | self.imgDisp = QLabel('Display the image here') 176 | self.imgDisp.setAlignment(Qt.AlignCenter) 177 | self.rightLayout.addWidget(self.imgDisp) 178 | 179 | def initBottomButtons(self): 180 | ''' 181 | Initialize buttons to open a new Image Window or Quit the application. 182 | ''' 183 | self.newWindowBtn = QPushButton('Open Image Window') 184 | self.newWindowBtn.clicked.connect(self.openNewWindow) 185 | 186 | self.quitBtn = QPushButton('Quit') 187 | self.quitBtn.clicked.connect(self.confirmQuit) 188 | 189 | self.btnLayout.addWidget(self.newWindowBtn) 190 | self.btnLayout.addWidget(self.quitBtn) 191 | self.leftLayout.addStretch(1) 192 | 193 | self.mainLayout.addLayout(self.leftLayout, 1) 194 | self.mainLayout.addLayout(self.rightLayout, 1) 195 | self.rightLayout.addLayout(self.btnLayout) 196 | 197 | def openFileDialog(self): 198 | ''' 199 | Opens File Explorer to choose image/GIF files. 200 | The selected image is displayed in Image Windows. 201 | ''' 202 | self.statusBar.showMessage('Choosing a new Image/GIF', 5000) 203 | 204 | options = QFileDialog.Options() 205 | fileName, i = QFileDialog.getOpenFileName(self, 'Open Image', '', 'Image Files (*.jpg;*.jpeg;*.png;*.bmp;*.gif)', options=options) 206 | 207 | if fileName: 208 | self.currentImagePath = fileName 209 | 210 | self.updateAllWindows() 211 | 212 | def updateAllWindows(self): 213 | ''' 214 | Updates image preview in the main window and loads the image in all image windows. 215 | ''' 216 | self.displayImagePreview() 217 | 218 | for window in self.imageWindows: 219 | window.loadImage(self.currentImagePath, self.scaleSlider.value() / 100) # Scale = 1 220 | 221 | def openNewWindow(self): 222 | ''' 223 | Opens a new image window as a child of the main window. 224 | Connects signals for image position and window closing. 225 | ''' 226 | newWindow = ImageWindow(self) 227 | 228 | newWindow.imageMoved.connect(self.onImageMoved) 229 | newWindow.windowClosing.connect(self.removeImageWindow) 230 | 231 | newWindow.show() 232 | 233 | self.imageWindows.append(newWindow) 234 | 235 | newWindow.loadImage(self.currentImagePath, self.scaleSlider.value() / 100) 236 | 237 | self.statusBar.showMessage('Opened new window', 3000) 238 | 239 | for window in self.imageWindows: # Sync GIF 240 | window.restartGif() 241 | 242 | self.displayImagePreview() 243 | 244 | self.updateAllSettings() 245 | 246 | def onImageMoved(self, globalPos): 247 | ''' 248 | Handles communication b/w windows on moving a window and updates the position of the images in other windows. 249 | ''' 250 | senderWindow = self.sender() 251 | 252 | for window in self.imageWindows: 253 | if window is not senderWindow: 254 | localPos = window.mapFromGlobal(globalPos) 255 | window.imageLabel.move(localPos) 256 | window.update() # Refresh 257 | window.isImageMoved = False 258 | 259 | def displayImagePreview(self): 260 | ''' 261 | Display current image in image display area and scale the image to fit the display area while maintaining aspect ratio. 262 | ''' 263 | pixmap = QPixmap(self.currentImagePath) 264 | 265 | if not pixmap.isNull(): 266 | scaledPixmap = pixmap.scaled(self.imgDisp.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) 267 | 268 | self.imgDisp.setPixmap(scaledPixmap) 269 | self.imgDisp.adjustSize() 270 | else: 271 | self.statusBar.showMessage('Failed to load image.', 5000) 272 | 273 | def updateAllSettings(self): 274 | ''' 275 | Single method to update all settings from the Main Window. 276 | ''' 277 | self.updateImageScale() 278 | self.updateTimeStep() 279 | self.updateImageSettings() 280 | 281 | def updateTimeStep(self): 282 | ''' 283 | Update refresh rate of Update Function in the windows based on the radio button selected. 284 | ''' 285 | timeStep = 0 286 | if self.fastR.isChecked(): 287 | timeStep = 1 288 | elif self.medR.isChecked(): 289 | timeStep = 100 290 | else: 291 | timeStep = 2000 292 | 293 | for window in self.imageWindows: 294 | window.updateFunction(timeStep) 295 | 296 | def updateImageScale(self): 297 | ''' 298 | Apply scale value from the slider to the all the image and their labels in the image window. 299 | ''' 300 | scaleFactor = self.scaleSlider.value() / 100 # Scale = 1 301 | 302 | for window in self.imageWindows: 303 | window.setScale(scaleFactor) 304 | 305 | self.statusBar.showMessage('Scale Value Assigned', 5000) 306 | 307 | def updateImageSettings(self): 308 | ''' 309 | Apply settings from the checkboxes. 310 | Give message in the status bar about one of the bugs that we're currently facing. 311 | ''' 312 | moveWithWindow = self.moveWWindowCb.isChecked() 313 | keepCentered = self.keepCenteredCb.isChecked() 314 | 315 | for window in self.imageWindows: 316 | window.setMoveWithWindow(moveWithWindow) 317 | window.setKeepCentered(keepCentered) 318 | 319 | if (moveWithWindow == False): 320 | self.statusBar.showMessage('Parent window still moves the image for ref to other windows', 5000) # To be fixed 321 | 322 | def randomizeSettings(self): 323 | ''' 324 | Randomize all the Main Window Settings. 325 | ''' 326 | self.scaleSlider.setValue(random.randint(25, 400)) 327 | 328 | self.moveWWindowCb.setChecked(random.choice([True, False])) 329 | self.keepCenteredCb.setChecked(random.choice([True, False])) 330 | 331 | random.choice([self.slowR, self.medR, self.fastR]).setChecked(True) 332 | 333 | for i in range(random.randint(2, 10)): 334 | self.openNewWindow() 335 | 336 | self.updateAllSettings() 337 | 338 | def openMultipleWindows(self): 339 | ''' 340 | Open multiple mindows specified in the toolbar. 341 | ''' 342 | numWindows = int(self.numWindowsInput.text()) 343 | 344 | for i in range(numWindows): 345 | self.openNewWindow() 346 | 347 | self.statusBar.showMessage('Opened {} windows!'.format(numWindows), 5000) 348 | 349 | def closeAllImageWindows(self): 350 | ''' 351 | Close all the windows and remove their reference in the Main Window. 352 | ''' 353 | windowsToClose = self.imageWindows.copy() 354 | 355 | for window in windowsToClose: 356 | window.close() 357 | 358 | self.imageWindows.clear() 359 | 360 | def removeImageWindow(self, window): 361 | ''' 362 | Remove closed window based on signals from the closing window. 363 | ''' 364 | if window in self.imageWindows: 365 | self.imageWindows.remove(window) 366 | 367 | def confirmQuit(self): 368 | ''' 369 | Quit Window. You can't see me :-] 370 | ''' 371 | self.statusBar.showMessage('Quitting', 5000) 372 | reply = QMessageBox() 373 | reply.setWindowIcon(QIcon('Files/johnwacenwa.png')) 374 | reply.setIcon(QMessageBox.Question) 375 | reply.setText('Are you sure about that?') 376 | reply.setWindowTitle('Confirm Quit') 377 | reply.setStandardButtons(QMessageBox.Yes | QMessageBox.No) 378 | returnVal = reply.exec_() 379 | 380 | if returnVal == QMessageBox.Yes: 381 | QApplication.instance().quit() -------------------------------------------------------------------------------- /src/MultiWindowTest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from PyQt5.QtWidgets import QApplication 3 | from MainWindow import MainWindow 4 | 5 | def main(): 6 | ''' 7 | Creates the Image Window Manager that can be used to open the image windows. 8 | ''' 9 | app = QApplication(sys.argv) 10 | main_window = MainWindow() 11 | main_window.show() 12 | sys.exit(app.exec_()) # For clean exit 13 | 14 | main() -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | PyQt5==5.15.10 --------------------------------------------------------------------------------