├── .github └── workflows │ ├── build-and-test.yml │ └── do-release.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── icon256.ico ├── icon256.png └── icon32.ico ├── requirements-dev.txt ├── requirements.txt ├── ruff.toml ├── src ├── _version.py ├── common.py ├── device.py ├── gui │ ├── __init__.py │ ├── layout_manager.py │ ├── on_spawn_manager.py │ ├── rule_manager.py │ ├── settings.py │ ├── systray.py │ ├── widgets.py │ └── wx_app.py ├── main.py ├── named_pipe.py ├── services.py ├── snapshot.py ├── win32_extras.py └── window.py ├── test ├── __init__.py ├── conftest.py ├── test_common.py └── test_window.py └── tools ├── choco_package.py ├── choco_template ├── restorewindowpos.nuspec └── tools │ ├── VERIFICATION.txt │ ├── chocolateybeforemodify.ps1 │ ├── chocolateyinstall.ps1 │ └── chocolateyuninstall.ps1 ├── compile.bat ├── format.bat ├── installer.nsi ├── test-installer.ps1 └── version_file.py /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | paths: 6 | - src/** 7 | - tools/** 8 | - test/** 9 | - requirements*.txt 10 | - .github/workflows/build-and-test.yml 11 | branches: 12 | - '**' 13 | tags-ignore: 14 | - '**' 15 | pull_request: 16 | workflow_dispatch: 17 | inputs: 18 | gitRef: 19 | description: The git ref to build against 20 | required: true 21 | type: string 22 | doArtifact: 23 | description: Publish an artifact 24 | required: false 25 | default: false 26 | type: boolean 27 | workflow_call: 28 | inputs: 29 | gitRef: 30 | description: The git ref to build against 31 | required: true 32 | type: string 33 | doArtifact: 34 | description: Publish an artifact 35 | required: false 36 | default: false 37 | type: boolean 38 | 39 | 40 | jobs: 41 | build-and-test: 42 | runs-on: windows-latest 43 | steps: 44 | - uses: actions/checkout@v4 45 | with: 46 | ref: ${{ inputs.gitRef }} 47 | - uses: actions/setup-python@v5 48 | with: 49 | python-version: "3.13" 50 | - name: Install Python dependencies 51 | run: | 52 | python -m pip install -r requirements-dev.txt 53 | - name: Python Tests 54 | run: pytest --verbose 55 | - name: Install build tools 56 | run: | 57 | choco install nsis upx -y 58 | - name: Build package 59 | run: | 60 | tools\compile.bat 61 | - name: Test Installer 62 | run: | 63 | tools\test-installer.ps1 -confirm y 64 | - name: Publish artifact 65 | if: ${{ inputs.doArtifact }} 66 | uses: actions/upload-artifact@v4 67 | with: 68 | name: RWP-dist 69 | path: dist/ 70 | -------------------------------------------------------------------------------- /.github/workflows/do-release.yml: -------------------------------------------------------------------------------- 1 | name: Release on tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | description: The version to publish 11 | required: true 12 | gitRef: 13 | description: The git ref to build against 14 | required: false 15 | 16 | jobs: 17 | getversion: 18 | runs-on: ubuntu-latest 19 | outputs: 20 | version: ${{ steps.get_version.outputs.pkgVersion }} 21 | gitRef: ${{ steps.get_version.outputs.gitRef }} 22 | steps: 23 | - name: Get version 24 | id: get_version 25 | run: | 26 | version="" 27 | if [ "${{ github.event_name }}" == "workflow_dispatch" ] 28 | then 29 | version="${{ github.event.inputs.version }}" 30 | else 31 | version="$GITHUB_REF_NAME" 32 | fi 33 | echo "pkgVersion=$version" >> $GITHUB_OUTPUT 34 | echo "Version: $version" 35 | 36 | gitRef="" 37 | if [ "${{ github.event.inputs.gitRef }}" != "" ] 38 | then 39 | gitRef="${{ github.event.inputs.gitRef }}" 40 | else 41 | gitRef="$version" 42 | fi 43 | echo "gitRef=$gitRef" >> $GITHUB_OUTPUT 44 | echo "GIT Ref: $gitRef" 45 | do-build: 46 | needs: getversion 47 | uses: ./.github/workflows/build-and-test.yml 48 | with: 49 | gitRef: ${{ needs.getversion.outputs.gitRef }} 50 | doArtifact: true 51 | publish: 52 | runs-on: windows-latest 53 | needs: [do-build, getversion] 54 | environment: build 55 | steps: 56 | - uses: actions/download-artifact@v4 57 | with: 58 | name: RWP-dist 59 | path: dist 60 | - run: | 61 | Get-ChildItem -Recurse 62 | - name: Get installer checksum 63 | id: get_installer_checksum 64 | run: | 65 | $rwpChecksum=$(Get-FileHash -Path .\dist\RestoreWindowPos_install.exe -Algorithm sha256).Hash 66 | echo "SHA256 checksum: $rwpChecksum" 67 | echo "pkgChecksum=$rwpChecksum" >> $env:GITHUB_OUTPUT 68 | - name: Draft GitHub release 69 | uses: softprops/action-gh-release@v2 70 | with: 71 | body: "RestoreWindowPos_install.exe SHA256 Checksum: `${{steps.get_installer_checksum.outputs.pkgChecksum}}`" 72 | draft: true 73 | files: "dist/RestoreWindowPos_install.exe" 74 | name: "RestoreWindowPos v${{ needs.getversion.outputs.version }}" 75 | tag_name: ${{needs.getversion.outputs.version}} 76 | - name: Push to chocolatey 77 | env: 78 | PKGVERSION: ${{ needs.getversion.outputs.version }} 79 | run: | 80 | choco push "dist/restorewindowpos.$env:PKGVERSION.nupkg" --source "https://push.chocolatey.org/" -k "${{secrets.CHOCO_API_KEY}}" 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/history.json 2 | **/settings.json 3 | __pycache__ 4 | assets/*.png 5 | build/ 6 | dist/ 7 | *.spec 8 | **log.txt -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome. Issues, ideas and pull requests are all appreciated. 4 | 5 | ## Dev Setup 6 | 7 | This library is built for Python 3.10 and up, although `wxPython` wheels are currently unavailable for Python 3.11. 8 | 9 | You will need to install NSIS and add it to your PATH in order to compile the installer for the program, but you can bundle the app 10 | itself with the following commands alone: 11 | 12 | ``` 13 | git clone https://github.com/Crozzers/RestoreWindowPos 14 | cd RestoreWindowPos 15 | pip install -r requirements.dev.txt 16 | .\tools\compile.bat 17 | ``` 18 | 19 | ## Releases 20 | 21 | When publishing a release, the dev must do the following: 22 | * Bump version number in `src/_version.py` 23 | * Create a git tag for the release 24 | * `git push` and `git push --tags` 25 | * Approve chocolatey pipeline deployment 26 | * Update release drafted by github actions 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2024 Crozzers (https://github.com/Crozzers) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RestoreWindowPos 2 | 3 | Whenever I connect/disconnect a monitor, all of my windows jump around, resize and teleport to places they are not meant to be in. 4 | 5 | This project aims to fix this behaviour by taking regular snapshots of window positions. Once it detects a display being connected/disconnected, it will restore windows to their last known positions on that display. 6 | 7 | You can also define rules for windows with specific titles and/or created by specific programs. Rules will be automatically applied to matching windows that are not part of your current snapshot (eg: windows that have been created since a snapshot was last taken). 8 | You can also give these rules memorable names, and apply any and/or all of them at any time 9 | 10 | ## Installation 11 | 12 | ### Chocolatey 13 | 14 | The [RestoreWindowPos Chocolatey package](https://community.chocolatey.org/packages/restorewindowpos/) can be installed with this command: 15 | ``` 16 | choco install restorewindowpos 17 | ``` 18 | 19 | Chocolatey packages are auto-generated each release using [GitHub actions](https://github.com/Crozzers/RestoreWindowPos/actions). The packages are then submitted to Chocolatey for review and to be published. This process does take time, so the Chocolatey version of the package may lag behind the latest GitHub release. 20 | 21 | #### Package Parameters 22 | 23 | | Parameter | Descrption | 24 | |----------------------|---------------------------------------------------| 25 | | `/StartAfterInstall` | Launch the program after installation is finished | 26 | | `/DesktopShortcut` | Create a desktop shortcut for the program | 27 | | `/StartMenuShortcut` | Create a start menu shortcut for the program | 28 | 29 | Example: 30 | ``` 31 | choco install restorewindowpos --params '"/StartAfterInstall /DesktopShortcut /StartMenuShortcut"' 32 | ``` 33 | 34 | ### Manual install 35 | 36 | Head over to the [releases page](https://github.com/Crozzers/RestoreWindowPos/releases) to grab the latest installer 37 | for the program. 38 | 39 | ## Updating 40 | 41 | ### Chocolatey 42 | 43 | If you used Chocolatey to install, it should be as simple as running: 44 | ``` 45 | choco upgrade restorewindowpos 46 | ``` 47 | And if you want to immediately restart the program after upgrading: 48 | ``` 49 | choco upgrade restorewindowpos --params '"/StartAfterInstall"' 50 | ``` 51 | This should handle exiting any currently running instances and installing the new version. If it doesn't work, or if the new files aren't properly copied across, try manually shutting down any running instances and upgrading after that. 52 | 53 | ### Manual 54 | 55 | To update to the latest version, download the latest installer from the [releases page](https://github.com/Crozzers/RestoreWindowPos/releases) and run it. Make sure to shutdown any running instances of RestoreWindowPos beforehand, otherwise the installer won't be able to overwrite your previous install. 56 | 57 | To shutdown RestoreWindowPos, simply right click the system tray icon and click "Quit". Wait a couple of seconds for the program to shut itself down properly then launch the latest installer. 58 | 59 | If the newly installed update throws an error on launch, try moving your snapshot history file. 60 | Hit Win + R and enter `%localappdata%\Programs\RestoreWindowPos`. Rename `history.json` to `history.json.old`. 61 | If this does not resolve your issue, please [report the issue](https://github.com/Crozzers/RestoreWindowPos/issues). 62 | 63 | ## Contributing 64 | 65 | Check the [contribution guidelines](CONTRIBUTING.md) for instructions on how contribute to the project, and instructions on how to compile the program. 66 | 67 | ## Features 68 | 69 | * Regular snapshots of current window layout (with options for various different intervals) 70 | * Remembers window sizes and positions and restores them when monitors are connected/disconnected 71 | * Can restore snapped windows 72 | * Can restore past snapshots 73 | * Easy to use installer that registers the program as a startup task 74 | * Create and apply rules for specific windows 75 | * Create and apply rules for specific display configurations 76 | * React to new windows spawning and take some predefined action 77 | -------------------------------------------------------------------------------- /assets/icon256.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Crozzers/RestoreWindowPos/b81227e4f32cc80436ee679e10e9d325ec7f733f/assets/icon256.ico -------------------------------------------------------------------------------- /assets/icon256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Crozzers/RestoreWindowPos/b81227e4f32cc80436ee679e10e9d325ec7f733f/assets/icon256.png -------------------------------------------------------------------------------- /assets/icon32.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Crozzers/RestoreWindowPos/b81227e4f32cc80436ee679e10e9d325ec7f733f/assets/icon32.ico -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | # packaging 3 | pyinstaller 4 | pyinstaller-versionfile 5 | packaging 6 | # testing 7 | pytest 8 | pytest-mock 9 | # lint/formatting 10 | ruff 11 | isort 12 | string-fixer 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pywin32 2 | wmi 3 | pypiwin32 4 | wxPython 5 | pyvda>=0.4.0 6 | psutil 7 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 119 2 | target-version = "py311" 3 | 4 | [format] 5 | quote-style = "single" 6 | skip-magic-trailing-comma = true 7 | line-ending = "lf" 8 | -------------------------------------------------------------------------------- /src/_version.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import win32api 4 | 5 | __version__ = '0.22.0' 6 | __build__ = None 7 | 8 | if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): 9 | __build__ = win32api.LOWORD(win32api.GetFileVersionInfo(sys.executable, '\\')['FileVersionLS']) 10 | 11 | if __name__ == '__main__': 12 | print(__version__) 13 | -------------------------------------------------------------------------------- /src/common.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import operator 4 | import os 5 | import re 6 | import sys 7 | import threading 8 | import time 9 | from types import UnionType 10 | import typing 11 | from dataclasses import asdict, dataclass, field, is_dataclass 12 | from functools import lru_cache 13 | from typing import Any, Callable, Iterable, Literal, Optional, Self, Union, overload 14 | 15 | import pythoncom 16 | import pywintypes 17 | import win32api 18 | import win32con 19 | import win32gui 20 | import win32process 21 | import wmi 22 | 23 | from win32_extras import DwmGetWindowAttribute, GetDpiForMonitor 24 | 25 | log = logging.getLogger(__name__) 26 | 27 | # some basic types 28 | XandY = tuple[int, int] 29 | Rect = tuple[int, int, int, int] 30 | """X, Y, X1, Y1""" 31 | Placement = tuple[int, int, XandY, XandY, Rect] 32 | """Flags, showCmd, min pos, max pos, normal pos""" 33 | 34 | 35 | def local_path(path, asset=False): 36 | if getattr(sys, 'frozen', False): 37 | if asset: 38 | base = sys._MEIPASS # type: ignore 39 | else: 40 | base = os.path.dirname(sys.executable) 41 | else: 42 | base = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) 43 | 44 | return os.path.abspath(os.path.join(base, path)) 45 | 46 | 47 | def single_call(func): 48 | has_been_called = False 49 | 50 | def inner_func(*a, **kw): 51 | nonlocal has_been_called 52 | if has_been_called: 53 | return 54 | has_been_called = True 55 | return func(*a, **kw) 56 | 57 | return inner_func 58 | 59 | 60 | def size_from_rect(rect: Rect) -> XandY: 61 | return (rect[2] - rect[0], rect[3] - rect[1]) 62 | 63 | 64 | def reverse_dict_lookup(d: dict, value): 65 | return list(d.keys())[list(d.values()).index(value)] 66 | 67 | 68 | def match(a: Optional[int | str], b: Optional[int | str]) -> int: 69 | """ 70 | Check if `a` matches `b` as an integer or string. 71 | Values are deemed to be "matching" if they are equal in some way, with more exact equalities 72 | resulting in stronger matches. Integers are matched on absulute value. Strings are matched 73 | against `a` as a regex. 74 | 75 | Returns: 76 | A score that indicates how well the two match. 0 means no match, 1 means 77 | partial match and 2 means exact match. 78 | """ 79 | 80 | if a is None or b is None: 81 | return 1 82 | if a == b: 83 | return 2 84 | 85 | # both must be same type. Just do this to make type checker happy 86 | if isinstance(a, int) and isinstance(b, int): 87 | return 2 if abs(a) == abs(b) else 0 88 | 89 | if isinstance(a, str) and isinstance(b, str): 90 | try: 91 | return 0 if re.match(a, b, re.IGNORECASE) is None else 1 92 | except re.error: 93 | log.exception(f'fail to compile pattern "{a}"') 94 | return 0 95 | 96 | return 0 97 | 98 | 99 | def str_to_op(op_name: str) -> Callable[[Any, Any], bool]: 100 | if op_name in ('lt', 'le', 'eq', 'ge', 'gt'): 101 | return getattr(operator, op_name) 102 | raise ValueError(f'invalid operation {op_name!r}') 103 | 104 | 105 | def dpi_scale(x: int, dpi: int) -> int: 106 | """Scale a number according to a DPI""" 107 | # DPI / standard DPI = scaling factor, eg: 144 / 96 = 1.5 = 150% in windows settings 108 | return int(x / (dpi / 96)) 109 | 110 | 111 | class JSONFile: 112 | def __init__(self, file: str, *a, **kw): 113 | self._log = logging.getLogger(__name__).getChild(self.__class__.__name__ + '.' + str(id(self))) 114 | self.file = file 115 | self.lock = threading.RLock() 116 | 117 | def load(self, default=None): 118 | with self.lock: 119 | try: 120 | with open(local_path(self.file), 'r') as f: 121 | self.data = json.load(f) 122 | except (FileNotFoundError, json.decoder.JSONDecodeError): 123 | self.data = default if default is not None else {} 124 | except Exception: 125 | self._log.exception('failed to load file "%s"' % self.file) 126 | raise 127 | 128 | def save(self, data=None): 129 | with self.lock: 130 | if data is None: 131 | data = self.data 132 | try: 133 | with open(local_path(self.file), 'w') as f: 134 | json.dump(data, f) 135 | except Exception: 136 | self._log.exception('failed to save file "%s"' % self.file) 137 | raise 138 | 139 | def set(self, key, value): 140 | with self.lock: 141 | self.data[key] = value 142 | self.save() 143 | 144 | def get(self, key, default=None): 145 | with self.lock: 146 | try: 147 | return self.data[key] 148 | except (IndexError, KeyError): 149 | return default 150 | 151 | 152 | @lru_cache 153 | def load_json(file: Literal['settings', 'history']): 154 | """ 155 | Load a JSON file and cache the instance. Useful for creating global instances for settings files. 156 | The `.json` suffix is automatically added if missing. 157 | """ 158 | json_file = JSONFile(file + '.json') 159 | json_file.load() 160 | return json_file 161 | 162 | 163 | def tuple_convert(item: Iterable, to=tuple, from_: type | UnionType = list): 164 | if isinstance(item, from_): 165 | item = to(tuple_convert(sub, to=to, from_=from_) for sub in item) 166 | return item 167 | 168 | 169 | class JSONType: 170 | @classmethod 171 | def from_json(cls, data: dict) -> Self | None: 172 | if is_dataclass(data): 173 | if isinstance(data, type): 174 | # check if just the type itself, not an actual instance 175 | # (should never happen but needed for type checker) 176 | return None 177 | if isinstance(data, cls): 178 | # if the dataclass is an instance of this class, return it 179 | return data 180 | # convert to an instance of this class 181 | return cls(**asdict(data)) 182 | 183 | init_data = {} 184 | hints = typing.get_type_hints(cls) 185 | for field_name, field_type in hints.items(): 186 | if field_name not in data: 187 | continue 188 | sub_types = typing.get_args(field_type) 189 | if sub_types and issubclass(sub_types[0], JSONType): 190 | value = field_type(filter(None, (sub_types[0].from_json(i) for i in data[field_name]))) 191 | elif sub_types: 192 | value = tuple_convert(data[field_name], to=field_type, from_=tuple | list) 193 | if isinstance(value, tuple): 194 | # only convert sub items in tuple because tuple 195 | # types are positional 196 | try: 197 | value = field_type(sub_types[i](value[i]) for i in range(len(value))) 198 | except ValueError: 199 | pass 200 | else: 201 | if issubclass(field_type, JSONType): 202 | value = field_type.from_json(data[field_name]) 203 | else: 204 | try: 205 | value = field_type(data[field_name]) 206 | except TypeError: 207 | value = data[field_name] 208 | init_data[field_name] = value 209 | 210 | try: 211 | return cls(**init_data) 212 | except TypeError: 213 | return None 214 | 215 | 216 | @dataclass 217 | class WindowType(JSONType): 218 | size: XandY 219 | rect: Rect 220 | placement: Placement 221 | 222 | def fits_display(self, display: 'Display') -> bool: 223 | """ 224 | Check whether `self` fits within the bounds of a display. This function uses 225 | `fits_rect` internally. 226 | """ 227 | # define constant offset because window rects include the drop shadow, but 228 | # display rects don't. 229 | return self.fits_rect(display.rect, offset=self.get_border_and_shadow_thickness()) 230 | 231 | def fits_rect(self, target_rect: Rect, offset: Optional[int] = None) -> bool: 232 | """ 233 | Check whether `self` fits within a rect. This function takes into 234 | account whether `self` is minimised, maximised or floating, and applies an 235 | appropriate offset. 236 | 237 | Args: 238 | target_rect: the rect to check against `self` 239 | offset: pixel offset override 240 | """ 241 | if self.placement[1] == win32con.SW_SHOWMINIMIZED: 242 | self.rect = self.placement[4] 243 | 244 | if offset is None: 245 | if self.placement[1] == win32con.SW_SHOWMAXIMIZED: 246 | offset = self.get_border_and_shadow_thickness() 247 | else: 248 | offset = 0 249 | 250 | return ( 251 | self.rect[0] >= target_rect[0] - offset 252 | and self.rect[1] >= target_rect[1] - offset 253 | and self.rect[2] <= target_rect[2] + offset 254 | and self.rect[3] <= target_rect[3] + offset 255 | ) 256 | 257 | def fits_display_config(self, displays: list['Display']) -> bool: 258 | return any(self.fits_display(d) for d in displays) 259 | 260 | def get_closest_display_rect(self, coords: XandY) -> Rect: 261 | """ 262 | Get the `Rect` of the display that contains (or is closest to) a set of coordinates. 263 | 264 | Returns: 265 | A rect of the display, excluding the Windows taskbar. 266 | """ 267 | display = win32api.MonitorFromPoint(coords, win32con.MONITOR_DEFAULTTONEAREST) 268 | # use working area rather than total monitor area so we don't move window into the taskbar 269 | return win32api.GetMonitorInfo(display)['Work'] 270 | 271 | def get_border_and_shadow_thickness(self): 272 | """ 273 | Get the size of the window's resizable border and drop shadow in pixels. 274 | 275 | For `WindowType` objects, this is based on system metrics, not on the window itself. 276 | See also: `Window.get_border_and_shadow_thickness` 277 | """ 278 | return ( 279 | max( 280 | win32api.GetSystemMetrics(win32con.SM_CXSIZEFRAME), 281 | win32api.GetSystemMetrics(win32con.SM_CYSIZEFRAME), 282 | # on my system, the total window border offset is 8px and CXSIZEFRAME is 4px. 2x seems to line up 283 | # TODO: find a better way of getting this 284 | ) 285 | * 2 286 | ) 287 | 288 | 289 | @dataclass(slots=True) 290 | class Window(WindowType): 291 | id: int 292 | name: str 293 | executable: str 294 | resizable: bool = True 295 | 296 | def __post_init__(self): 297 | if win32gui.IsWindow(self.id): 298 | self.resizable = self.is_resizable() 299 | 300 | @property 301 | def parent(self) -> Optional['Window']: 302 | p_id = win32gui.GetParent(self.id) 303 | if p_id == 0: 304 | return None 305 | return self.from_hwnd(p_id) 306 | 307 | def center_on(self, coords: XandY): 308 | """ 309 | Centers the window around a point, making sure to keep it on screen 310 | """ 311 | # get basic centering coords 312 | w, h = self.get_size() 313 | x = coords[0] - (w // 2) 314 | y = coords[1] - (h // 2) 315 | self.move(self.rebound((x, y))) 316 | 317 | def focus(self): 318 | """ 319 | Raises a window and brings it to the top of the Z order. 320 | 321 | Called 'focus' rather than 'raise' because the latter is a keyword 322 | """ 323 | win32gui.BringWindowToTop(self.id) 324 | win32gui.ShowWindow(self.id, win32con.SW_SHOWNORMAL) 325 | 326 | @classmethod 327 | def from_hwnd(cls, hwnd: int) -> 'Window': 328 | if threading.current_thread() != threading.main_thread(): 329 | pythoncom.CoInitialize() 330 | w = wmi.WMI() 331 | # https://stackoverflow.com/a/14973422 332 | _, pid = win32process.GetWindowThreadProcessId(hwnd) 333 | exe = w.query(f'SELECT ExecutablePath FROM Win32_Process WHERE ProcessId = {pid}')[0] 334 | rect = win32gui.GetWindowRect(hwnd) 335 | 336 | return Window( 337 | id=hwnd, 338 | name=win32gui.GetWindowText(hwnd), 339 | executable=exe.ExecutablePath, 340 | size=size_from_rect(rect), 341 | rect=rect, 342 | placement=win32gui.GetWindowPlacement(hwnd), 343 | ) 344 | 345 | def get_placement(self) -> Placement: 346 | self.placement = win32gui.GetWindowPlacement(self.id) 347 | return self.placement 348 | 349 | def get_rect(self) -> Rect: 350 | self.rect = win32gui.GetWindowRect(self.id) 351 | return self.rect 352 | 353 | def get_size(self) -> XandY: 354 | return size_from_rect(self.get_rect()) 355 | 356 | def is_minimised(self) -> bool: 357 | return self.get_placement()[1] == win32con.SW_SHOWMINIMIZED 358 | 359 | def is_resizable(self) -> bool: 360 | return win32gui.GetWindowLong(self.id, win32con.GWL_STYLE) & win32con.WS_THICKFRAME 361 | 362 | def move(self, coords: XandY, size: Optional[XandY] = None): 363 | """ 364 | Move the window to a new position. This does not adjust placement. 365 | 366 | Args: 367 | coords: the new coordinates to move do 368 | size: if given, the window will be set to this size. 369 | Otherwise, current window size will be maintained 370 | """ 371 | size = size or self.get_size() 372 | target_rect: Rect = (*coords, coords[0] + size[0], coords[1] + size[1]) 373 | tries = 0 374 | while tries < 3: 375 | # multi-monitor setups with different scaling often don't resize the window properly first try 376 | # so we try multiple times and force repaints after the first one 377 | win32gui.MoveWindow(self.id, *coords, *size, tries > 1) 378 | # refresh current rect for fit check 379 | self.rect = self.get_rect() 380 | if self.fits_rect(target_rect): 381 | break 382 | tries += 1 383 | else: 384 | # sometimes we move a window to a place and it decides to display as minimised but tell Windows it isn't 385 | # so you have to click the taskbar icon to focus, click again to "minimise" and click AGAIN to get it to 386 | # actually show up. For some reason, generating a bit more churn when moving it fixes the issue 387 | win32gui.MoveWindow(self.id, *size, *size, tries > 1) 388 | time.sleep(0.05) 389 | win32gui.MoveWindow(self.id, *coords, *size, tries > 1) 390 | 391 | log.debug(f'move window {self.id}, {target_rect=}, {self.get_rect()=}, {tries=}') 392 | self.refresh() 393 | 394 | @overload 395 | def rebound(self, coords: XandY, to_rect: Optional[Rect] = None, offset: int = 0) -> XandY: 396 | ... 397 | 398 | @overload 399 | def rebound(self, coords: Rect, to_rect: Optional[Rect] = None, offset: int = 0) -> Rect: 400 | ... 401 | 402 | def rebound(self, coords: XandY | Rect, to_rect: Optional[Rect] = None, offset: int = 0) -> XandY | Rect: 403 | """ 404 | Takes a set of coordinates and moves them so that the window will not appear off-screen 405 | 406 | Args: 407 | coords: can be coordinates (top left) or a rect 408 | 409 | Returns: 410 | same type as input. Returned rects will also have the bottom right coord adjusted 411 | """ 412 | if len(coords) == 4: 413 | # rect 414 | x, y, rx, ry = coords 415 | w, h = size_from_rect(coords) 416 | else: 417 | # xandy 418 | x, y = coords 419 | w, h = self.get_size() 420 | rx, ry = x + w, y + h 421 | 422 | display_rect = to_rect or self.get_closest_display_rect((x, y)) 423 | dx, dy, drx, dry = display_rect 424 | 425 | # adjust top left 426 | # make sure bottom right corner is on-screen 427 | x, y = min(drx - w + offset, x), min(dry - h + offset, y) 428 | # make sure x, y >= top left corner of display 429 | x, y = max(dx - offset, x), max(dy - offset, y) 430 | 431 | if len(coords) == 4: 432 | # adjust bottom right 433 | rx, ry = min(drx + offset, rx), min(dry + offset, ry) 434 | return (x, y, rx, ry) 435 | 436 | return x, y 437 | 438 | def refresh(self): 439 | """Re-fetch stale window information""" 440 | self.rect = self.get_rect() 441 | self.placement = self.get_placement() 442 | self.size = size_from_rect(self.rect) 443 | self.name = win32gui.GetWindowText(self.id) 444 | self.resizable = self.is_resizable() 445 | 446 | def set_pos(self, rect: Rect, placement: Optional[Placement] = None): 447 | """ 448 | Set the position, size and placement of the window 449 | """ 450 | try: 451 | # adjust the offset for the monitor that the window is going to end up on, since it might change 452 | # if that monitor's DPI is different 453 | target_display_dpi = GetDpiForMonitor( 454 | win32api.MonitorFromPoint(rect[:2], win32con.MONITOR_DEFAULTTONEAREST).handle # type: ignore 455 | ) 456 | offset = dpi_scale(self.get_border_and_shadow_thickness(), target_display_dpi) 457 | 458 | # check if Window will fit on the Display it's being moved to. If not, adjust the rect to fit 459 | # use center point because top left might be out of bounds due to drop shadow and offset, which may 460 | # lead to `MONITOR_DEFAULTTONEAREST` picking the wrong display 461 | target_display_rect = self.get_closest_display_rect(( 462 | rect[0] + (size_from_rect(rect)[0] // 2), 463 | rect[1] + (size_from_rect(rect)[1] // 2) 464 | )) 465 | rect = self.rebound(rect, to_rect=target_display_rect, offset=offset) 466 | 467 | resizable = self.is_resizable() 468 | # if the window is not resizeable, make sure we don't resize it by preserving the w + h 469 | # includes 95 era system dialogs and the Outlook reminder window 470 | w, h = size_from_rect(rect) if resizable else self.get_size() 471 | # remake rect with the bounds adjusted coords 472 | rect = (*rect[:2], rect[0] + w, rect[1] + h) 473 | if placement: 474 | if not resizable: 475 | # override the placement for non-resizable windows to avoid setting wrong size for unminimised state 476 | placement = (*placement[:-1], (*rect[:2], rect[0] + w, rect[1] + h)) 477 | elif placement[1] == win32con.SW_SHOWMAXIMIZED: 478 | # rebound placement rect so that when user drags window away from maximised position it doesn't 479 | # suddenly expand to some silly size 480 | np_rect = self.rebound(placement[4], to_rect=target_display_rect, offset=offset) 481 | # DPI scale it. From experimentation this worked best but I don't have any docs to back it up 482 | np_rect = Rect(dpi_scale(i, target_display_dpi) for i in np_rect) 483 | placement = (*placement[:-1], np_rect) 484 | 485 | if placement: 486 | win32gui.SetWindowPlacement(self.id, placement) 487 | 488 | self.move(rect[:2], (w, h)) 489 | except pywintypes.error as e: 490 | log.error('err moving window %s : %s' % (win32gui.GetWindowText(self.id), e)) 491 | 492 | def get_border_and_shadow_thickness(self): 493 | """ 494 | Get the size of the window's resizable border and drop shadow in pixels. 495 | 496 | Unlike `WindowType.get_border_and_shadow_thickness`, this function is based on the actual 497 | shadow size of the window subject to the DPI of the monitor the window is on. 498 | """ 499 | # DWMWA_EXTENDED_FRAME_BOUNDS = 9 says every StackOverflow answer, and it's the 9th item in this enum: 500 | # https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute 501 | efb = DwmGetWindowAttribute(self.id, 9) 502 | # usually we would have to scale the extended frame bounds for DPI, however, this application is set as 503 | # "per-monitor DPI aware", so the results from EFB should be directly comparable to GWR. 504 | # Testing on my machine with various scaling settings seems to back this up. 505 | efb_rect = (efb.left, efb.top, efb.right, efb.bottom) 506 | return abs(max(efb_rect[i] - win32gui.GetWindowRect(self.id)[i] for i in range(4))) 507 | 508 | 509 | @dataclass(slots=True) 510 | class WindowHistory(JSONType): 511 | time: float 512 | windows: list[Window] = field(default_factory=list) 513 | 514 | 515 | @dataclass(slots=True) 516 | class Display(JSONType): 517 | uid: str 518 | name: str 519 | resolution: XandY 520 | rect: Rect 521 | comparison_params: dict[str, str | list[str]] = field(default_factory=dict) 522 | """ 523 | Optional member detailing how comparisons should be made between members of 524 | this class and members of another class 525 | """ 526 | 527 | def matches(self, display: 'Display'): 528 | # check UIDs 529 | if display.uid and not match(self.uid, display.uid): 530 | return False 531 | # check names 532 | if display.name and not match(self.name, display.name): 533 | return False 534 | # check resolution 535 | for index, metric in enumerate(zip(display.resolution, self.resolution)): 536 | if 0 in metric: 537 | continue 538 | op = self.comparison_params.get('resolution', ('eq', 'eq'))[index] 539 | if not str_to_op(op)(*metric): 540 | return False 541 | else: 542 | return True 543 | 544 | def matches_config(self, config: list['Display']): 545 | return any(self.matches(d) for d in config) 546 | 547 | def set_res(self, index, value): 548 | res = list(self.resolution) 549 | res[index] = value 550 | self.resolution = tuple(res) # type: ignore 551 | 552 | 553 | @dataclass(slots=True) 554 | class Rule(WindowType): 555 | name: str | None = None 556 | executable: str | None = None 557 | rule_name: str | None = None 558 | 559 | def __post_init__(self): 560 | if self.rule_name is None: 561 | # TODO: create name from window name and/or exe 562 | self.rule_name = 'Unnamed rule' 563 | 564 | 565 | @dataclass(slots=True) 566 | class Snapshot(JSONType): 567 | displays: list[Display] = field(default_factory=list) 568 | history: list[WindowHistory] = field(default_factory=list) 569 | mru: float | None = None 570 | rules: list[Rule] = field(default_factory=list) 571 | phony: str = '' 572 | comparison_params: dict[str, str | list[str]] = field(default_factory=dict) 573 | """ 574 | Optional member detailing how comparisons should be made between members of 575 | this class and members of another class 576 | """ 577 | 578 | def cleanup(self, prune=True, ttl=0, maximum=10): 579 | """ 580 | Perform a variety of operations to clean up the window history 581 | 582 | Args: 583 | prune: remove windows that no longer exist 584 | ttl: remove captures older than this. Set to 0 to ignore 585 | maximum: max number of captures to keep 586 | """ 587 | self.squash_history(prune) 588 | if ttl != 0: 589 | current = time.time() 590 | self.history = [i for i in self.history if current - i.time <= ttl] 591 | if len(self.history) > maximum: 592 | self.history = self.history[-maximum:] 593 | 594 | @classmethod 595 | def from_json(cls, data: dict) -> Optional['Snapshot']: 596 | """ 597 | Returns: 598 | A new snapshot, or None if `data` is falsey 599 | """ 600 | if not data: 601 | return None 602 | 603 | if 'history' not in data: 604 | if 'windows' in data: 605 | data['history'] = [{'time': time.time(), 'windows': data.pop('windows')}] 606 | if 'phony' in data: 607 | if data['phony'] is False: 608 | data['phony'] = '' 609 | elif data['phony'] is True: 610 | if data['displays'] == []: 611 | data['phony'] = 'Global' 612 | else: 613 | data['phony'] = 'Unnamed Layout' 614 | 615 | return super(cls, cls).from_json(data) 616 | 617 | def last_known_process_instance(self, window: Window, match_title=False, match_resizability=True) -> Window | None: 618 | def compare_titles(base: str, other: str): 619 | base_chunks = base.split() 620 | if base == other: 621 | # return number of chunks plus one to ensure exact matches get highest score 622 | return len(base_chunks) + 1 623 | score = 0 624 | for a, b in zip(reversed(base_chunks), reversed(other.split())): 625 | if a != b: 626 | return score 627 | score += 1 628 | return score 629 | 630 | contenders: list[Window] = [] 631 | for history in reversed(self.history): 632 | for archived in reversed(history.windows): 633 | if archived.executable == window.executable: 634 | contenders.append(archived) 635 | 636 | if match_resizability: 637 | contenders = [c for c in contenders if c.resizable == window.resizable] 638 | 639 | if match_title: 640 | contenders.sort(key=lambda x: compare_titles(window.name, x.name), reverse=True) 641 | 642 | if contenders: 643 | return contenders[0] 644 | 645 | # use union because `|` doesn't like string forward refs 646 | def matches_display_config(self, config: Union[list[Display], 'Snapshot']) -> bool: 647 | """ 648 | Whether this snapshot is deemed compatible with a another snapshot/list 649 | of displays. 650 | Can operate in 2 modes depending on how `self.comparison_params` are set. 651 | If set to 'all' mode every display in this snapshot must find a match 652 | within `config`. Otherwise, only one match needs to be found. 653 | """ 654 | if isinstance(config, Snapshot): 655 | config = config.displays 656 | matches, misses = 0, 0 657 | for display in self.displays: 658 | if display.matches_config(config): 659 | matches += 1 660 | else: 661 | misses += 1 662 | 663 | mode = self.comparison_params.get('displays') 664 | if mode == 'all': 665 | return misses == 0 666 | return matches >= 1 667 | 668 | def squash_history(self, prune=True): 669 | """ 670 | Squashes the window history by merging overlapping captures and 671 | removing duplicates. 672 | 673 | Args: 674 | prune: remove windows that no longer exist 675 | """ 676 | 677 | def should_keep(window: Window) -> bool: 678 | try: 679 | if prune: 680 | return ( 681 | # window exists and hwnd still belongs to same process 682 | win32gui.IsWindow(window.id) == 1 683 | and window.id in exe_by_id 684 | and window.executable == exe_by_id[window.id] 685 | ) 686 | return ( 687 | # hwnd is not in use by another window 688 | window.id not in exe_by_id or window.executable == exe_by_id[window.id] 689 | ) 690 | except Exception: 691 | return False 692 | 693 | index = len(self.history) - 1 694 | exe_by_id = {} 695 | while index > 0: 696 | for window in self.history[index].windows: 697 | if window.id not in exe_by_id: 698 | try: 699 | exe_by_id[window.id] = window.executable 700 | except KeyError: 701 | pass 702 | 703 | current = self.history[index].windows = list(filter(should_keep, self.history[index].windows)) 704 | previous = self.history[index - 1].windows = list(filter(should_keep, self.history[index - 1].windows)) 705 | 706 | if len(current) > len(previous): 707 | # if current is greater but contains all the items of previous 708 | smaller, greater = previous, current 709 | to_pop = index - 1 710 | else: 711 | # if current is lesser but all items are already in previous 712 | smaller, greater = current, previous 713 | to_pop = index 714 | 715 | for window_a in smaller: 716 | if window_a in greater: 717 | continue 718 | 719 | for window_b in greater: 720 | if ( 721 | window_a.id == window_b.id 722 | and window_a.rect == window_b.rect 723 | and window_a.placement == window_b.placement 724 | ): 725 | break 726 | else: 727 | break 728 | else: 729 | # successful loop, all items in smaller are already present in greater. 730 | # remove smaller 731 | self.history.pop(to_pop) 732 | 733 | index -= 1 734 | -------------------------------------------------------------------------------- /src/device.py: -------------------------------------------------------------------------------- 1 | import time 2 | from dataclasses import dataclass 3 | from typing import Callable, Optional 4 | 5 | import win32con 6 | import win32gui 7 | import win32gui_struct 8 | 9 | from services import Service, ServiceCallback 10 | 11 | GUID_DEVINTERFACE_DISPLAY_DEVICE = '{E6F07B5F-EE97-4a90-B076-33F57BF4EAA7}' 12 | 13 | 14 | @dataclass(slots=True) 15 | class DeviceChangeCallback(ServiceCallback): 16 | capture: Optional[Callable] = None 17 | 18 | 19 | class DeviceChangeService(Service): 20 | def pre_callback(self, hwnd, msg, wp, lp): 21 | super().pre_callback() 22 | if msg == win32con.WM_CLOSE: 23 | self.log.info('invoke shutdown due to WM_CLOSE signal') 24 | self.shutdown() 25 | return False 26 | 27 | if msg == win32con.WM_POWERBROADCAST: 28 | if wp in (win32con.PBT_APMSTANDBY, win32con.PBT_APMSUSPEND): 29 | self.log.info('invoke capture due to PBT_APM[STANDBY|SUSPEND] signal') 30 | return self._run_callback('capture', threaded=True) 31 | 32 | if wp not in ( 33 | win32con.PBT_APMRESUMEAUTOMATIC, 34 | win32con.PBT_APMRESUMECRITICAL, 35 | win32con.PBT_APMRESUMESTANDBY, 36 | win32con.PBT_APMRESUMESUSPEND, 37 | ): 38 | self.log.info('skip WM_POWERBROADCAST event due to unknown signal') 39 | return False 40 | 41 | self.log.info('trigger PBT_APMRESUME[AUTOMATIC|CRITICAL|STANDBY|SUSPEND] signal') 42 | time.sleep(1) 43 | elif msg == win32con.WM_DISPLAYCHANGE: 44 | self.log.info('trigger WM_DISPLAYCHANGE') 45 | elif msg == win32con.WM_WINDOWPOSCHANGING: 46 | self.log.info('trigger WM_WINDOWPOSCHANGING') 47 | else: 48 | self.log.info(f'trigger {msg=:x} {wp=:x} {lp=:x}') 49 | 50 | return True 51 | 52 | def _runner(self): 53 | wc = win32gui.WNDCLASS() 54 | wc.lpszClassName = 'RestoreWindowPos' 55 | wc.style = win32con.CS_GLOBALCLASS | win32con.CS_VREDRAW | win32con.CS_HREDRAW 56 | wc.hbrBackground = win32con.COLOR_WINDOW + 1 57 | 58 | wc.lpfnWndProc = { 59 | win32con.WM_DISPLAYCHANGE: self.callback, 60 | win32con.WM_WINDOWPOSCHANGING: self.callback, 61 | win32con.WM_POWERBROADCAST: self.callback, 62 | win32con.WM_CLOSE: self.callback, 63 | } 64 | win32gui.RegisterClass(wc) 65 | hwnd = win32gui.CreateWindow( 66 | wc.lpszClassName, 67 | 'Device Change Monitoring Window - RestoreWindowPos', 68 | # no need for it to be visible. 69 | win32con.WS_CAPTION, 70 | 100, 71 | 100, 72 | 900, 73 | 900, 74 | 0, 75 | 0, 76 | 0, 77 | None, 78 | ) 79 | 80 | filter = win32gui_struct.PackDEV_BROADCAST_DEVICEINTERFACE(GUID_DEVINTERFACE_DISPLAY_DEVICE) 81 | win32gui.RegisterDeviceNotification(hwnd, filter, win32con.DEVICE_NOTIFY_WINDOW_HANDLE) 82 | 83 | while not self._kill_signal.wait(timeout=0.01): 84 | win32gui.PumpWaitingMessages() 85 | 86 | win32gui.DestroyWindow(hwnd) 87 | win32gui.UnregisterClass(wc.lpszClassName, None) 88 | -------------------------------------------------------------------------------- /src/gui/__init__.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from _version import __build__, __version__ 4 | from common import local_path 5 | 6 | from .systray import TaskbarIcon, radio_menu # noqa:F401 7 | from .wx_app import WxApp # noqa:F401 8 | 9 | 10 | def about_dialog(): 11 | about = wx.adv.AboutDialogInfo() 12 | about.SetIcon(wx.Icon(local_path('assets/icon32.ico', asset=True))) 13 | about.SetName('RestoreWindowPos') 14 | about.SetVersion(f'v{__version__}') 15 | about.SetDescription('\n'.join((f'Build: {__build__}', 'Install Dir: %s' % local_path('.')))) 16 | with open(local_path('./LICENSE', asset=True), encoding='utf8') as f: 17 | about.SetLicence(f.read()) 18 | about.SetCopyright('© 2024') 19 | about.SetWebSite('https://github.com/Crozzers/RestoreWindowPos', 'Open GitHub Page') 20 | wx.adv.AboutBox(about) 21 | -------------------------------------------------------------------------------- /src/gui/layout_manager.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from typing import Callable, Optional 3 | 4 | import wx 5 | import wx.lib.scrolledpanel 6 | 7 | from common import Display, Snapshot 8 | from gui.rule_manager import RuleSubsetManager 9 | from gui.widgets import EditableListCtrl, Frame, ListCtrl, SelectionWindow 10 | from snapshot import SnapshotFile, enum_display_devices 11 | from window import restore_snapshot 12 | 13 | 14 | class EditResolutionWindow(Frame): 15 | def __init__(self, parent, display: Display, callback: Callable, **kw): 16 | super().__init__(parent, title='Edit Resolution', **kw) 17 | self.display = display 18 | self.callback = callback 19 | 20 | display.comparison_params.setdefault('resolution', ['eq', 'eq']) 21 | self.selected_ops = display.comparison_params['resolution'].copy() 22 | 23 | sizer = wx.GridBagSizer(5, 5) 24 | 25 | sizer.Add(wx.StaticText(self, label='X Resolution'), (0, 1)) 26 | sizer.Add(wx.StaticText(self, label='Y Resolution'), (0, 2)) 27 | sizer.Add(wx.StaticText(self, label='Value (set to 0 to ignore):'), (1, 0)) 28 | 29 | max_res = 10**4 30 | self.x_res_ctrl = wx.SpinCtrlDouble(self, value=str(display.resolution[0]), min=-max_res, max=max_res) 31 | sizer.Add(self.x_res_ctrl, (1, 1)) 32 | self.y_res_ctrl = wx.SpinCtrlDouble(self, value=str(display.resolution[1]), min=-max_res, max=max_res) 33 | sizer.Add(self.y_res_ctrl, (1, 2)) 34 | 35 | self.operations = { 36 | 'gt': 'Greater than (>)', 37 | 'ge': 'Greater than or equal (>=)', 38 | 'eq': 'Equal to (==)', 39 | 'le': 'Less than or equal to (<=)', 40 | 'lt': 'Less than', 41 | } 42 | sizer.Add(wx.StaticText(self, label='Match when:'), (round(len(self.operations) / 2) + 2, 0)) 43 | 44 | count = 1 45 | for index in range(len(display.resolution)): 46 | row = 2 47 | kw = {'style': wx.RB_GROUP} 48 | for op_name, op_desc in self.operations.items(): 49 | w = wx.RadioButton(self, label=op_desc, id=count, **kw) 50 | sizer.Add(w, (row, index + 1)) 51 | w.Bind(wx.EVT_RADIOBUTTON, self.select_op) 52 | if display.comparison_params['resolution'][index] == op_name: 53 | w.SetValue(True) 54 | 55 | count += 1 56 | row += 1 57 | kw = {} 58 | 59 | save = wx.Button(self, label='Save') 60 | sizer.Add(save, (row, 0)) 61 | save.Bind(wx.EVT_BUTTON, self.save) 62 | 63 | self.SetSizerAndFit(sizer) 64 | 65 | def save(self, *_): 66 | self.display.resolution = (int(self.x_res_ctrl.Value), int(self.y_res_ctrl.Value)) 67 | self.display.comparison_params['resolution'] = self.selected_ops 68 | 69 | self.callback() 70 | self.Close() 71 | 72 | def select_op(self, evt: wx.Event): 73 | plane = evt.Id // len(self.operations) 74 | index = evt.Id - 1 75 | if plane: 76 | index -= len(self.operations) 77 | self.selected_ops[plane] = tuple(self.operations.keys())[index] 78 | 79 | 80 | class DisplayManager(wx.StaticBox): 81 | def __init__(self, parent: wx.Frame | wx.Panel, layout: Snapshot, **kwargs): 82 | wx.StaticBox.__init__(self, parent, **kwargs) 83 | self.layout = layout 84 | self.displays = layout.displays 85 | 86 | # create action buttons 87 | action_panel = wx.Panel(self) 88 | add_display_btn = wx.Button(action_panel, label='Add') 89 | clone_display_btn = wx.Button(action_panel, label='Clone') 90 | dup_display_btn = wx.Button(action_panel, label='Duplicate') 91 | del_display_btn = wx.Button(action_panel, label='Delete') 92 | mode_txt = wx.StaticText(action_panel, label='Match:') 93 | mode_opt = wx.Choice(action_panel, choices=('All', 'Any')) 94 | 95 | # bind events 96 | add_display_btn.Bind(wx.EVT_BUTTON, self.add_display) 97 | clone_display_btn.Bind(wx.EVT_BUTTON, self.clone_display) 98 | dup_display_btn.Bind(wx.EVT_BUTTON, self.duplicate_display) 99 | del_display_btn.Bind(wx.EVT_BUTTON, self.delete_display) 100 | mode_opt.Bind(wx.EVT_CHOICE, self.select_mode) 101 | 102 | # position widgets 103 | action_sizer = wx.BoxSizer(wx.HORIZONTAL) 104 | for widget in (add_display_btn, clone_display_btn, dup_display_btn, del_display_btn, mode_txt, mode_opt): 105 | action_sizer.Add(widget, 0, wx.ALL | wx.CENTER, 5) 106 | action_panel.SetSizer(action_sizer) 107 | 108 | # set widget states 109 | mode_opt.SetSelection(0 if layout.comparison_params.get('displays') == 'all' else 1) 110 | 111 | # create list control 112 | self.list_control = EditableListCtrl(self, edit_cols=list(range(0, 4)), on_edit=self.on_edit) 113 | self.list_control.Bind(wx.EVT_TEXT_ENTER, self.edit_display) 114 | for index, col in enumerate( 115 | ('Display UID (regex)', 'Display Name (regex)', 'X Resolution', 'Y Resolution', 'Rect') 116 | ): 117 | self.list_control.AppendColumn(col) 118 | self.list_control.SetColumnWidth(index, 250 if index < 2 else 125) 119 | 120 | # add rules 121 | for display in self.displays: 122 | self.append_display(display, False) 123 | 124 | # position list control 125 | sizer = wx.BoxSizer(wx.VERTICAL) 126 | sizer.AddSpacer(15) 127 | sizer.Add(action_panel, 0, wx.ALL | wx.EXPAND, 0) 128 | sizer.Add(self.list_control, 1, wx.ALL | wx.EXPAND, 5) 129 | self.SetSizer(sizer) 130 | 131 | def add_display(self, *_): 132 | self.append_display(Display('', '', [1920, 1080], [0, 0, 1920, 1080])) 133 | 134 | def append_display(self, display: Display, new=True): 135 | self.list_control.Append( 136 | (display.uid or '', display.name or '', *(display.resolution or (1920, 1080)), str(display.rect)) 137 | ) 138 | if not new: 139 | return 140 | self.displays.append(display) 141 | 142 | def clone_display(self, *_): 143 | displays = enum_display_devices() 144 | d_names = [i.name for i in displays] 145 | options = {'Clone UIDs': True, 'Clone Names': True} 146 | 147 | def on_select(selection, options): 148 | for index in selection: 149 | display: Display = deepcopy(displays[index]) 150 | if not options['Clone UIDs']: 151 | display.uid = None 152 | if not options['Clone Names']: 153 | display.name = None 154 | self.displays.append(display) 155 | self.refresh_list() 156 | 157 | SelectionWindow(self, d_names, on_select, options, title='Clone Displays').Show() 158 | 159 | def delete_display(self, *_): 160 | while (item := self.list_control.GetFirstSelected()) != -1: 161 | self.displays.pop(item) 162 | self.list_control.DeleteItem(item) 163 | 164 | def duplicate_display(self, *_): 165 | for item in self.list_control.GetAllSelected(): 166 | self.append_display(deepcopy(self.displays[item])) 167 | 168 | def edit_display(self, evt: wx.Event): 169 | def update(): 170 | for index, display in enumerate(self.displays): 171 | try: 172 | display.resolution = ( 173 | int(self.list_control.GetItemText(index, 2)), 174 | int(self.list_control.GetItemText(index, 3)), 175 | ) 176 | display.uid = self.list_control.GetItemText(index, 0) 177 | display.name = self.list_control.GetItemText(index, 1) 178 | except ValueError: 179 | wx.MessageDialog( 180 | self, 181 | 'Invalid value for display resolution. Please enter a valid integer', 182 | 'Error', 183 | style=wx.OK, 184 | ).ShowModal() 185 | else: 186 | wx.CallAfter(self.refresh_list) 187 | 188 | evt.Skip() 189 | update() 190 | 191 | def on_edit(self, col, row) -> bool: 192 | if col < 2: 193 | return True 194 | if col > 4: 195 | return False 196 | 197 | display: Display = self.displays[row] 198 | w_name = f'editdisplay-{id(display)}' 199 | for child in self.GetChildren(): 200 | if not isinstance(child, Frame): 201 | continue 202 | if child.GetName() == w_name: 203 | return child.Raise() 204 | EditResolutionWindow(self, display, callback=self.refresh_list, name=w_name).Show() 205 | return False 206 | 207 | def refresh_list(self): 208 | self.list_control.DeleteAllItems() 209 | for display in self.displays: 210 | self.append_display(display, new=False) 211 | 212 | def select_mode(self, evt: wx.CommandEvent): 213 | choice = 'any' if evt.GetSelection() == 1 else 'all' 214 | self.layout.comparison_params['display'] = choice 215 | 216 | 217 | class LayoutManager(wx.StaticBox): 218 | def __init__(self, parent: 'LayoutPage', snapshot_file: SnapshotFile): 219 | wx.StaticBox.__init__(self, parent, label='Layouts') 220 | self.snapshot_file = snapshot_file 221 | self.layouts = [snapshot_file.get_current_snapshot()] 222 | for layout in self.snapshot_file.data: 223 | if not layout.phony: 224 | continue 225 | if layout.phony == 'Global' and layout.displays == []: 226 | self.layouts.insert(1, layout) 227 | else: 228 | self.layouts.append(layout) 229 | 230 | # create action buttons 231 | action_panel = wx.Panel(self) 232 | add_layout_btn = wx.Button(action_panel, label='Add New') 233 | apply_layout_btn = wx.Button(action_panel, label='Apply') 234 | clone_layout_btn = wx.Button(action_panel, label='Clone Current') 235 | edit_layout_btn = wx.Button(action_panel, label='Edit') 236 | dup_layout_btn = wx.Button(action_panel, label='Duplicate') 237 | del_layout_btn = wx.Button(action_panel, label='Delete') 238 | mov_up_btn = wx.Button(action_panel, id=1, label='Move Up') 239 | mov_dn_btn = wx.Button(action_panel, id=2, label='Move Down') 240 | rename_btn = wx.Button(action_panel, id=3, label='Rename') 241 | 242 | self._disallow_current = (edit_layout_btn, del_layout_btn, mov_up_btn, mov_dn_btn, rename_btn) 243 | 244 | def btn_evt(func, swap=True): 245 | wrapped = lambda *_: [func(*_), self.update_snapshot_file()] # noqa: E731 246 | if swap: 247 | return lambda *_: [wrapped(*_), self.edit_layout()] 248 | return wrapped 249 | 250 | # bind events 251 | add_layout_btn.Bind(wx.EVT_BUTTON, btn_evt(self.add_layout)) 252 | apply_layout_btn.Bind(wx.EVT_BUTTON, btn_evt(self.apply_layout)) 253 | clone_layout_btn.Bind(wx.EVT_BUTTON, btn_evt(self.clone_layout)) 254 | edit_layout_btn.Bind(wx.EVT_BUTTON, btn_evt(self.edit_layout, False)) 255 | dup_layout_btn.Bind(wx.EVT_BUTTON, btn_evt(self.duplicate_layout)) 256 | del_layout_btn.Bind(wx.EVT_BUTTON, btn_evt(self.delete_layout)) 257 | mov_up_btn.Bind(wx.EVT_BUTTON, btn_evt(self.move_layout, False)) 258 | mov_dn_btn.Bind(wx.EVT_BUTTON, btn_evt(self.move_layout, False)) 259 | # not btn_evt since no data changes yet 260 | rename_btn.Bind(wx.EVT_BUTTON, self.rename_layout) 261 | 262 | # position buttons 263 | action_sizer = wx.BoxSizer(wx.HORIZONTAL) 264 | for btn in ( 265 | add_layout_btn, 266 | apply_layout_btn, 267 | clone_layout_btn, 268 | edit_layout_btn, 269 | dup_layout_btn, 270 | del_layout_btn, 271 | mov_up_btn, 272 | mov_dn_btn, 273 | rename_btn, 274 | ): 275 | action_sizer.Add(btn, 0, wx.ALL, 5) 276 | action_panel.SetSizer(action_sizer) 277 | 278 | # create list control 279 | self.list_control = ListCtrl(self, style=wx.LC_REPORT | wx.LC_EDIT_LABELS) 280 | self.list_control.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.edit_layout) 281 | self.list_control.Bind(wx.EVT_LIST_END_LABEL_EDIT, self.rename_layout) 282 | self.list_control.Bind(wx.EVT_LIST_ITEM_SELECTED, self.on_select) 283 | self.list_control.Bind(wx.EVT_LIST_ITEM_DESELECTED, self.on_select) 284 | for index, col in enumerate(('Layout Name',)): 285 | self.list_control.AppendColumn(col) 286 | self.list_control.SetColumnWidth(index, 600 if index < 3 else 150) 287 | 288 | # add layouts 289 | for layout in self.layouts: 290 | self.append_layout(layout, False) 291 | 292 | # position list control 293 | sizer = wx.BoxSizer(wx.VERTICAL) 294 | sizer.AddSpacer(15) 295 | sizer.Add(action_panel, 0, wx.ALL | wx.EXPAND, 0) 296 | sizer.Add(self.list_control, 1, wx.ALL | wx.EXPAND, 5) 297 | self.SetSizer(sizer) 298 | 299 | def add_layout(self, *_): 300 | layout = Snapshot(displays=enum_display_devices(), phony='Unnamed Layout') 301 | self.append_layout(layout) 302 | 303 | def append_layout(self, layout: Snapshot, new=True): 304 | if layout == self.layouts[0]: 305 | name = 'Current Snapshot' 306 | else: 307 | name = layout.phony 308 | self.list_control.Append((name,)) 309 | if not new: 310 | return 311 | self.layouts.append(layout) 312 | self.update_snapshot_file() 313 | 314 | def apply_layout(self, *_): 315 | for item in self.list_control.GetAllSelected(): 316 | layout: Snapshot = self.layouts[item] 317 | restore_snapshot([], layout.rules) 318 | 319 | def clone_layout(self, *_): 320 | layout = deepcopy(self.snapshot_file.get_current_snapshot()) 321 | layout.history = [] 322 | layout.phony = 'Unnamed Layout' 323 | layout.mru = None 324 | self.append_layout(layout) 325 | 326 | def delete_layout(self, *_): 327 | while (item := self.list_control.GetFirstSelected()) != -1: 328 | self.layouts.pop(item) 329 | self.list_control.DeleteItem(item) 330 | 331 | def duplicate_layout(self, *_): 332 | for item in self.list_control.GetAllSelected(): 333 | self.append_layout(deepcopy(self.layouts[item])) 334 | 335 | def edit_layout(self, *_): 336 | item = self.list_control.GetFirstSelected() 337 | try: 338 | layout = self.layouts[item] 339 | except IndexError: 340 | layout = None 341 | self.Parent.swap_layout(layout) 342 | 343 | def insert_layout(self, index: int, layout: Snapshot): 344 | self.list_control.Insert(index, (layout.phony,)) 345 | 346 | def move_layout(self, btn_evt: wx.Event): 347 | direction = -1 if btn_evt.Id == 1 else 1 348 | selected = list(self.list_control.GetAllSelected()) 349 | items: list[tuple[int, Snapshot]] = [] 350 | 351 | # get all items and their new positions 352 | for index in reversed(selected): 353 | self.list_control.DeleteItem(index) 354 | items.insert(0, (max(2, index + direction), self.layouts.pop(index))) 355 | 356 | # re-insert into list 357 | for new_index, rule in items: 358 | self.layouts.insert(new_index, rule) 359 | self.insert_layout(new_index, rule) 360 | self.list_control.Select(new_index) 361 | 362 | def on_select(self, evt: wx.Event): 363 | selected = tuple(self.list_control.GetAllSelected()) 364 | if 0 in selected or 1 in selected: 365 | func = wx.Button.Disable 366 | else: 367 | func = wx.Button.Enable 368 | for widget in self._disallow_current: 369 | func(widget) 370 | 371 | def rename_layout(self, evt: wx.Event): 372 | def update(): 373 | self.list_control.SetItemText(0, 'Current Snapshot') 374 | self.list_control.SetItemText(1, 'Global') 375 | for index, layout in enumerate(self.layouts[2:], start=2): 376 | text = self.list_control.GetItemText(index) 377 | if text == 'Global': 378 | self.list_control.SetItemText(index, layout.phony or 'Unnamed Layout') 379 | wx.MessageBox( 380 | 'Name "Global" is not allowed for user created layouts', 381 | 'Invalid Value', 382 | wx.OK | wx.ICON_WARNING, 383 | ) 384 | else: 385 | layout.phony = text 386 | if not layout.phony: 387 | layout.phony = 'Unnamed Layout' 388 | self.list_control.SetItemText(index, 'Unnamed Layout') 389 | self.update_snapshot_file() 390 | self.edit_layout() 391 | 392 | if evt.Id == 3: 393 | self.list_control.EditLabel(self.list_control.GetFirstSelected()) 394 | else: 395 | # use CallAfter to allow ListCtrl to update the value before we read it 396 | wx.CallAfter(update) 397 | 398 | def update_snapshot_file(self): 399 | with self.snapshot_file.lock: 400 | to_remove = [] 401 | 402 | for layout in self.snapshot_file.data: 403 | if not layout.phony: 404 | continue 405 | to_remove.append(layout) 406 | 407 | for layout in to_remove: 408 | while layout in self.snapshot_file.data: 409 | self.snapshot_file.data.remove(layout) 410 | 411 | self.snapshot_file.data.extend(self.layouts[1:]) 412 | 413 | self.snapshot_file.save() 414 | 415 | 416 | class LayoutPage(wx.Panel): 417 | def __init__(self, parent: wx.Frame, snapshot_file: SnapshotFile): 418 | wx.Panel.__init__(self, parent, id=wx.ID_ANY) 419 | self.snapshot = snapshot_file 420 | 421 | self.layout_manager = LayoutManager(self, snapshot_file) 422 | # set in swap_layout 423 | self.display_manager: DisplayManager 424 | self.rule_manager: RuleSubsetManager 425 | 426 | self.sizer = wx.BoxSizer(wx.VERTICAL) 427 | self.sizer.Add(self.layout_manager, 0, wx.ALL | wx.EXPAND, 0) 428 | self.SetSizer(self.sizer) 429 | 430 | self.swap_layout() 431 | 432 | def swap_layout(self, layout: Optional[Snapshot] = None): 433 | if self.sizer.GetItemCount() > 1: 434 | self.sizer.Remove(2) 435 | self.sizer.Remove(1) 436 | if self.display_manager: 437 | self.display_manager.Destroy() 438 | if self.rule_manager: 439 | self.rule_manager.Destroy() 440 | 441 | if layout is None: 442 | try: 443 | with self.snapshot.lock: 444 | layout = next(i for i in reversed(self.snapshot.data) if i.phony) 445 | except StopIteration: 446 | return 447 | 448 | current = self.snapshot.get_current_snapshot() 449 | if layout == current: 450 | name = 'Current Snapshot' 451 | else: 452 | name = layout.phony 453 | 454 | self.display_manager = DisplayManager(self, layout, label=f'Displays for {name}') 455 | 456 | if layout == current or (layout.phony == 'Global' and layout.displays == []): 457 | self.display_manager.Disable() 458 | 459 | self.rule_manager = RuleSubsetManager(self, self.snapshot, layout.rules, f'Rules for {name}') 460 | self.sizer.Add(self.display_manager, 0, wx.ALL | wx.EXPAND, 0) 461 | self.sizer.Add(self.rule_manager, 0, wx.ALL | wx.EXPAND, 0) 462 | self.sizer.Layout() 463 | -------------------------------------------------------------------------------- /src/gui/on_spawn_manager.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List, Literal, Optional, TypedDict 2 | 3 | import wx 4 | import wx.lib.scrolledpanel 5 | 6 | from common import load_json 7 | from gui.widgets import EVT_REARRANGE_LIST_SELECT, EditableListCtrl, RearrangeListCtrl, simple_box_sizer 8 | 9 | OnSpawnOperations = Literal['apply_lkp', 'apply_rules', 'move_to_mouse'] 10 | 11 | 12 | class WindowDictLite(TypedDict): 13 | ''' 14 | Super light typed dict that can be expanded to use the full `Window` class without much hassle 15 | ''' 16 | name: str 17 | executable: str 18 | 19 | 20 | class OnSpawnSettings(TypedDict): 21 | name: str 22 | enabled: bool 23 | move_to_mouse: bool 24 | apply_lkp: bool 25 | apply_rules: bool 26 | operation_order: list[OnSpawnOperations] 27 | ignore_children: bool 28 | capture_snapshot: bool | int # 0/False: disable, 1/True: capture, 2: update 29 | skip_non_resizable: bool 30 | match_resizability: bool 31 | fuzzy_mtm: bool 32 | apply_to: Optional[WindowDictLite] 33 | 34 | 35 | class OverallSpawnSettings(OnSpawnSettings): 36 | profiles: List[OnSpawnSettings] 37 | 38 | 39 | def default_spawn_settings(): 40 | return OnSpawnSettings( 41 | name='Profile', 42 | enabled=False, 43 | move_to_mouse=False, 44 | apply_lkp=True, 45 | apply_rules=True, 46 | operation_order=['apply_lkp', 'apply_rules', 'move_to_mouse'], 47 | ignore_children=True, 48 | capture_snapshot=2, 49 | skip_non_resizable=True, 50 | match_resizability=True, 51 | fuzzy_mtm=True, 52 | apply_to=None 53 | ) 54 | 55 | 56 | class OnSpawnPanel(wx.Panel): 57 | def __init__(self, parent: wx.Window, profile: OnSpawnSettings, on_save: Callable[[OnSpawnSettings], None]): 58 | wx.Panel.__init__(self, parent, id=wx.ID_ANY) 59 | self.on_save = on_save 60 | for key, value in default_spawn_settings().items(): 61 | profile.setdefault(key, value) # type: ignore 62 | 63 | self.profile = profile 64 | 65 | def header(text: str): 66 | txt = wx.StaticText(self.panel, label=text) 67 | txt.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)) 68 | return txt 69 | 70 | sizer = wx.StaticBoxSizer(wx.VERTICAL, self, label=f'{profile["name"]!r} profile') 71 | 72 | # create widgets 73 | enable_opt = wx.CheckBox(sizer.GetStaticBox(), id=1, label='React to new windows being created') 74 | enable_opt.SetToolTip('It\'s recommended to disable "Prune window history" when this is enabled') 75 | self.panel = wx.Panel(sizer.GetStaticBox()) 76 | header1 = header('When a new window spawns:') 77 | 78 | # reused later on in Misc settings. 79 | mtm_opt_name = 'Center on current mouse position' 80 | 81 | option_mapping: dict[str, OnSpawnOperations] = { 82 | 'Apply last known size and/or position': 'apply_lkp', 83 | 'Apply compatible rules': 'apply_rules', 84 | mtm_opt_name: 'move_to_mouse', 85 | } 86 | self.rc_opt = RearrangeListCtrl( 87 | self.panel, 88 | options={k: self.profile[k] for k in ('apply_lkp', 'apply_rules', 'move_to_mouse')}, 89 | order=self.profile['operation_order'], 90 | label_mapping=option_mapping, 91 | ) 92 | 93 | header2 = header('After a new window spawns:') 94 | 95 | update_snapshot_opt = wx.RadioButton(self.panel, id=6, label='Update the current snapshot', style=wx.RB_GROUP) 96 | capture_snapshot_opt = wx.RadioButton(self.panel, id=7, label='Capture a new snapshot') 97 | do_nothing_opt = wx.RadioButton(self.panel, id=8, label='Do nothing') 98 | 99 | header3 = header('Window filtering controls:') 100 | 101 | ignore_children_opt = wx.CheckBox(self.panel, id=5, label='Ignore child windows') 102 | ignore_children_opt.SetToolTip( 103 | 'Child windows are typically small popup windows that spawn near the cursor.' 104 | # '\nDisabling this means such windows will be moved to the top left corner of the parent window.' 105 | # ^ this may not be accurate. TODO: remove or implement this 106 | ) 107 | skip_non_resizable_opt = wx.CheckBox(self.panel, id=9, label='Ignore non-resizable windows') 108 | skip_non_resizable_opt.SetToolTip( 109 | 'Non resizable windows often include splash screens, alerts and notifications. Enable this to' 110 | ' prevent those windows from being moved, resized or added to the snapshot when they spawn.' 111 | ) 112 | 113 | match_resizability_opt = wx.CheckBox( 114 | self.panel, id=10, label='Filter last known window instances by resizability' 115 | ) 116 | match_resizability_opt.SetToolTip( 117 | 'When looking for the last known size/position of a window, filter out instances where' 118 | ' the current window is resizable but the last known instance was not, or vice versa.' 119 | '\nThis prevents splash screens from dictating the final window size.' 120 | ) 121 | 122 | header4 = header('Misc:') 123 | 124 | fuzzy_mtm_opt = wx.CheckBox( 125 | self.panel, id=11, label=f'Disable "{mtm_opt_name}" when mouse is already within the window' 126 | ) 127 | 128 | if self.profile['name'] == 'Global': 129 | apply_to_widgets = () 130 | else: 131 | header5 = header('Apply to:') 132 | explainer_box = wx.StaticText( 133 | self.panel, 134 | label=( 135 | 'These boxes control which windows this profile will be applied against.' 136 | ' If you leave one box empty, it will be ignored. If both are empty, the profile is ignored.' 137 | ' Profiles are matched against windows based on the closest and most specific match.' 138 | ' If no specific match is found, the "Global" profile is used.' 139 | ) 140 | ) 141 | explainer_box.Wrap(700) 142 | window_name_label = wx.StaticText(self.panel, label='Window name (regex) (leave empty to match all windows):') 143 | window_name = wx.TextCtrl(self.panel, id=12) 144 | window_exe_label = wx.StaticText(self.panel, label='Window executable (regex) (leave empty to match all windows):') 145 | window_exe = wx.TextCtrl(self.panel, id=13) 146 | apply_to = self.profile.get('apply_to', {}) 147 | if apply_to: 148 | window_name.SetValue(apply_to.get('name', '')) 149 | window_exe.SetValue(apply_to.get('executable', '')) 150 | window_name.Bind(wx.EVT_KEY_UP, self.on_setting) 151 | window_exe.Bind(wx.EVT_KEY_UP, self.on_setting) 152 | 153 | apply_to_widgets = (header5, explainer_box, window_name_label, window_name, window_exe_label, window_exe) 154 | 155 | # set state 156 | enable_opt.SetValue(self.profile['enabled']) 157 | if not self.profile['enabled']: 158 | self.panel.Disable() 159 | ignore_children_opt.SetValue(self.profile['ignore_children']) 160 | 161 | update_snapshot_opt.SetValue(False) 162 | capture_snapshot_opt.SetValue(False) 163 | do_nothing_opt.SetValue(False) 164 | # set the relevant radio button based on user settings 165 | [do_nothing_opt, capture_snapshot_opt, update_snapshot_opt][int(self.profile['capture_snapshot'])].SetValue( 166 | True 167 | ) 168 | fuzzy_mtm_opt.SetValue(self.profile['fuzzy_mtm']) 169 | 170 | skip_non_resizable_opt.SetValue(self.profile['skip_non_resizable']) 171 | match_resizability_opt.SetValue(self.profile['match_resizability']) 172 | 173 | # bind events 174 | for widget in ( 175 | enable_opt, 176 | ignore_children_opt, 177 | update_snapshot_opt, 178 | capture_snapshot_opt, 179 | do_nothing_opt, 180 | skip_non_resizable_opt, 181 | match_resizability_opt, 182 | fuzzy_mtm_opt, 183 | ): 184 | widget.Bind(wx.EVT_CHECKBOX if isinstance(widget, wx.CheckBox) else wx.EVT_RADIOBUTTON, self.on_setting) 185 | self.rc_opt.Bind(EVT_REARRANGE_LIST_SELECT, self.on_setting) 186 | 187 | # place 188 | simple_box_sizer( 189 | self.panel, 190 | ( 191 | *apply_to_widgets, 192 | header1, 193 | self.rc_opt, 194 | header2, 195 | update_snapshot_opt, 196 | capture_snapshot_opt, 197 | do_nothing_opt, 198 | header3, 199 | ignore_children_opt, 200 | skip_non_resizable_opt, 201 | match_resizability_opt, 202 | header4, 203 | fuzzy_mtm_opt 204 | ), 205 | group_mode=wx.HORIZONTAL, 206 | ) 207 | 208 | for widget in (enable_opt, self.panel): 209 | # panel does its own padding 210 | sizer.Add(widget, 0, wx.ALL | wx.EXPAND, 5 if widget != self.panel else 0) 211 | self.SetSizerAndFit(sizer) 212 | 213 | def on_setting(self, event: wx.Event): 214 | widget = event.GetEventObject() 215 | if isinstance(widget, wx.CheckBox): 216 | key = { 217 | 1: 'enabled', 218 | 2: 'move_to_mouse', 219 | 3: 'apply_lkp', 220 | 4: 'apply_rules', 221 | 5: 'ignore_children', 222 | 9: 'skip_non_resizable', 223 | 10: 'match_resizability', 224 | 11: 'fuzzy_mtm', 225 | }[event.Id] 226 | self.profile[key] = widget.GetValue() 227 | if event.Id == 1: # enable/disable feature 228 | if widget.GetValue(): 229 | self.panel.Enable() 230 | else: 231 | self.panel.Disable() 232 | elif isinstance(widget, wx.RadioButton): 233 | ids = [8, 7, 6] # do nothing, capture, update 234 | self.profile['capture_snapshot'] = ids.index(widget.Id) 235 | elif isinstance(widget, RearrangeListCtrl): 236 | operations = self.rc_opt.get_selection() 237 | self.profile['operation_order'] = list(operations.keys()) # type: ignore 238 | self.profile.update(operations) # type: ignore 239 | elif isinstance(widget, wx.TextCtrl) and isinstance(event, wx.KeyEvent): 240 | # allow text ctrl to sort itself out and insert values 241 | event.Skip() 242 | name = self.panel.FindWindowById(12).GetValue() 243 | exe = self.panel.FindWindowById(13).GetValue() 244 | apply_to: Optional[WindowDictLite] 245 | if name or exe: 246 | apply_to = { 247 | 'name': self.panel.FindWindowById(12).GetValue(), 248 | 'executable': self.panel.FindWindowById(13).GetValue() 249 | } 250 | else: 251 | apply_to = None 252 | self.profile['apply_to'] = apply_to 253 | 254 | self.on_save(self.profile) 255 | 256 | 257 | class OnSpawnPage(wx.lib.scrolledpanel.ScrolledPanel): 258 | def __init__(self, parent: wx.Frame): 259 | wx.lib.scrolledpanel.ScrolledPanel.__init__(self, parent, id=wx.ID_ANY) 260 | self.settings_file = load_json('settings') 261 | 262 | self.settings: OverallSpawnSettings = {**default_spawn_settings(), 'name': 'Global', 'profiles': []} 263 | if (ows := self.settings_file.get('on_window_spawn')) is not None: 264 | self.settings.update(ows) 265 | 266 | profile_box_sizer = wx.StaticBoxSizer(wx.VERTICAL, self, label='Profiles') 267 | action_panel = wx.Panel(profile_box_sizer.GetStaticBox()) 268 | add_profile_btn = wx.Button(action_panel, label='Add profile') 269 | del_profile_btn = wx.Button(action_panel, label='Delete profile') 270 | 271 | self.profiles_list = EditableListCtrl( 272 | profile_box_sizer.GetStaticBox(), 273 | post_edit=self.rename_profile, 274 | style=wx.LC_REPORT | wx.LC_EDIT_LABELS | wx.LC_SINGLE_SEL 275 | ) 276 | self.profiles_list.AppendColumn('Profiles') 277 | self.profiles_list.SetColumnWidth(0, 300) 278 | self.populate_profiles() 279 | self.profiles_list.Unbind(wx.EVT_LEFT_DOWN) 280 | self.profiles_list.Bind(wx.EVT_LEFT_DOWN, self.select_profile) 281 | 282 | add_profile_btn.Bind(wx.EVT_BUTTON, self.add_profile) 283 | del_profile_btn.Bind(wx.EVT_BUTTON, self.del_profile) 284 | 285 | action_sizer = wx.BoxSizer(wx.HORIZONTAL) 286 | action_sizer.Add(add_profile_btn) 287 | action_sizer.Add(del_profile_btn) 288 | action_panel.SetSizerAndFit(action_sizer) 289 | 290 | 291 | profile_box_sizer.Add(action_panel, 0, wx.ALL, 5) 292 | profile_box_sizer.Add(self.profiles_list, 0, wx.ALL, 5) 293 | 294 | self.profile_panel = OnSpawnPanel(self, self.settings, self.on_save) 295 | 296 | self.sizer = wx.BoxSizer(wx.VERTICAL) 297 | self.sizer.Add(profile_box_sizer, 0, wx.ALL | wx.EXPAND, 5) 298 | self.sizer.Add(self.profile_panel, 0, wx.ALL | wx.EXPAND, 5) 299 | self.SetSizerAndFit(self.sizer) 300 | 301 | self.SetupScrolling() 302 | 303 | def add_profile(self, event: wx.Event): 304 | new = default_spawn_settings() 305 | self.profiles_list.Append((new['name'],)) 306 | self.settings['profiles'].append(new) 307 | self.on_save() 308 | event.Skip() 309 | 310 | def del_profile(self, event: wx.Event): 311 | # get index of item just before selection 312 | first = max(min(self.profiles_list.GetAllSelected()) - 1, 0) 313 | for index in reversed(sorted(self.profiles_list.GetAllSelected())): 314 | if index == 0: 315 | continue 316 | self.profiles_list.DeleteItem(index) 317 | self.settings['profiles'].pop(index - 1) 318 | self.on_save() 319 | self.profiles_list.Select(first) 320 | self.set_state(first) 321 | 322 | def rename_profile(self, col, row): 323 | for index, profile in enumerate(self.get_all_profiles()): 324 | if index == 0: 325 | self.profiles_list.SetItemText(index, 'Global') 326 | else: 327 | text = self.profiles_list.GetItemText(index) 328 | if text == 'Global': 329 | self.profiles_list.SetItemText(index, profile['name']) 330 | else: 331 | profile['name'] = self.profiles_list.GetItemText(index) 332 | 333 | self.on_save() 334 | 335 | def populate_profiles(self): 336 | for index in range(self.profiles_list.GetItemCount()): 337 | self.profiles_list.DeleteItem(index) 338 | for profile in self.get_all_profiles(): 339 | self.profiles_list.Append((profile['name'],)) 340 | 341 | def get_all_profiles(self): 342 | return [self.settings] + self.settings['profiles'] 343 | 344 | def select_profile(self, event: wx.MouseEvent): 345 | x,y = event.GetPosition() 346 | row, _ = self.profiles_list.HitTest((x, y)) 347 | if row < 0: 348 | event.Skip() 349 | return 350 | self.profiles_list.Select(row) 351 | self.set_state(row) 352 | 353 | def set_state(self, selected: Optional[int] = None): 354 | self.sizer.Remove(1) 355 | self.profile_panel.Destroy() 356 | selected = self.profiles_list.GetFirstSelected() if selected is None else selected 357 | if selected == 0: 358 | profile = self.settings 359 | else: 360 | profile = self.settings['profiles'][selected - 1] 361 | self.profile_panel = OnSpawnPanel(self, profile, self.on_save) 362 | self.sizer.Add(self.profile_panel, 0, wx.ALL | wx.EXPAND, 5) 363 | self.sizer.Layout() 364 | self.profile_panel.Update() 365 | self.SetupScrolling() 366 | 367 | def on_save(self, event = None): 368 | self.settings_file.set('on_window_spawn', self.settings) 369 | self.settings_file.save() 370 | -------------------------------------------------------------------------------- /src/gui/rule_manager.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | from copy import deepcopy 3 | from typing import TYPE_CHECKING, Callable 4 | 5 | import win32gui 6 | import wx 7 | import wx.adv 8 | import wx.lib.scrolledpanel 9 | 10 | from common import Rule, Snapshot, Window, size_from_rect 11 | from gui.widgets import EditableListCtrl, Frame, SelectionWindow 12 | from snapshot import SnapshotFile 13 | from window import capture_snapshot, restore_snapshot 14 | 15 | if TYPE_CHECKING: 16 | from gui.layout_manager import LayoutManager 17 | 18 | 19 | class RuleWindow(Frame): 20 | def __init__(self, parent, rule: Rule, on_save: Callable, **kw): 21 | self.rule = rule 22 | self.on_save = on_save 23 | 24 | # create widgets and such 25 | super().__init__(parent, title=self.rule.rule_name, **kw) 26 | self.panel = wx.lib.scrolledpanel.ScrolledPanel(self) 27 | self.rule_name_label = wx.StaticText(self.panel, label='Rule name') 28 | self.rule_name = wx.TextCtrl(self.panel) 29 | self.window_name_label = wx.StaticText(self.panel, label='Window name (regex) (leave empty to ignore)') 30 | self.window_name = wx.TextCtrl(self.panel) 31 | self.window_exe_label = wx.StaticText(self.panel, label='Executable path (regex) (leave empty to ignore)') 32 | self.window_exe = wx.TextCtrl(self.panel) 33 | self.reset_btn = wx.Button(self.panel, label='Reset rule') 34 | self.save_btn = wx.Button(self.panel, label='Save') 35 | self.explanation_box = wx.StaticText( 36 | self.panel, 37 | label=( 38 | 'Resize and reposition this window and then click save.' 39 | ' Any window that is not currently part of a snapshot will be moved' 40 | ' to the same size and position as this window' 41 | ), 42 | ) 43 | 44 | # bind events 45 | self.reset_btn.Bind(wx.EVT_BUTTON, self.set_pos) 46 | self.save_btn.Bind(wx.EVT_BUTTON, self.save) 47 | 48 | pos = [-1, 1] 49 | 50 | # place everything 51 | def next_pos(): 52 | nonlocal pos 53 | if pos[1] == 1: 54 | pos = [pos[0] + 1, 0] 55 | else: 56 | pos[1] += 1 57 | return tuple(pos) 58 | 59 | self.sizer = wx.GridBagSizer(5, 5) 60 | self.sizer.Add(self.rule_name_label, pos=next_pos(), flag=wx.EXPAND) 61 | self.sizer.Add(self.rule_name, pos=next_pos(), flag=wx.EXPAND) 62 | self.sizer.Add(self.window_name_label, pos=next_pos(), flag=wx.EXPAND) 63 | self.sizer.Add(self.window_name, pos=next_pos(), flag=wx.EXPAND) 64 | self.sizer.Add(self.window_exe_label, next_pos(), flag=wx.EXPAND) 65 | self.sizer.Add(self.window_exe, next_pos(), flag=wx.EXPAND) 66 | self.sizer.Add(self.reset_btn, next_pos(), flag=wx.EXPAND) 67 | self.sizer.Add(self.save_btn, next_pos(), flag=wx.EXPAND) 68 | self.sizer.Add(self.explanation_box, next_pos(), span=(1, 2), flag=wx.EXPAND) 69 | self.panel.SetSizerAndFit(self.sizer) 70 | self.sizer.Fit(self.panel) 71 | 72 | self.populate_form() 73 | 74 | # final steps 75 | self.panel.SetupScrolling() 76 | self.set_pos() 77 | 78 | def get_placement(self): 79 | return win32gui.GetWindowPlacement(self.GetHandle()) 80 | 81 | def set_placement(self): 82 | win32gui.SetWindowPlacement(self.GetHandle(), self.rule.placement) 83 | 84 | def get_rect(self): 85 | return win32gui.GetWindowRect(self.GetHandle()) 86 | 87 | def populate_form(self): 88 | # insert data 89 | self.rule_name.ChangeValue(self.rule.rule_name or '') 90 | self.window_name.ChangeValue(self.rule.name or '') 91 | self.window_exe.ChangeValue(self.rule.executable or '') 92 | self.SetTitle(self.rule.rule_name) 93 | self.Update() 94 | 95 | def set_rect(self): 96 | x, y = self.rule.rect[:2] 97 | w, h = size_from_rect(self.rule.rect) 98 | win32gui.MoveWindow(self.GetHandle(), x, y, w, h, False) 99 | 100 | def set_pos(self, *_): 101 | self.set_placement() 102 | self.set_rect() 103 | 104 | def save(self, *_): 105 | self.rule.rule_name = self.rule_name.Value or None 106 | self.rule.name = self.window_name.Value or None 107 | self.rule.executable = self.window_exe.Value or None 108 | self.rule.rect = self.get_rect() 109 | self.rule.size = size_from_rect(self.rule.rect) 110 | self.rule.placement = self.get_placement() 111 | self.on_save() 112 | self.Close() 113 | 114 | 115 | class RuleSubsetManager(wx.StaticBox): 116 | def __init__(self, parent: 'LayoutManager', snapshot: SnapshotFile, rules: list[Rule], label=None): 117 | wx.StaticBox.__init__(self, parent, label=label or '') 118 | self.snapshot = snapshot 119 | self.rules = rules 120 | self.parent = parent 121 | 122 | # create action buttons 123 | action_panel = wx.Panel(self) 124 | apply_btn = wx.Button(action_panel, label='Apply') 125 | add_rule_btn = wx.Button(action_panel, label='Create') 126 | clone_window_btn = wx.Button(action_panel, label='Clone Window') 127 | edit_rule_btn = wx.Button(action_panel, label='Edit') 128 | dup_rule_btn = wx.Button(action_panel, label='Duplicate') 129 | del_rule_btn = wx.Button(action_panel, label='Delete') 130 | mov_up_rule_btn = wx.Button(action_panel, id=1, label='Move Up') 131 | mov_dn_rule_btn = wx.Button(action_panel, id=2, label='Move Down') 132 | move_to_btn = wx.Button(action_panel, id=3, label='Move To') 133 | # bind events 134 | apply_btn.Bind(wx.EVT_BUTTON, self.apply_rule) 135 | add_rule_btn.Bind(wx.EVT_BUTTON, self.add_rule) 136 | clone_window_btn.Bind(wx.EVT_BUTTON, self.clone_windows) 137 | edit_rule_btn.Bind(wx.EVT_BUTTON, self.edit_rule) 138 | dup_rule_btn.Bind(wx.EVT_BUTTON, self.duplicate_rule) 139 | del_rule_btn.Bind(wx.EVT_BUTTON, self.delete_rule) 140 | mov_up_rule_btn.Bind(wx.EVT_BUTTON, self.move_rule) 141 | mov_dn_rule_btn.Bind(wx.EVT_BUTTON, self.move_rule) 142 | move_to_btn.Bind(wx.EVT_BUTTON, self.move_to) 143 | # position buttons 144 | action_sizer = wx.BoxSizer(wx.HORIZONTAL) 145 | for btn in ( 146 | apply_btn, 147 | add_rule_btn, 148 | clone_window_btn, 149 | edit_rule_btn, 150 | dup_rule_btn, 151 | del_rule_btn, 152 | mov_up_rule_btn, 153 | mov_dn_rule_btn, 154 | move_to_btn, 155 | ): 156 | action_sizer.Add(btn, 0, wx.ALL, 5) 157 | action_panel.SetSizer(action_sizer) 158 | 159 | # create list control 160 | self.list_control = EditableListCtrl(self, edit_cols=[0, 1, 2], post_edit=self.post_edit) 161 | self.list_control.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.edit_rule) 162 | for index, col in enumerate(('Rule Name', 'Window Title', 'Window Executable', 'Window Rect', 'Window Size')): 163 | self.list_control.AppendColumn(col) 164 | self.list_control.SetColumnWidth(index, 200 if index < 3 else 150) 165 | 166 | # add rules 167 | for rule in self.rules: 168 | self.append_rule(rule) 169 | 170 | # position list control 171 | sizer = wx.BoxSizer(wx.VERTICAL) 172 | sizer.AddSpacer(15) 173 | sizer.Add(action_panel, 0, wx.ALL | wx.EXPAND, 0) 174 | sizer.Add(self.list_control, 1, wx.ALL | wx.EXPAND, 5) 175 | self.SetSizer(sizer) 176 | 177 | def add_rule(self, *_): 178 | rule = _new_rule() 179 | rule.rule_name = 'Unnamed rule' 180 | self.rules.append(rule) 181 | self.append_rule(rule) 182 | self.snapshot.save() 183 | 184 | def apply_rule(self, *_): 185 | rules = [] 186 | for index in self.list_control.GetAllSelected(): 187 | rules.append(self.rules[index]) 188 | restore_snapshot([], rules) 189 | 190 | def append_rule(self, rule: Rule): 191 | self.list_control.Append( 192 | (rule.rule_name or 'Unnamed rule', rule.name or '', rule.executable or '', str(rule.rect), str(rule.size)) 193 | ) 194 | 195 | def clone_windows(self, *_): 196 | def on_clone(indexes: list[int], options: dict[str, bool]): 197 | selected = [windows[i] for i in indexes] 198 | for window in selected: 199 | if window.executable: 200 | rule_name = os.path.basename(window.executable) + ' rule' 201 | else: 202 | rule_name = 'Unnamed rule' 203 | 204 | rule = Rule( 205 | size=window.size, 206 | rect=window.rect, 207 | placement=window.placement, 208 | name=window.name if options['Clone window names'] else '', 209 | executable=window.executable if options['Clone window executable paths'] else '', 210 | rule_name=rule_name, 211 | ) 212 | self.rules.append(rule) 213 | self.append_rule(rule) 214 | self.snapshot.save() 215 | 216 | windows: list[Window] = sorted(capture_snapshot(), key=lambda w: w.name) 217 | options = {'Clone window names': True, 'Clone window executable paths': True} 218 | SelectionWindow(self, [i.name for i in windows], on_clone, options, title='Clone Windows').Show() 219 | 220 | def delete_rule(self, *_): 221 | while (item := self.list_control.GetFirstSelected()) != -1: 222 | self.rules.pop(item) 223 | self.list_control.DeleteItem(item) 224 | self.snapshot.save() 225 | 226 | def duplicate_rule(self, *_): 227 | for item in self.list_control.GetAllSelected(): 228 | self.rules.append(deepcopy(self.rules[item])) 229 | self.append_rule(self.rules[-1]) 230 | self.snapshot.save() 231 | 232 | def edit_rule(self, *_): 233 | alive_windows = {i.GetName(): i for i in self.GetChildren() if isinstance(i, Frame)} 234 | for item in self.list_control.GetAllSelected(): 235 | rule = self.rules[item] 236 | r_name = f'editrule-{id(rule)}' 237 | if r_name in alive_windows: 238 | alive_windows[r_name].Raise() 239 | else: 240 | RuleWindow(self, rule, on_save=self.refresh_list, name=r_name).Show() 241 | 242 | def insert_rule(self, index: int, rule: Rule): 243 | self.list_control.Insert( 244 | index, 245 | (rule.rule_name or 'Unnamed rule', rule.name or '', rule.executable or '', str(rule.rect), str(rule.size)), 246 | ) 247 | 248 | def move_rule(self, btn_event: wx.Event): 249 | direction = -1 if btn_event.Id == 1 else 1 250 | selected = list(self.list_control.GetAllSelected()) 251 | items: list[tuple[int, Rule]] = [] 252 | 253 | # get all items and their new positions 254 | for index in reversed(selected): 255 | self.list_control.DeleteItem(index) 256 | items.insert(0, (max(0, index + direction), self.rules.pop(index))) 257 | 258 | # re-insert into list 259 | for new_index, rule in items: 260 | self.rules.insert(new_index, rule) 261 | self.insert_rule(new_index, rule) 262 | self.list_control.Select(new_index) 263 | 264 | self.snapshot.save() 265 | 266 | def move_to(self, btn_event: wx.Event): 267 | options = {'Create a copy': False} 268 | layouts: list[Snapshot] = [self.snapshot.get_current_snapshot()] 269 | l_names = ['Current Snapshot'] + [i.phony for i in layouts[1:]] 270 | for layout in self.snapshot.data: 271 | if not layout.phony: 272 | continue 273 | layouts.append(layout) 274 | l_names.append(layout.phony) 275 | 276 | def on_select(selection, options): 277 | rules_to_move = list(self.list_control.GetAllSelected()) 278 | rules = [self.rules[i] for i in rules_to_move] 279 | 280 | # remove from current 281 | if not options['Create a copy']: 282 | for index in reversed(rules_to_move): 283 | self.rules.pop(index) 284 | 285 | # copy to others 286 | for index in selection: 287 | layouts[index].rules.extend(deepcopy(rules)) 288 | 289 | # refresh 290 | self.refresh_list() 291 | 292 | SelectionWindow(self, l_names, on_select, options, title='Move Rules Between Layouts').Show() 293 | 294 | def post_edit(self, col, row): 295 | rule: Rule = self.rules[row] 296 | for window in self.GetChildren(): 297 | if not isinstance(window, RuleWindow): 298 | continue 299 | if window.rule is not rule: 300 | continue 301 | text = self.list_control.GetItemText(row, col) 302 | match col: 303 | case 0: 304 | rule.rule_name = text 305 | case 1: 306 | rule.name = text 307 | case 3: 308 | rule.executable = text 309 | window.populate_form() 310 | 311 | def refresh_list(self, selected=None): 312 | selected = selected or [] 313 | self.list_control.DeleteAllItems() 314 | for index, rule in enumerate(self.rules): 315 | self.append_rule(rule) 316 | self.list_control.Select(index, on=index in selected) 317 | self.snapshot.save() 318 | 319 | 320 | def _new_rule(): 321 | rect = (0, 0, 1000, 500) 322 | return Rule(size=size_from_rect(rect), rect=rect, placement=(0, 1, (-1, -1), (-1, -1), rect)) 323 | -------------------------------------------------------------------------------- /src/gui/settings.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import urllib.request 5 | 6 | import wx 7 | 8 | from _version import __version__ 9 | from common import load_json, local_path, reverse_dict_lookup 10 | from gui.widgets import EVT_TIME_SPAN_SELECT, TimeSpanSelector, simple_box_sizer 11 | 12 | 13 | class SettingsPanel(wx.Panel): 14 | def __init__(self, parent: wx.Frame): 15 | self.log = logging.getLogger(__name__).getChild(f'{self.__class__.__name__}.{id(self)}') 16 | wx.Panel.__init__(self, parent, id=wx.ID_ANY) 17 | self.settings = load_json('settings') 18 | 19 | def header(text: str): 20 | txt = wx.StaticText(panel, label=text) 21 | txt.SetFont(wx.Font(14, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)) 22 | div = wx.StaticLine(panel, size=wx.DefaultSize) 23 | return (txt, div) 24 | 25 | # widgets 26 | panel = wx.Panel(self) 27 | header1 = header('Snapshot settings') 28 | pause_snap_opt = wx.CheckBox(panel, id=1, label='Pause snapshots') 29 | snap_freq_txt = wx.StaticText(panel, label='Snapshot frequency') 30 | self.__snap_freq_choices = { 31 | '5 seconds': 5, 32 | '10 seconds': 10, 33 | '30 seconds': 30, 34 | '1 minute': 60, 35 | '5 minutes': 300, 36 | '10 minutes': 600, 37 | '30 minutes': 1800, 38 | '1 hour': 3600, 39 | } 40 | snap_freq_opt = wx.Choice(panel, id=2, choices=list(self.__snap_freq_choices.keys())) 41 | save_freq_txt = wx.StaticText(panel, label='Save frequency') 42 | save_freq_opt = wx.SpinCtrl(panel, id=3, min=1, max=10) 43 | 44 | prune_history_opt = wx.CheckBox(panel, id=4, label='Prune window history') 45 | prune_history_opt.SetToolTip( 46 | 'Remove windows from the history if they no longer exist.' 47 | '\nIt\'s recommended disable this when "Enable reacting to new windows being created" is enabled.' 48 | ) 49 | 50 | history_ttl_txt = wx.StaticText(panel, label='Window history retention') 51 | history_ttl_txt.SetToolTip('How long window positioning information should be remembered by the program') 52 | history_ttl_opt = TimeSpanSelector(panel, id=5) 53 | 54 | history_count_txt = wx.StaticText(panel, label='Max number of snapshots to keep') 55 | history_count_opt = wx.SpinCtrl(panel, id=6, min=1, max=50) 56 | 57 | header2 = header('Misc') 58 | 59 | log_level_txt = wx.StaticText(panel, label='Logging level') 60 | log_level_opt = wx.Choice(panel, id=7, choices=['Debug', 'Info', 'Warning', 'Error', 'Critical']) 61 | 62 | open_install_btn = wx.Button(panel, label='Open install directory') 63 | open_github_btn = wx.Button(panel, label='Open GitHub page') 64 | check_updates_btn = wx.Button(panel, label='Check for updates') 65 | # place 66 | simple_box_sizer( 67 | panel, 68 | ( 69 | *header1, 70 | pause_snap_opt, 71 | (snap_freq_txt, snap_freq_opt), 72 | (save_freq_txt, save_freq_opt), 73 | prune_history_opt, 74 | (history_ttl_txt, history_ttl_opt), 75 | (history_count_txt, history_count_opt), 76 | *header2, 77 | (log_level_txt, log_level_opt), 78 | open_install_btn, 79 | open_github_btn, 80 | check_updates_btn, 81 | ), 82 | ) 83 | 84 | # set widget states 85 | pause_snap_opt.SetValue(wx.CHK_CHECKED if self.settings.get('pause_snapshots', False) else wx.CHK_UNCHECKED) 86 | snap_freq_opt.SetStringSelection( 87 | reverse_dict_lookup(self.__snap_freq_choices, self.settings.get('snapshot_freq', 60)) 88 | ) 89 | save_freq_opt.SetValue(self.settings.get('save_freq', 1)) 90 | prune_history_opt.SetValue(self.settings.get('prune_history', True)) 91 | history_ttl_opt.SetTime(self.settings.get('window_history_ttl', 0)) 92 | history_count_opt.SetValue(self.settings.get('max_snapshots', 10)) 93 | log_level_opt.SetStringSelection(self.settings.get('log_level', 'Info')) 94 | 95 | # bind events 96 | pause_snap_opt.Bind(wx.EVT_CHECKBOX, self.on_setting) 97 | snap_freq_opt.Bind(wx.EVT_CHOICE, self.on_setting) 98 | save_freq_opt.Bind(wx.EVT_SPINCTRL, self.on_setting) 99 | prune_history_opt.Bind(wx.EVT_CHECKBOX, self.on_setting) 100 | history_ttl_opt.Bind(EVT_TIME_SPAN_SELECT, self.on_setting) 101 | history_count_opt.Bind(wx.EVT_SPINCTRL, self.on_setting) 102 | log_level_opt.Bind(wx.EVT_CHOICE, self.on_setting) 103 | 104 | open_install_btn.Bind(wx.EVT_BUTTON, lambda *_: os.startfile(local_path('.'))) 105 | open_github_btn.Bind(wx.EVT_BUTTON, lambda *_: os.startfile('https://github.com/Crozzers/RestoreWindowPos')) 106 | 107 | self._latest_version = None 108 | check_updates_btn.Bind(wx.EVT_BUTTON, self.check_update) 109 | 110 | # place 111 | self.sizer = wx.BoxSizer(wx.VERTICAL) 112 | self.sizer.Add(panel, 0, wx.ALL | wx.EXPAND, 5) 113 | self.SetSizerAndFit(self.sizer) 114 | 115 | def on_setting(self, event: wx.Event): 116 | widget = event.GetEventObject() 117 | if isinstance(widget, wx.CheckBox): 118 | if event.Id == 1: 119 | self.settings.set('pause_snapshots', widget.GetValue()) 120 | elif event.Id == 4: 121 | self.settings.set('prune_history', widget.GetValue()) 122 | elif isinstance(widget, wx.Choice): 123 | if event.Id == 2: 124 | self.settings.set('snapshot_freq', self.__snap_freq_choices[widget.GetStringSelection()]) 125 | elif event.Id == 7: 126 | level: str = widget.GetStringSelection().upper() 127 | self.settings.set('log_level', level) 128 | logging.getLogger().setLevel(logging.getLevelName(level)) 129 | elif isinstance(widget, wx.SpinCtrl): 130 | if event.Id == 3: 131 | self.settings.set('save_freq', widget.GetValue()) 132 | elif event.Id == 6: 133 | self.settings.set('max_snapshots', widget.GetValue()) 134 | elif isinstance(widget, TimeSpanSelector): 135 | if event.Id == 5: 136 | self.settings.set('window_history_ttl', widget.GetTime()) 137 | 138 | def check_update(self, event: wx.Event): 139 | widget: wx.Button = event.GetEventObject() 140 | if 'check for' in widget.GetLabelText().lower(): 141 | try: 142 | data = json.loads( 143 | urllib.request.urlopen('https://api.github.com/repos/Crozzers/RestoreWindowPos/tags').read().decode() 144 | ) 145 | except Exception as e: 146 | self.log.error(f'failed to check for updates: {e!r}') 147 | widget.SetLabel('Check for updates (error: failed to query GitHub)') 148 | else: 149 | self._latest_version = data[0]['name'] 150 | # convert both to an int tuple for simple comparison without requiring the `packaging` package 151 | latest_version_num = tuple(int(i) for i in self._latest_version.split('.')) 152 | current_version_num = tuple(int(i) for i in __version__.split('.')) 153 | 154 | if latest_version_num > current_version_num: 155 | widget.SetLabel(f'Update Available ({__version__} -> {self._latest_version}). Click to download') 156 | else: 157 | widget.SetLabel('Check for updates (none available)') 158 | widget.Layout() 159 | self.sizer.Layout() 160 | else: 161 | os.startfile( 162 | f'https://github.com/Crozzers/RestoreWindowPos/releases/download/{self._latest_version}/RestoreWindowPos_install.exe' 163 | ) 164 | -------------------------------------------------------------------------------- /src/gui/systray.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Callable, Optional 3 | 4 | import wx 5 | import wx.adv 6 | 7 | from common import local_path 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | MenuItem = tuple[str, Callable | 'MenuList', Optional[bool]] 12 | MenuList = list[MenuItem] 13 | 14 | 15 | class TaskbarIcon(wx.adv.TaskBarIcon): 16 | SEPARATOR = wx.ITEM_SEPARATOR 17 | RADIO = wx.ITEM_RADIO 18 | NORMAL = wx.ITEM_NORMAL 19 | 20 | def __init__( 21 | self, menu_options: MenuList, on_click: Optional[Callable] = None, on_exit: Optional[Callable] = None 22 | ): 23 | wx.adv.TaskBarIcon.__init__(self) 24 | self.SetIcon(wx.Icon(local_path('assets/icon32.ico', asset=True)), 'RestoreWindowPos') 25 | self.menu_options = menu_options 26 | self._on_click = on_click 27 | self._on_exit = on_exit 28 | 29 | def set_menu_options(self, menu_options): 30 | self.menu_options = menu_options 31 | 32 | def CreatePopupMenu(self): 33 | if callable(self._on_click): 34 | self._on_click() 35 | if self.menu_options is None: 36 | return False 37 | menu = wx.Menu() 38 | menu_from_list(menu, self.menu_options + [self.SEPARATOR, ['Quit', lambda *_: self.exit()]]) 39 | return menu 40 | 41 | def exit(self): 42 | self.RemoveIcon() 43 | if callable(self._on_exit): 44 | self._on_exit() 45 | 46 | def __enter__(self): 47 | return self 48 | 49 | def __exit__(self, *_): 50 | self.exit() 51 | 52 | 53 | def execute_menu_item(callback, *args): 54 | try: 55 | callback(*args) 56 | except Exception as e: 57 | log.exception(f'failed to execute menu item {callback}, args: {args}') 58 | wx.MessageBox( 59 | f'Failed to execute system tray menu command\n{type(e).__name__}: {e}', 60 | 'RestoreWindowPos', 61 | wx.OK | wx.ICON_ERROR, 62 | ) 63 | raise 64 | 65 | 66 | def menu_from_list(menu: wx.Menu, menu_items: MenuList): 67 | """Modifies menu inplace""" 68 | item_kind = TaskbarIcon.NORMAL 69 | 70 | for index, item in enumerate(menu_items): 71 | if item == TaskbarIcon.RADIO: 72 | item_kind = TaskbarIcon.RADIO 73 | continue 74 | elif item == TaskbarIcon.NORMAL: 75 | item_kind = TaskbarIcon.NORMAL 76 | continue 77 | 78 | if item == TaskbarIcon.SEPARATOR: 79 | if index > 0 and menu_items[index - 1] != TaskbarIcon.SEPARATOR: 80 | menu.AppendSeparator() 81 | elif len(item) == 1: 82 | item = wx.MenuItem(menu, id=wx.ID_ANY, text=item[0], kind=TaskbarIcon.NORMAL) 83 | item.Enable(False) 84 | menu.Append(item) 85 | elif not callable(item[1]): 86 | sub_menu = wx.Menu() 87 | menu_from_list(sub_menu, item[1]) 88 | menu.Append(wx.ID_ANY, item[0], sub_menu) 89 | else: 90 | menu_item = wx.MenuItem(menu, id=wx.ID_ANY, text=item[0], kind=item_kind) 91 | menu.Bind(wx.EVT_MENU, lambda *_, cb=item[1]: execute_menu_item(cb, *_), id=menu_item.GetId()) 92 | menu.Append(menu_item) 93 | 94 | if item_kind == TaskbarIcon.RADIO: 95 | if item[2]: 96 | menu_item.Check() 97 | 98 | 99 | def radio_menu(allowed_values: dict[str, Any], get_value: Callable, set_value: Callable) -> MenuList: 100 | def cb(k): 101 | set_value(allowed_values[k]) 102 | for opt in opts: 103 | opt[2] = opt[0] == k 104 | 105 | opts = [] 106 | current_value = get_value() 107 | for key, value in allowed_values.items(): 108 | opts.append([key, lambda *_, k=key: cb(k), value == current_value]) 109 | return [TaskbarIcon.RADIO] + opts 110 | -------------------------------------------------------------------------------- /src/gui/widgets.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable, Iterable, Mapping, Sequence 2 | from typing import Optional 3 | 4 | import wx 5 | from wx.lib.mixins.listctrl import TextEditMixin 6 | from wx.lib.newevent import NewEvent 7 | 8 | from common import local_path 9 | 10 | 11 | class Frame(wx.Frame): 12 | def __init__(self, parent=None, title=None, **kwargs): 13 | wx.Frame.__init__(self, parent=parent, id=wx.ID_ANY, **kwargs) 14 | self.SetTitle(title) 15 | self.SetBackgroundColour(wx.SystemSettings.GetColour(wx.SYS_COLOUR_MENU)) 16 | self.SetIcon(wx.Icon(local_path('assets/icon32.ico', asset=True))) 17 | 18 | def GetIdealSize(self): 19 | x = 480 20 | y = 360 21 | for child in self.GetChildren(): 22 | if hasattr(child, 'GetBestVirtualSize'): 23 | vsize = child.GetBestVirtualSize() 24 | x = max(x, vsize.x) 25 | y = max(y, vsize.y) 26 | return wx.Size(x + 10, y + 10) 27 | 28 | def SetIdealSize(self): 29 | self.SetSize(self.GetIdealSize()) 30 | 31 | def SetTitle(self, title): 32 | if title is None: 33 | title = 'RestoreWindowPos' 34 | else: 35 | title = f'{title} - RestoreWindowPos' 36 | return super().SetTitle(title) 37 | 38 | 39 | class ListCtrl(wx.ListCtrl): 40 | def __init__(self, parent, *args, **kwargs): 41 | kwargs.setdefault('style', wx.LC_REPORT) 42 | wx.ListCtrl.__init__(self, parent, *args, **kwargs) 43 | 44 | def GetAllSelected(self): 45 | pos = self.GetFirstSelected() 46 | if pos == -1: 47 | return 48 | yield pos 49 | while (item := self.GetNextSelected(pos)) != -1: 50 | yield item 51 | pos = item 52 | 53 | def Insert(self, index: int, entry): 54 | pos = self.InsertItem(index, entry[0]) 55 | for i in range(1, len(entry)): 56 | self.SetItem(pos, i, entry[i]) 57 | 58 | def ScrollList(self, dx, dy): 59 | return super().ScrollList(int(dx), int(dy)) 60 | 61 | 62 | class EditableListCtrl(ListCtrl, TextEditMixin): 63 | def __init__( 64 | self, 65 | parent, 66 | *args, 67 | edit_cols: Optional[list[int]] = None, 68 | on_edit: Optional[Callable[[int, int], bool]] = None, 69 | post_edit: Optional[Callable[[int, int], None]] = None, 70 | **kwargs, 71 | ): 72 | """ 73 | Args: 74 | parent: parent widget 75 | *args: passed to `ListCtrl.__init__` 76 | edit_cols: columns to allow editing in, zero based 77 | on_edit: callback for when editing starts. Takes column and row 78 | being edited as params. Boolean return determines whether to 79 | allow that cell to be edited 80 | post_edit: callback for once editing has been completed. Takes 81 | column and row as params. 82 | **kwargs: passed to `ListCtrl.__init__` 83 | """ 84 | kwargs.setdefault('style', wx.LC_REPORT | wx.LC_EDIT_LABELS) 85 | ListCtrl.__init__(self, parent, *args, **kwargs) 86 | TextEditMixin.__init__(self) 87 | self.Bind(wx.EVT_LEFT_DCLICK, self._on_double_click) 88 | self.edit_cols = edit_cols 89 | self.on_edit = on_edit 90 | self.post_edit = post_edit 91 | 92 | def _on_double_click(self, evt: wx.Event): 93 | handler: wx.EvtHandler = self.GetEventHandler() 94 | handler.ProcessEvent(wx.PyCommandEvent(wx.EVT_LIST_ITEM_ACTIVATED.typeId, self.GetId())) 95 | evt.Skip() 96 | 97 | def CloseEditor(self, evt=None): 98 | if not self.editor.IsShown(): 99 | return 100 | super().CloseEditor(evt) 101 | 102 | if callable(self.post_edit): 103 | self.post_edit(self.curCol, self.curRow) 104 | 105 | def OnChar(self, event: wx.KeyEvent): 106 | keycode = event.GetKeyCode() 107 | if keycode == wx.WXK_ESCAPE: 108 | self.editor.Hide() 109 | else: 110 | super().OnChar(event) 111 | 112 | def OpenEditor(self, col, row): 113 | if self.on_edit is not None: 114 | if not self.on_edit(col, row): 115 | self.Select(row) 116 | return 117 | 118 | if self.edit_cols is not None: 119 | if col not in self.edit_cols: 120 | self.Select(row) 121 | return 122 | 123 | super().OpenEditor(col, row) 124 | if not self.editor.IsShown(): 125 | # editor is activated using `CallAfter` but after closing 126 | # and re-opening the window, this event is never fired. 127 | # This hack copies internal code of the TextEditMixin to 128 | # force the editor to open 129 | x0 = self.col_locs[col] 130 | x1 = self.col_locs[col + 1] - x0 131 | y0 = self.GetItemRect(row)[1] 132 | 133 | scrolloffset = self.GetScrollPos(wx.HORIZONTAL) 134 | self.editor.SetSize(x0 - scrolloffset, y0, x1, -1, wx.SIZE_USE_EXISTING) 135 | self.editor.SetValue(self.GetItem(row, col).GetText()) 136 | self.editor.Show() 137 | self.editor.Raise() 138 | self.editor.SetSelection(-1, -1) 139 | self.editor.SetFocus() 140 | 141 | 142 | RearrangeListSelect, EVT_REARRANGE_LIST_SELECT = NewEvent() 143 | 144 | 145 | class RearrangeListCtrl(wx.Panel): 146 | def __init__(self, parent, options: Mapping[str, bool], order: Sequence[str], label_mapping: Mapping[str, str]): 147 | """ 148 | Args: 149 | parent: parent widget 150 | options: mapping of programmatic name -> enabled/disabled 151 | order: ordered list of the programmatic option names 152 | label_mapping: mapping of friendly name -> programmatic name 153 | """ 154 | wx.Panel.__init__(self, parent) 155 | 156 | self.__labels = label_mapping 157 | self.rearrange_list = wx.RearrangeList( 158 | self, items=list(label_mapping.keys()), order=[list(label_mapping.values()).index(i) for i in order] 159 | ) 160 | self.up_btn = wx.Button(self, label='Up') 161 | self.down_btn = wx.Button(self, label='Down') 162 | 163 | # set state 164 | self.rearrange_list.SetCheckedItems([order.index(opt) for opt, state in options.items() if state]) 165 | 166 | self.rearrange_list.Bind(wx.EVT_CHECKLISTBOX, lambda *_: self.OnSelectionChange()) 167 | self.up_btn.Bind(wx.EVT_BUTTON, self.move_selection) 168 | self.down_btn.Bind(wx.EVT_BUTTON, self.move_selection) 169 | 170 | self.sizer = wx.BoxSizer(wx.HORIZONTAL) 171 | self.button_sizer = wx.BoxSizer(wx.VERTICAL) 172 | self.sizer.Add(self.rearrange_list) 173 | self.button_sizer.Add(self.up_btn) 174 | self.button_sizer.Add(self.down_btn) 175 | self.sizer.Add(self.button_sizer) 176 | self.SetSizerAndFit(self.sizer) 177 | 178 | def get_selection(self): 179 | """ 180 | Returns: 181 | mapping of option -> checked status. Dict items are inserted in the correct order 182 | """ 183 | items_in_order = self.rearrange_list.GetItems() 184 | checked = self.rearrange_list.GetCheckedStrings() 185 | return {self.__labels[item]: item in checked for item in items_in_order} 186 | 187 | def move_selection(self, evt: wx.Event): 188 | if evt.Id == self.up_btn.Id: 189 | self.rearrange_list.MoveCurrentUp() 190 | elif evt.Id == self.down_btn.Id: 191 | self.rearrange_list.MoveCurrentDown() 192 | self.OnSelectionChange() 193 | 194 | def OnSelectionChange(self): 195 | """Posts a `EVT_REARRANGE_LIST_SELECT` event""" 196 | evt = RearrangeListSelect() 197 | evt.SetEventObject(self) 198 | evt.SetId(self.Id) 199 | wx.PostEvent(self.GetEventHandler(), evt) 200 | 201 | 202 | class SelectionWindow(Frame): 203 | def __init__( 204 | self, 205 | parent, 206 | select_from: list, 207 | callback: Callable[[list[int], dict[str, bool]], None], 208 | options: Optional[dict[str, bool]] = None, 209 | **kwargs, 210 | ): 211 | super().__init__(parent, **kwargs) 212 | self.CenterOnParent() 213 | self.select_from = select_from 214 | self.callback = callback 215 | self.options = options or {} 216 | 217 | # create action buttons 218 | action_panel = wx.Panel(self) 219 | done_btn = wx.Button(action_panel, label='Done') 220 | deselect_all_btn = wx.Button(action_panel, label='Deselect All') 221 | select_all_btn = wx.Button(action_panel, label='Select All') 222 | # bind events 223 | done_btn.Bind(wx.EVT_BUTTON, self.done) 224 | deselect_all_btn.Bind(wx.EVT_BUTTON, self.deselect_all) 225 | select_all_btn.Bind(wx.EVT_BUTTON, self.select_all) 226 | # place 227 | action_sizer = wx.BoxSizer(wx.HORIZONTAL) 228 | for check in (done_btn, deselect_all_btn, select_all_btn): 229 | action_sizer.Add(check, 0, wx.ALL, 5) 230 | action_panel.SetSizer(action_sizer) 231 | 232 | # create option buttons 233 | def toggle_option(key): 234 | self.options[key] = not self.options[key] 235 | 236 | option_panel = wx.Panel(self) 237 | option_sizer = wx.GridSizer(cols=len(self.options), hgap=5, vgap=5) 238 | for key, value in self.options.items(): 239 | check = wx.CheckBox(option_panel, label=key) 240 | if value: 241 | check.SetValue(wx.CHK_CHECKED) 242 | check.Bind(wx.EVT_CHECKBOX, lambda *_, k=key: toggle_option(k)) 243 | option_sizer.Add(check, 0, wx.ALIGN_CENTER) 244 | option_panel.SetSizer(option_sizer) 245 | 246 | self.check_list = wx.CheckListBox(self, style=wx.LB_EXTENDED | wx.LB_NEEDED_SB) 247 | self.check_list.AppendItems(select_from) 248 | 249 | sizer = wx.BoxSizer(wx.VERTICAL) 250 | sizer.Add(action_panel, 0, wx.ALL | wx.EXPAND, 5) 251 | sizer.Add(option_panel, 0, wx.ALL | wx.EXPAND, 5) 252 | sizer.Add(self.check_list, 0, wx.ALL | wx.EXPAND, 5) 253 | self.SetSizerAndFit(sizer) 254 | 255 | def done(self, *_): 256 | selected = self.check_list.GetCheckedItems() 257 | 258 | try: 259 | self.Close() 260 | self.Destroy() 261 | except RuntimeError: 262 | pass 263 | 264 | self.callback(selected, self.options) 265 | 266 | def deselect_all(self, *_): 267 | self.select_all(check=False) 268 | 269 | def select_all(self, *_, check=True): 270 | for i in range(len(self.select_from)): 271 | self.check_list.Check(i, check=check) 272 | 273 | 274 | TimeSpanSelect, EVT_TIME_SPAN_SELECT = NewEvent() 275 | 276 | 277 | class TimeSpanSelector(wx.Panel): 278 | def __init__(self, parent, *a, **kw): 279 | wx.Panel.__init__(self, parent, *a, **kw) 280 | 281 | # create widgets 282 | self.spin_ctrl = wx.SpinCtrl(self, min=1) 283 | self.choices = {'Forever': 0, 'Minutes': 60, 'Hours': 3600, 'Days': 86400, 'Months': 86400 * 30} 284 | self.multiplier_selector = wx.Choice(self, choices=list(self.choices.keys())) 285 | 286 | # bind events 287 | self.spin_ctrl.Bind(wx.EVT_SPINCTRL, self.OnSelection) 288 | self.multiplier_selector.Bind(wx.EVT_CHOICE, self.OnSelection) 289 | 290 | # place widgets 291 | sizer = wx.BoxSizer(wx.HORIZONTAL) 292 | sizer.Add(self.spin_ctrl, 0, wx.ALL, 0) 293 | sizer.Add(self.multiplier_selector, 0, wx.ALL, 0) 294 | self.SetSizerAndFit(sizer) 295 | 296 | def GetTime(self) -> int: 297 | multiplier = tuple(self.choices.values())[self.multiplier_selector.GetSelection()] 298 | return self.spin_ctrl.GetValue() * multiplier 299 | 300 | def OnSelection(self, _): 301 | if self.multiplier_selector.GetSelection() == 0: 302 | self.spin_ctrl.Hide() 303 | elif not self.spin_ctrl.IsShown(): 304 | self.spin_ctrl.Show() 305 | self.Layout() 306 | 307 | evt = TimeSpanSelect() 308 | evt.SetEventObject(self) 309 | evt.SetId(self.Id) 310 | wx.PostEvent(self.GetEventHandler(), evt) 311 | 312 | def SetTime(self, seconds: int): 313 | if seconds == 0: 314 | self.multiplier_selector.SetSelection(0) 315 | self.spin_ctrl.Hide() 316 | return 317 | if not self.spin_ctrl.IsShown(): 318 | self.spin_ctrl.Show() 319 | self.Layout() 320 | 321 | for name, multiplier in reversed(self.choices.items()): 322 | if (count := seconds // multiplier) >= 1: 323 | self.spin_ctrl.SetValue(count) 324 | self.multiplier_selector.SetSelection(tuple(self.choices).index(name)) 325 | return 326 | 327 | 328 | def simple_box_sizer(parent: wx.Panel, widgets: Iterable[wx.Window | Iterable[wx.Window]], group_mode=wx.VERTICAL): 329 | # place 330 | sizer = wx.BoxSizer(wx.VERTICAL) 331 | for widget in widgets: 332 | flag = wx.ALL 333 | if isinstance(widget, wx.StaticLine): 334 | flag |= wx.EXPAND 335 | elif isinstance(widget, tuple): 336 | sz = wx.BoxSizer(group_mode) 337 | for w in widget: 338 | sz.Add(w, 0, wx.ALL, 0) 339 | widget = sz 340 | 341 | sizer.Add(widget, 0, flag, 5) 342 | parent.SetSizerAndFit(sizer) 343 | parent.Layout() 344 | -------------------------------------------------------------------------------- /src/gui/wx_app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Literal, Optional 3 | 4 | import psutil 5 | import wx 6 | import wx.adv 7 | 8 | from common import single_call 9 | from gui.layout_manager import LayoutPage 10 | from gui.on_spawn_manager import OnSpawnPage 11 | from gui.settings import SettingsPanel 12 | from gui.widgets import Frame 13 | from snapshot import SnapshotFile 14 | 15 | 16 | class WxApp(wx.App): 17 | __instance: 'WxApp' 18 | 19 | @single_call # mark as `single_call` so we don't re-call `OnInit` during shutdown 20 | def __init__(self): 21 | self._log = logging.getLogger(__name__).getChild(self.__class__.__name__ + '.' + str(id(self))) 22 | super().__init__() 23 | self.SetExitOnFrameDelete(False) 24 | 25 | def __new__(cls, *args, **kwargs): 26 | if not isinstance(getattr(cls, '_WxApp__instance', None), cls): 27 | cls._WxApp__instance = wx.App.__new__(cls, *args, **kwargs) 28 | return cls._WxApp__instance 29 | 30 | def OnInit(self): 31 | if isinstance(getattr(self, '_top_frame', None), wx.Frame): 32 | return True 33 | self._top_frame = wx.Frame(None, -1) 34 | self.SetTopWindow(self._top_frame) 35 | self.enable_sigterm() 36 | return True 37 | 38 | def enable_sigterm(self, parent: Optional[psutil.Process] = None): 39 | """ 40 | Allow the application to respond to external signals such as SIGTERM or SIGINT. 41 | This is done by creating a wx timer that regularly returns control of the program 42 | to the Python runtime, allowing it to process the signals. 43 | 44 | Args: 45 | parent: optional parent process. If provided, the lifetime of the application will 46 | be tied to this process. When the parent exits, this app will follow suit 47 | """ 48 | self._log.debug(f'enable sigterm, {parent=}') 49 | 50 | def check_parent_alive(): 51 | if not parent or parent.is_running(): 52 | return 53 | self._log.info('parent process no longer running. exiting mainloop...') 54 | if self.timer.IsRunning(): 55 | self.timer.Stop() 56 | self.ExitMainLoop() 57 | 58 | # enable sigterm by regularly returning control back to python 59 | self.timer = wx.Timer(self._top_frame) 60 | self._top_frame.Bind(wx.EVT_TIMER, lambda *_: check_parent_alive(), self.timer) 61 | self.timer.Start(1000) 62 | 63 | def Destroy(self): 64 | if self.timer.IsRunning(): 65 | self.timer.Stop() 66 | self.timer.Destroy() 67 | if self._top_frame: 68 | self._top_frame.Destroy() 69 | if top := self.GetTopWindow(): 70 | top.Destroy() 71 | if top := self.GetMainTopWindow(): 72 | top.Destroy() 73 | return super().Destroy() 74 | 75 | 76 | def spawn_gui(snapshot: SnapshotFile, start_page: Literal['rules', 'settings'] = 'rules'): 77 | top = WxApp()._top_frame 78 | 79 | for child in top.GetChildren(): 80 | if isinstance(child, Frame): 81 | if child.GetName() == 'RWPGUI': 82 | f = child 83 | nb = f.nb 84 | break 85 | else: 86 | f = Frame(parent=top, size=wx.Size(600, 500), name='RWPGUI') 87 | nb = wx.Notebook(f, id=wx.ID_ANY, style=wx.BK_DEFAULT) 88 | f.nb = nb 89 | layout_panel = LayoutPage(nb, snapshot) 90 | on_spawn_panel = OnSpawnPage(nb) 91 | settings_panel = SettingsPanel(nb) 92 | nb.AddPage(layout_panel, 'Layouts and Rules') 93 | nb.AddPage(on_spawn_panel, 'Window Spawn Behaviour') 94 | nb.AddPage(settings_panel, 'Settings') 95 | nb.SetPadding(wx.Size(5, 2)) 96 | 97 | nb.ChangeSelection(2 if start_page == 'settings' else 0) 98 | f.SetIdealSize() 99 | f.Show() 100 | f.Raise() 101 | f.Iconize(False) 102 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import logging 3 | import logging.handlers 4 | import os 5 | import signal 6 | import sys 7 | import time 8 | from typing import Optional 9 | 10 | import psutil 11 | import win32con 12 | import win32gui 13 | import wx 14 | 15 | import named_pipe 16 | from common import Window, XandY, load_json, local_path, match, single_call 17 | from device import DeviceChangeCallback, DeviceChangeService 18 | from gui import TaskbarIcon, WxApp, about_dialog, radio_menu 19 | from gui.wx_app import spawn_gui 20 | from services import ServiceCallback 21 | from snapshot import SnapshotFile, SnapshotService 22 | from window import WindowSpawnService, apply_rules, is_window_valid, restore_snapshot 23 | 24 | 25 | class LoggingFilter(logging.Filter): 26 | def filter(self, record): 27 | try: 28 | return not ( 29 | # sometimes the record has msg, sometimes its message. Just try to catch all of them 30 | 'Release ' in getattr(record, 'message', getattr(record, 'msg', '')) and record.name.startswith('comtypes') 31 | ) 32 | except Exception: 33 | # sometimes (rarely) record doesn't have a `message` attr 34 | return True 35 | 36 | 37 | def update_systray_options(): 38 | global menu_options 39 | 40 | if SETTINGS.get('pause_snapshots', False): 41 | menu_options[1][0] = 'Resume snapshots' 42 | else: 43 | menu_options[1][0] = 'Pause snapshots' 44 | 45 | history_menu = [] 46 | for config in snap.get_current_snapshot().history: 47 | timestamp = config.time 48 | label = time.strftime('%b %d %H:%M:%S', time.localtime(timestamp)) 49 | history_menu.append([label, lambda *_, t=timestamp: snap.restore(t)]) 50 | 51 | if history_menu: 52 | history_menu.insert(0, TaskbarIcon.SEPARATOR) 53 | history_menu.insert(0, ['Clear history', lambda *_: clear_restore_options()]) 54 | 55 | menu_options[2][1][:-2] = history_menu 56 | 57 | current_snapshot = snap.get_current_snapshot() 58 | layout_menu = [] 59 | for snapshot in snap.data: 60 | if not snapshot.phony: 61 | continue 62 | layout_menu.append([snapshot.phony, lambda *_, s=snapshot: restore_snapshot([], s.rules)]) 63 | menu_options[7][1][1:] = layout_menu 64 | 65 | rule_menu = [] 66 | for header, ruleset in ( 67 | ('Current Snapshot', current_snapshot.rules), 68 | ('All Compatible', snap.get_rules(compatible_with=True, exclusive=True)), 69 | ): 70 | if not ruleset: 71 | continue 72 | rule_menu.extend([TaskbarIcon.SEPARATOR, [header]]) 73 | for rule in ruleset: 74 | rule_menu.append([rule.rule_name or 'Unnamed Rule', lambda *_, r=rule: restore_snapshot([], [r])]) 75 | menu_options[8][1][:-2] = rule_menu 76 | 77 | 78 | def clear_restore_options(): 79 | result = win32gui.MessageBox( 80 | None, 81 | 'Are you sure you want to clear the snapshot history for this display configuration?', 82 | 'Clear snapshot history?', 83 | win32con.MB_YESNO | win32con.MB_ICONWARNING, 84 | ) 85 | if result == win32con.IDYES: 86 | snap.get_current_snapshot().history.clear() 87 | 88 | 89 | def rescue_windows(snap: SnapshotFile): 90 | def callback(hwnd, _): 91 | if not is_window_valid(hwnd): 92 | return 93 | window = Window.from_hwnd(hwnd) 94 | if not window.fits_display_config(displays): 95 | rect = [0, 0, *window.size] 96 | logging.info(f'rescue window {window.name!r} {window.rect} -> {rect}') 97 | window.move((0, 0)) 98 | 99 | displays = snap.get_current_snapshot().displays 100 | win32gui.EnumWindows(callback, None) 101 | 102 | 103 | def on_window_spawn(windows: list[Window]): 104 | on_spawn_settings = SETTINGS.get('on_window_spawn', {}) 105 | if not on_spawn_settings or not on_spawn_settings.get('enabled', False): 106 | return 107 | # sleep to make sure window is fully initialised with resizability info 108 | time.sleep(0.05) 109 | current_snap = snap.get_current_snapshot() 110 | rules = snap.get_rules(compatible_with=True, exclusive=True) 111 | 112 | def lkp(window: Window, match_resizability: bool) -> bool: 113 | last_instance = current_snap.last_known_process_instance( 114 | window, match_title=True, match_resizability=match_resizability 115 | ) 116 | if not last_instance: 117 | return False 118 | log.info(f'apply LKP: {window} -> {last_instance}') 119 | 120 | # if the last known process instance was minimised then we need to override the placement here 121 | rect = last_instance.rect 122 | placement = last_instance.placement 123 | if placement[1] == win32con.SW_SHOWMINIMIZED: 124 | show_cmd = win32con.SW_SHOWNORMAL 125 | if placement[0] == win32con.WPF_RESTORETOMAXIMIZED: 126 | show_cmd = win32con.SW_SHOWMAXIMIZED 127 | rect = placement[4] 128 | placement = (placement[0], show_cmd, (-1, -1), (-1, -1), placement[4]) 129 | 130 | window.set_pos(rect, placement) 131 | return True 132 | 133 | def mtm(window: Window, fuzzy_mtm: bool) -> bool: 134 | cursor_pos: XandY = win32gui.GetCursorPos() 135 | if fuzzy_mtm: 136 | # if cursor X between window X and X1, and cursor Y between window Y and Y1 137 | if window.rect[0] <= cursor_pos[0] <= window.rect[2] and window.rect[1] <= cursor_pos[1] <= window.rect[3]: 138 | return True 139 | window.center_on(cursor_pos) 140 | return True 141 | 142 | def find_matching_profile(window: Window) -> Optional[dict]: 143 | matches = [] 144 | for profile in on_spawn_settings.get('profiles', []): 145 | if not profile.get('enabled', False): 146 | continue 147 | apply_to = profile.get('apply_to', {}) 148 | if not apply_to: 149 | continue 150 | name = apply_to.get('name', '') or None 151 | exe = apply_to.get('executable', '') or None 152 | if not name and not exe: 153 | continue 154 | score = 0 155 | if name: 156 | score += match(name, window.name) 157 | if exe: 158 | score += match(exe, window.executable) 159 | if score: 160 | matches.append((score, profile)) 161 | if not matches: 162 | return None 163 | return sorted(matches, key=lambda x: x[0])[0][1] 164 | 165 | capture_snapshot = 0 166 | for window in windows: 167 | profile = find_matching_profile(window) or on_spawn_settings 168 | log.debug(f'OWS profile {profile.get("name")!r} matches window {window}') 169 | if window.parent is not None and profile.get('ignore_children', True): 170 | continue 171 | # get all the operations and the order we run them 172 | operations = { 173 | k: profile.get(k, True) 174 | for k in profile.get('operation_order', ['apply_lkp', 'apply_rules', 'move_to_mouse']) 175 | } 176 | for op_name, state in operations.items(): 177 | if not state: 178 | continue 179 | if op_name == 'apply_lkp' and lkp(window, profile.get('match_resizability', True)): 180 | break 181 | elif op_name == 'move_to_mouse' and mtm(window, profile.get('fuzzy_mtm', True)): 182 | break 183 | elif op_name == 'apply_rules' and apply_rules(rules, window): 184 | break 185 | capture_snapshot = max(capture_snapshot, profile.get('capture_snapshot', 2)) 186 | 187 | if capture_snapshot == 2: 188 | # these are all newly spawned windows so we don't have to worry about merging them into the history 189 | current_snap.history[-1].windows.extend(windows) 190 | elif capture_snapshot == 1: 191 | snap.update() 192 | 193 | 194 | def interpret_pipe_signals(message: named_pipe.Messages): 195 | match message: 196 | case named_pipe.Messages.PING: 197 | pass 198 | case named_pipe.Messages.GUI: 199 | wx.CallAfter(lambda: spawn_gui(snap, 'rules')) 200 | 201 | @single_call 202 | def shutdown(*_): 203 | log.info('begin shutdown process') 204 | monitor_thread.stop() 205 | snapshot_service.stop() 206 | window_spawn_thread.stop() 207 | log.info('save snapshot before shutting down') 208 | snap.save() 209 | log.debug('destroy WxApp') 210 | app.ExitMainLoop() 211 | app.Destroy() 212 | log.info('end shutdown process') 213 | wx.Exit() 214 | 215 | 216 | if __name__ == '__main__': 217 | ctypes.windll.shcore.SetProcessDpiAwareness(2) 218 | log_handler = logging.handlers.RotatingFileHandler( 219 | local_path('log.txt'), mode='a', maxBytes=1 * 1024 * 1024, backupCount=2, encoding='utf-8', delay=False 220 | ) 221 | # filter the excessive comtypes logs 222 | log_handler.addFilter(LoggingFilter()) 223 | logging.basicConfig( 224 | format='[%(process)d] %(asctime)s:%(levelname)s:%(name)s:%(message)s', 225 | handlers=[log_handler], 226 | level=logging.INFO 227 | ) 228 | log = logging.getLogger(__name__) 229 | log.info('start') 230 | 231 | log.info('check for existing process') 232 | if named_pipe.is_proc_alive(): 233 | if '--allow-multiple-instances' in sys.argv: 234 | log.info('Existing RWP proccess found but --allow-multiple-instances is enabled. Ignoring') 235 | else: 236 | if '--open-gui' in sys.argv: 237 | log.info('existing RWP instance found. Requesting GUI') 238 | if not named_pipe.send_message(named_pipe.Messages.GUI): 239 | log.error('GUI request failed: proper acknowledgment not received') 240 | sys.exit(1) 241 | else: 242 | # use ctypes rather than wx because wxapp isn't created yet 243 | log.info('existing RWP process found. Exiting') 244 | ctypes.windll.user32.MessageBoxW( 245 | 0, 246 | "An instance of RestoreWindowPos is already running. Please close it before launching another", 247 | "RestoreWindowPos", 248 | win32con.MB_OK 249 | ) 250 | sys.exit(0) 251 | else: 252 | # only create new pipe if no other instances running 253 | named_pipe.PipeServer(ServiceCallback(interpret_pipe_signals)).start() 254 | 255 | SETTINGS = load_json('settings') 256 | SETTINGS.set('pause_snapshots', False) # reset this key 257 | 258 | logging.getLogger().setLevel(logging.getLevelName(SETTINGS.get('log_level', 'INFO').upper())) 259 | 260 | snap = SnapshotFile() 261 | app = WxApp() 262 | 263 | menu_options = [ 264 | ['Capture now', lambda *_: snap.update()], 265 | ['Pause snapshots', lambda *_: SETTINGS.set('pause_snapshots', not SETTINGS.get('pause_snapshots', False))], 266 | ['Restore snapshot', [TaskbarIcon.SEPARATOR, ['Most recent', lambda *_: snap.restore(-1)]]], 267 | ['Rescue windows', lambda *_: rescue_windows(snap)], 268 | TaskbarIcon.SEPARATOR, 269 | [ 270 | 'Snapshot frequency', 271 | radio_menu( 272 | { 273 | '5 seconds': 5, 274 | '10 seconds': 10, 275 | '30 seconds': 30, 276 | '1 minute': 60, 277 | '5 minutes': 300, 278 | '10 minutes': 600, 279 | '30 minutes': 1800, 280 | '1 hour': 3600, 281 | }, 282 | lambda: SETTINGS.get('snapshot_freq', 60), 283 | lambda v: SETTINGS.set('snapshot_freq', v), 284 | ), 285 | ], 286 | TaskbarIcon.SEPARATOR, 287 | ['Apply layout', [['Current Snapshot', lambda *_: restore_snapshot([], snap.get_rules())]]], 288 | [ 289 | 'Apply rules', 290 | [ 291 | TaskbarIcon.SEPARATOR, 292 | ['Apply all', lambda *_: restore_snapshot([], snap.get_rules(compatible_with=True))], 293 | ], 294 | ], 295 | ['Configure rules', lambda *_: spawn_gui(snap, 'rules')], 296 | TaskbarIcon.SEPARATOR, 297 | ['Settings', lambda *_: spawn_gui(snap, 'settings')], 298 | ['About', lambda *_: about_dialog()], 299 | ] 300 | 301 | # register termination signals so we can do graceful shutdown 302 | for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGABRT): 303 | signal.signal(sig, shutdown) 304 | 305 | # detect if we are running as a single exe file (pyinstaller --onefile mode) 306 | current_process = psutil.Process(os.getpid()) 307 | log.debug(f'PID: {current_process.pid}') 308 | parent_process = current_process.parent() 309 | if ( 310 | parent_process is not None 311 | and current_process.exe() == parent_process.exe() 312 | and current_process.name() == parent_process.name() 313 | ): 314 | log.debug(f'parent detected. PPID: {parent_process.pid}') 315 | app.enable_sigterm(parent_process) 316 | 317 | with TaskbarIcon(menu_options, on_click=update_systray_options, on_exit=shutdown): 318 | monitor_thread = DeviceChangeService(DeviceChangeCallback(snap.restore, shutdown, snap.update), snap.lock) 319 | monitor_thread.start() 320 | window_spawn_thread = WindowSpawnService(ServiceCallback(on_window_spawn)) 321 | window_spawn_thread.start() 322 | snapshot_service = SnapshotService(None) 323 | snapshot_service.start(args=(snap,)) 324 | 325 | try: 326 | app.MainLoop() 327 | except KeyboardInterrupt: 328 | pass 329 | finally: 330 | log.info('app mainloop closed') 331 | shutdown() 332 | log.debug('fin') 333 | wx.Exit() 334 | -------------------------------------------------------------------------------- /src/named_pipe.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import struct 3 | import time 4 | from enum import StrEnum 5 | from typing import cast 6 | 7 | import pywintypes 8 | import win32file 9 | import win32pipe 10 | from services import Service 11 | 12 | log = logging.getLogger(__name__) 13 | PIPE = r'\\.\pipe\RestoreWindowPos' 14 | 15 | 16 | class Messages(StrEnum): 17 | ACK = 'ack_' 18 | PING = 'ping' 19 | GUI = 'open_gui' 20 | 21 | 22 | class PipeServer(Service): 23 | '''Service that creates and listens on a named PIPE and responds to messages''' 24 | 25 | def _runner(self): 26 | self.log.debug('starting pipe sever') 27 | # https://stackoverflow.com/questions/48542644/python-and-windows-named-pipes 28 | # https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-createnamedpipea 29 | pipe = win32pipe.CreateNamedPipe( 30 | PIPE, 31 | win32pipe.PIPE_ACCESS_DUPLEX, 32 | win32pipe.PIPE_TYPE_MESSAGE | win32pipe.PIPE_READMODE_MESSAGE | win32pipe.PIPE_WAIT, 33 | 1, # max instances 34 | 65536, # out buffer size 35 | 65536, # in buffer size 36 | 0, # default timeout 37 | None, # security attrs 38 | ) 39 | self.log.debug(f'created pipe {PIPE!r}') 40 | 41 | while not self._kill_signal.wait(timeout=0.1): 42 | for _ in range(10): 43 | try: 44 | win32pipe.ConnectNamedPipe(pipe, None) 45 | break 46 | except Exception as e: 47 | self.log.error(f'pipe connection failed: {e!r}') 48 | time.sleep(1) 49 | else: 50 | continue 51 | 52 | self.log.info('pipe connected. Reading data...') 53 | 54 | try: 55 | message = readpipe(pipe) 56 | except pywintypes.error as e: 57 | if e.args[0] == 109: 58 | self.log.info('broken pipe') 59 | else: 60 | self.log.error(f'failed to read pipe: {e!r}') 61 | 62 | win32pipe.DisconnectNamedPipe(pipe) 63 | time.sleep(1) 64 | continue 65 | 66 | try: 67 | Messages(message) 68 | except Exception: 69 | self.log.debug(f'received and ignored message: {message[:128]!r}') 70 | else: 71 | if message == Messages.GUI: 72 | self.log.debug('GUI requested via pipe') 73 | self.callback(message) 74 | 75 | self.log.info(f'received message {message!r} and sent acknowledgement') 76 | writepipe(pipe, Messages.ACK + message) 77 | 78 | win32file.CloseHandle(pipe) 79 | self.log.info('pipe closed') 80 | 81 | 82 | def readpipe(handle: int) -> str: 83 | '''Read data from a named pipe and return the decoded value''' 84 | msg_len = struct.unpack('I', win32file.ReadFile(handle, 4)[1])[0] 85 | return win32file.ReadFile(handle, msg_len)[1].decode() 86 | 87 | 88 | def writepipe(handle: int, message: Messages | str): 89 | '''Write data to a named pipe''' 90 | # write data len so that `readpipe` knows how much to read 91 | data = struct.pack('I', len(message)) 92 | win32file.WriteFile(handle, data) 93 | win32file.WriteFile(handle, message.encode()) 94 | 95 | 96 | def send_message(message: Messages): 97 | '''Sends a message on the named pipe and checks that the correct acknowledgment is received''' 98 | try: 99 | log.info(f'attempt to send {message!r} on pipe {PIPE!r}') 100 | # make sure we wait for it to be available 101 | win32pipe.WaitNamedPipe(PIPE, 2000) 102 | handle = win32file.CreateFile( 103 | PIPE, win32file.GENERIC_READ | win32file.GENERIC_WRITE, 0, None, win32file.OPEN_EXISTING, 0, None 104 | ) 105 | # handle here is pyhandle. Consumer functions have `int` as the type hint. That is incorrect and can be ignored 106 | handle = cast(int, handle) 107 | except pywintypes.error as e: 108 | log.error(f'failed to connect to pipe: {e!r}') 109 | return False 110 | 111 | try: 112 | win32pipe.SetNamedPipeHandleState(handle, win32pipe.PIPE_READMODE_MESSAGE, None, None) 113 | writepipe(handle, message) 114 | time.sleep(1) 115 | response = readpipe(handle) 116 | except Exception as e: 117 | log.error(f'failed to write message {message!r} to pipe {PIPE!r}: {e!r}') 118 | finally: 119 | win32file.CloseHandle(handle) 120 | 121 | return response == Messages.ACK + message 122 | 123 | 124 | def is_proc_alive(): 125 | '''Pings the named pipe and listens for a response''' 126 | return send_message(Messages.PING) 127 | -------------------------------------------------------------------------------- /src/services.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | import time 4 | from abc import ABC, abstractmethod 5 | from dataclasses import dataclass 6 | from typing import Callable, Optional 7 | 8 | 9 | @dataclass(slots=True) 10 | class ServiceCallback: 11 | default: Callable 12 | shutdown: Optional[Callable] = None 13 | 14 | 15 | class Service(ABC): 16 | def __init__(self, callback: ServiceCallback | None, lock=None): 17 | self.log = logging.getLogger(__name__).getChild(self.__class__.__name__).getChild(str(id(self))) 18 | self._callback = callback 19 | self._lock = lock or threading.RLock() 20 | self._kill_signal = threading.Event() 21 | self._thread = None 22 | 23 | def start(self, args=None): 24 | args = args or () 25 | if self._thread is None: 26 | self._thread = threading.Thread(target=self._runner, args=args, daemon=True) 27 | 28 | self._thread.start() 29 | self.log.info('started thread') 30 | 31 | def stop(self, timeout=10) -> bool: 32 | """ 33 | Returns: 34 | Whether stopping the thread was successful 35 | """ 36 | self.log.info('send kill signal') 37 | self._kill_signal.set() 38 | 39 | if self._thread is None: 40 | return True 41 | 42 | start = time.time() 43 | while self._thread.is_alive(): 44 | time.sleep(0.5) 45 | if timeout is None: 46 | continue 47 | 48 | if (duration := time.time() - start) > timeout: 49 | self.log.info(f'kill signal timeout after {duration}s') 50 | return False 51 | 52 | self.log.info('thread exited') 53 | return True 54 | 55 | def shutdown(self): 56 | def func(): 57 | self._kill_signal.set() 58 | self._run_callback('shutdown') 59 | 60 | threading.Thread(target=func).start() 61 | 62 | @abstractmethod 63 | def _runner(self): 64 | pass 65 | 66 | def pre_callback(self, *args, **kwargs) -> bool: 67 | self.log.debug('run pre_callback') 68 | return True 69 | 70 | def callback(self, *args, **kwargs): 71 | with self._lock: 72 | if self.pre_callback(*args, **kwargs): 73 | try: 74 | self.log.info('run callback') 75 | self._run_callback('default', *args, **kwargs) 76 | except Exception: 77 | self.log.exception('callback failed') 78 | return False 79 | else: 80 | self.log.info('pre_callback returned False, skipping callback') 81 | self.post_callback(*args, **kwargs) 82 | 83 | return True 84 | 85 | def post_callback(self, *args, **kwargs): 86 | self.log.debug('run post_callback') 87 | 88 | def _run_callback(self, name, *args, threaded=False, **kwargs): 89 | if self._callback is None: 90 | return 91 | func = getattr(self._callback, name, None) 92 | if not callable(func): 93 | return 94 | 95 | if threaded: 96 | threading.Thread(target=func, args=args, kwargs=kwargs, daemon=True).start() 97 | else: 98 | func(*args, **kwargs) 99 | -------------------------------------------------------------------------------- /src/snapshot.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import time 4 | from dataclasses import asdict 5 | from typing import Iterator, Literal, Optional 6 | 7 | import pywintypes 8 | import win32api 9 | 10 | from common import Display, JSONFile, Snapshot, WindowHistory, load_json, local_path, size_from_rect 11 | from services import Service 12 | from window import capture_snapshot, restore_snapshot 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | 17 | def enum_display_devices() -> list[Display]: 18 | result = [] 19 | for monitor in win32api.EnumDisplayMonitors(): 20 | try: 21 | info = win32api.GetMonitorInfo(monitor[0]) # type: ignore 22 | except pywintypes.error: 23 | log.exception(f'GetMonitorInfo failed on handle {monitor[0]}') 24 | continue 25 | dev_rect = info['Monitor'] 26 | for adaptor_index in range(5): 27 | try: 28 | device = win32api.EnumDisplayDevices(info['Device'], adaptor_index, 1) 29 | dev_uid = re.findall(r'UID[0-9]+', device.DeviceID)[0] 30 | dev_name = device.DeviceID.split('#')[1] 31 | except Exception: 32 | pass 33 | else: 34 | result.append(Display(uid=dev_uid, name=dev_name, resolution=size_from_rect(dev_rect), rect=dev_rect)) 35 | return result 36 | 37 | 38 | class SnapshotFile(JSONFile): 39 | data: list[Snapshot] 40 | 41 | def __init__(self): 42 | super().__init__(local_path('history.json')) 43 | self.load() 44 | 45 | def load(self): 46 | super().load(default=[]) 47 | g_phony_found = False 48 | for index in range(len(self.data)): 49 | snapshot: Snapshot = Snapshot.from_json(self.data[index]) or Snapshot() 50 | self.data[index] = snapshot 51 | snapshot.history.sort(key=lambda a: a.time) 52 | 53 | if snapshot.phony == 'Global' and snapshot.displays == []: 54 | g_phony_found = True 55 | 56 | if not g_phony_found: 57 | self.data.append(Snapshot(phony='Global')) 58 | 59 | self.data = list(filter(None, self.data)) 60 | 61 | def save(self): 62 | with self.lock: 63 | return super().save([asdict(i) for i in self.data]) 64 | 65 | def restore(self, timestamp: Optional[float] = None): 66 | with self.lock: 67 | snap = self.get_current_snapshot() 68 | if snap is None or not snap.history: 69 | return 70 | rules = self.get_rules(compatible_with=snap) 71 | 72 | history = snap.history 73 | 74 | def restore_ts(timestamp: float): 75 | for config in history: 76 | if config.time == timestamp: 77 | restore_snapshot(config.windows, rules) 78 | snap.mru = timestamp 79 | return True 80 | 81 | self._log.info(f'restore snapshot, timestamp={timestamp}') 82 | if timestamp == -1: 83 | restore_snapshot(history[-1].windows, rules) 84 | elif timestamp: 85 | restore_ts(timestamp) 86 | else: 87 | if not (snap.mru and restore_ts(snap.mru)): 88 | restore_snapshot(history[-1].windows, rules) 89 | 90 | def capture(self): 91 | """ 92 | Captures the info for a snapshot but does not update the history. 93 | Use `update` instead. 94 | """ 95 | self._log.info('capture snapshot') 96 | return time.time(), enum_display_devices(), capture_snapshot() 97 | 98 | def get_current_snapshot(self) -> Snapshot: 99 | displays = enum_display_devices() 100 | 101 | def find(): 102 | for ss in self.data: 103 | if ss.phony: 104 | continue 105 | if ss.displays == displays: 106 | return ss 107 | 108 | with self.lock: 109 | snap = find() 110 | if snap is None: 111 | self.update() 112 | snap = find() 113 | return snap 114 | 115 | def get_compatible_snapshots(self, compatible_with: Optional[Snapshot] = None) -> Iterator[Snapshot]: 116 | with self.lock: 117 | if compatible_with is None: 118 | compatible_with = self.get_current_snapshot() 119 | 120 | for snap in self.data: 121 | if snap == compatible_with or not snap.phony: 122 | continue 123 | if not compatible_with.matches_display_config(snap.displays): 124 | continue 125 | yield snap 126 | 127 | def get_rules(self, compatible_with: Optional[Snapshot | Literal[True]] = None, exclusive=False): 128 | with self.lock: 129 | current = self.get_current_snapshot() 130 | if not compatible_with: 131 | return current.rules 132 | 133 | if compatible_with is True: 134 | compatible_with: Snapshot = current 135 | 136 | rules = [] if exclusive else compatible_with.rules.copy() 137 | for snap in self.get_compatible_snapshots(compatible_with): 138 | rules.extend(r for r in snap.rules if r.fits_display_config(compatible_with.displays)) 139 | return rules 140 | 141 | def prune_history(self): 142 | settings = load_json('settings') 143 | with self.lock: 144 | for snapshot in self.data: 145 | if snapshot.phony: 146 | continue 147 | snapshot.cleanup( 148 | prune=settings.get('prune_history', True), 149 | ttl=settings.get('window_history_ttl', 0), 150 | maximum=settings.get('max_snapshots', 10), 151 | ) 152 | 153 | def update(self): 154 | """Captures a new snapshot, updates and prunes the history then saves to disk""" 155 | timestamp, displays, windows = self.capture() 156 | 157 | if not displays: 158 | return 159 | 160 | with self.lock: 161 | wh = WindowHistory(time=timestamp, windows=windows) 162 | for item in self.data: 163 | if item.displays == displays: 164 | # add current config to history 165 | item.history.append(wh) 166 | item.mru = None 167 | break 168 | else: 169 | self.data.append(Snapshot(displays=displays, history=[wh])) 170 | 171 | self.prune_history() 172 | 173 | self.save() 174 | 175 | 176 | class SnapshotService(Service): 177 | def _runner(self, snapshot: SnapshotFile): 178 | count = 0 179 | settings = load_json('settings') 180 | while not self._kill_signal.is_set(): 181 | if not settings.get('pause_snapshots', False): 182 | snapshot.update() 183 | count += 1 184 | 185 | if count >= settings.get('save_freq', 1): 186 | snapshot.save() 187 | count = 0 188 | 189 | sleep_start = time.time() 190 | while time.time() - sleep_start < settings.get('snapshot_freq', 30): 191 | time.sleep(0.5) 192 | if self._kill_signal.is_set(): 193 | return 194 | -------------------------------------------------------------------------------- /src/win32_extras.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Module for wrangling additional functions out of Windows that the `win32api` family of packages doesn't expose. 3 | ''' 4 | import ctypes 5 | from ctypes.wintypes import HWND, DWORD, RECT 6 | 7 | 8 | dwmapi = ctypes.WinDLL('dwmapi') 9 | 10 | def DwmGetWindowAttribute(hwnd: int, attr: int): 11 | ''' 12 | Exposes the `dwmapi.DwmGetWindowAttribute` function but takes care of the ctypes noise. 13 | 14 | See: https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/nf-dwmapi-dwmgetwindowattribute 15 | ''' 16 | rect = RECT() 17 | dwmapi.DwmGetWindowAttribute(HWND(hwnd), DWORD(attr), ctypes.byref(rect), ctypes.sizeof(rect)) 18 | return rect 19 | 20 | 21 | shcore = ctypes.WinDLL('shcore') 22 | 23 | 24 | def GetDpiForMonitor(monitor: int) -> int: 25 | ''' 26 | Exposes the `shcore.GetDpiForMonitor` function but takes care of the ctypes noise. 27 | 28 | See: https://learn.microsoft.com/en-gb/windows/win32/api/shellscalingapi/nf-shellscalingapi-getdpiformonitor 29 | ''' 30 | dpi_x = ctypes.c_uint() 31 | shcore.GetDpiForMonitor(monitor, 0, ctypes.byref(dpi_x), ctypes.byref(ctypes.c_uint())) # MDT_EFFECTIVE_DPI 32 | # from MSDocs: "The values of *dpiX and *dpiY are identical" 33 | return dpi_x.value 34 | 35 | 36 | __all__ = ['DwmGetWindowAttribute', 'GetDpiForMonitor'] 37 | -------------------------------------------------------------------------------- /src/window.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import ctypes.wintypes 3 | import logging 4 | import time 5 | from typing import Iterator, Optional 6 | 7 | import pyvda 8 | import pywintypes 9 | import win32con 10 | import win32gui 11 | from comtypes import GUID 12 | 13 | from common import Rule, Window, load_json, match 14 | from services import Service 15 | 16 | log = logging.getLogger(__name__) 17 | 18 | 19 | class TitleBarInfo(ctypes.Structure): 20 | _fields_ = [ 21 | ('cbSize', ctypes.wintypes.DWORD), 22 | ('rcTitleBar', ctypes.wintypes.RECT), 23 | ('rgState', ctypes.wintypes.DWORD * 6), 24 | ] 25 | 26 | 27 | class WindowSpawnService(Service): 28 | def _runner(self): 29 | def get_windows() -> dict[int, bool]: 30 | def fill(h, *_): 31 | resizable = win32gui.GetWindowLong(h, win32con.GWL_STYLE) & win32con.WS_THICKFRAME 32 | # if we've already seen this window and it hasn't changed its resizability status 33 | if h in old and old[h] == resizable: 34 | return 35 | hwnds[h] = resizable 36 | 37 | hwnds = {} 38 | win32gui.EnumWindows(fill, None) 39 | return hwnds 40 | 41 | def window_valid(hwnd): 42 | return is_window_valid(hwnd) and ( 43 | not settings.get('on_window_spawn', {}).get('skip_non_resizable', False) 44 | or win32gui.GetWindowLong(hwnd, win32con.GWL_STYLE) & win32con.WS_THICKFRAME 45 | ) 46 | 47 | settings = load_json('settings') 48 | # quickly set `old` before calling `get_windows` because it relies on `old` being defined 49 | old = {} 50 | old.update(get_windows()) 51 | while not self._kill_signal.wait(timeout=0.1): 52 | if not settings.get('on_window_spawn', {}).get('enabled', False): 53 | time.sleep(1) 54 | continue 55 | new = get_windows() 56 | if new: 57 | # wait for window to load in before checking validity 58 | time.sleep(0.1) 59 | try: 60 | windows = [Window.from_hwnd(h) for h in new if window_valid(h)] 61 | except Exception: 62 | self.log.info('failed to get list of newly spawned windows') 63 | else: 64 | if windows: 65 | try: 66 | self._run_callback('default', windows) 67 | except Exception: 68 | self.log.exception('failed to run callback on new window spawn') 69 | old.update(new) 70 | old = {h: r for h, r in old.items() if is_window_valid(h)} 71 | 72 | 73 | def is_window_cloaked(hwnd) -> bool: 74 | # https://stackoverflow.com/a/64597308 75 | # https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/nf-dwmapi-dwmgetwindowattribute 76 | # https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute 77 | cloaked = ctypes.c_int(0) 78 | ctypes.windll.dwmapi.DwmGetWindowAttribute(hwnd, 14, ctypes.byref(cloaked), ctypes.sizeof(cloaked)) 79 | if cloaked.value != 0: 80 | try: 81 | # this seems to do a pretty decent job catching all cloaked windows 82 | # whilst allowing windows on other v_desktops 83 | app_view = pyvda.AppView(hwnd=hwnd) 84 | if app_view.desktop_id == GUID(): # GUID({"00000000..."}) 85 | return True 86 | assert app_view.desktop.number > 0 87 | except Exception: 88 | return True 89 | return False 90 | 91 | 92 | def is_window_valid(hwnd: int) -> bool: 93 | if not win32gui.IsWindow(hwnd): 94 | return False 95 | if not win32gui.IsWindowVisible(hwnd): 96 | return False 97 | if not win32gui.GetWindowText(hwnd): 98 | return False 99 | if win32gui.GetWindowRect(hwnd) == (0, 0, 0, 0): 100 | return False 101 | if is_window_cloaked(hwnd): 102 | return False 103 | 104 | titlebar = TitleBarInfo() 105 | titlebar.cbSize = ctypes.sizeof(titlebar) 106 | ctypes.windll.user32.GetTitleBarInfo(hwnd, ctypes.byref(titlebar)) 107 | return not titlebar.rgState[0] & win32con.STATE_SYSTEM_INVISIBLE 108 | 109 | 110 | def capture_snapshot() -> list[Window]: 111 | def callback(hwnd, *_): 112 | if is_window_valid(hwnd): 113 | try: 114 | snapshot.append(Window.from_hwnd(hwnd)) 115 | except pywintypes.error: 116 | log.error(f'could not load window info for hwnd: {hwnd}') 117 | 118 | snapshot: list[Window] = [] 119 | win32gui.EnumWindows(callback, None) 120 | return snapshot 121 | 122 | 123 | def find_matching_rules(rules: list[Rule], window: Window) -> Iterator[Rule]: 124 | matching: list[tuple[int, Rule]] = [] 125 | for rule in rules: 126 | points = 0 127 | for attr in ('name', 'executable'): 128 | rv = getattr(rule, attr) 129 | wv = getattr(window, attr) 130 | p = match(rv, wv) 131 | if not p: 132 | break 133 | if rv: 134 | points += p 135 | else: 136 | matching.append((points, rule)) 137 | return (i[1] for i in sorted(matching, reverse=True, key=lambda m: m[0])) 138 | 139 | 140 | def apply_rules(rules: list[Rule], window: Window) -> bool: 141 | """ 142 | Returns: 143 | whether any rules were applied 144 | """ 145 | matching = list(find_matching_rules(rules, window)) 146 | for rule in matching: 147 | window.set_pos(rule.rect, rule.placement) 148 | return len(matching) > 0 149 | 150 | 151 | def restore_snapshot(snap: list[Window], rules: Optional[list[Rule]] = None): 152 | def callback(hwnd, extra): 153 | if not is_window_valid(hwnd): 154 | return 155 | 156 | window = Window.from_hwnd(hwnd) 157 | for item in snap: 158 | if item.rect == (0, 0, 0, 0): 159 | return 160 | 161 | if hwnd != item.id: 162 | continue 163 | 164 | if window.rect == item.rect: 165 | return 166 | 167 | try: 168 | placement = item.placement 169 | except KeyError: 170 | placement = None 171 | 172 | log.info(f'restore window "{window.name}" {window.rect} -> {item.rect}') 173 | window.set_pos(item.rect, placement) 174 | return 175 | else: 176 | if not rules: 177 | return 178 | for rule in find_matching_rules(rules, window): 179 | log.info(f'apply rule "{rule.rule_name}" to "{window.name}"') 180 | window.set_pos(rule.rect, rule.placement) 181 | 182 | win32gui.EnumWindows(callback, None) 183 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Crozzers/RestoreWindowPos/b81227e4f32cc80436ee679e10e9d325ec7f733f/test/__init__.py -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains sample data used for testing. Much of the sample data is 3 | split for different display configurations, but grouped by numbering. 4 | 5 | For example, `DISPLAYS1`, `WINDOWS1` and `RULES1` all go together to form a 6 | cohesive snapshot, where the windows fit in the display and the rules match 7 | the windows. 8 | 9 | `(DISPLAYS|WINDOWS|RULES)2` are grouped similarly, but aren't compatible with 10 | group 1. 11 | 12 | The generic lists (`DISPLAYS`, `RULES`, etc...) simply combine all groups into 13 | a single list 14 | """ 15 | import os 16 | import sys 17 | from copy import deepcopy 18 | from pathlib import Path 19 | 20 | import pytest 21 | 22 | if os.getenv('GITHUB_ACTIONS') == 'true': 23 | # mock pyvda since it's not supported in windows server/GH actions runners 24 | sys.modules['pyvda'] = type(os)('pyvda') 25 | 26 | sys.path.insert(0, str((Path(__file__).parent / '../').resolve())) 27 | # allow internal imports like win32_extras 28 | sys.path.insert(0, str((Path(__file__).parent / '../src').resolve())) 29 | from src import common # noqa:E402 30 | 31 | 32 | DISPLAYS1 = [ 33 | { 34 | 'uid': 'UID11111', 35 | 'name': 'Display1', 36 | 'resolution': [2560, 1440], 37 | 'rect': [0, 0, 2560, 1440], 38 | 'comparison_params': {}, 39 | } 40 | ] 41 | DISPLAYS2 = [ 42 | { 43 | 'uid': 'UID22222', 44 | 'name': 'Display2', 45 | 'resolution': [1920, 1080], 46 | 'rect': [-1920, 0, 0, 1080], 47 | 'comparison_params': {}, 48 | } 49 | ] 50 | DISPLAYS = DISPLAYS1 + DISPLAYS2 51 | 52 | 53 | @pytest.fixture(params=DISPLAYS) 54 | def display_json(request: pytest.FixtureRequest): 55 | return request.param 56 | 57 | 58 | @pytest.fixture 59 | def display_cls(display_json): 60 | return common.Display.from_json(display_json) 61 | 62 | 63 | @pytest.fixture 64 | def displays(): 65 | return [common.Display.from_json(d) for d in DISPLAYS] 66 | 67 | 68 | # windows that match DISPLAYS1 69 | WINDOWS1 = [ 70 | { 71 | 'size': [2560, 1440], 72 | 'rect': [-8, -8, 2568, 1448], # mimic maximised window 73 | 'placement': [2, 3, [-1, -1], [-1, -1], [-8, -8, 2568, 1448]], 74 | 'id': 1, 75 | 'name': 'Maximised window 1', 76 | 'executable': 'C:\\Program Files\\MyProgram1\\maximised.exe', 77 | }, 78 | { 79 | # minimised windows often have wonky size and rect 80 | 'size': [160, 28], 81 | 'rect': [-32000, -32000, -31840, -31972], 82 | 'placement': [2, 2, [-32000, -32000], [-1, -1], [19, 203, 1239, 864]], 83 | 'id': 2, 84 | 'name': 'Minimised window 1', 85 | 'executable': 'C:\\Program Files\\MyProgram1\\minimised.exe', 86 | }, 87 | { 88 | 'size': [300, 200], 89 | 'rect': [100, 100, 400, 300], 90 | 'placement': [0, 1, [-1, -1], [-1, -1], [100, 100, 400, 300]], 91 | 'id': 3, 92 | 'name': 'Floating window 1', 93 | 'executable': 'C:\\Program Files\\MyProgram1\\floating.exe', 94 | }, 95 | { 96 | 'size': [1290, 1405], 97 | 'rect': [-5, 0, 1285, 1405], 98 | 'placement': [0, 1, [-1, -1], [-1, -1], [-5, 0, 1285, 1405]], 99 | 'id': 4, 100 | 'name': 'Snapped LHS window 1', # left hand side 101 | 'executable': 'C:\\Program Files\\MyProgram1\\snapped-lhs.exe', 102 | }, 103 | { 104 | 'size': [1290, 705], 105 | 'rect': [1275, 0, 2565, 705], 106 | 'placement': [0, 1, [-1, -1], [-1, -1], [1275, 0, 2565, 705]], 107 | 'id': 5, 108 | 'name': 'Snapped RUQ window 1', # right upper quarter 109 | 'executable': 'C:\\Program Files\\MyProgram1\\snapped-ruq.exe', 110 | }, 111 | ] 112 | # windows that match DISPLAYS2 113 | WINDOWS2 = [ 114 | { 115 | 'size': [1920, 1080], 116 | 'rect': [-1928, -8, 8, 1088], # mimic maximised window 117 | 'placement': [2, 3, [-1, -1], [-1, -1], [-1500, 100, -150, 600]], 118 | 'id': 1, 119 | 'name': 'Maximised window 2', 120 | 'executable': 'C:\\Program Files\\MyProgram2\\maximised.exe', 121 | }, 122 | { 123 | # minimised windows often have wonky size and rects 124 | 'size': [160, 28], 125 | 'rect': [-32000, -32000, -31840, -31972], 126 | 'placement': [2, 2, [-32000, -32000], [-1, -1], [-1549, 55, -579, 693]], 127 | 'id': 2, 128 | 'name': 'Minimised window 2', 129 | 'executable': 'C:\\Program Files\\MyProgram2\\minimised.exe', 130 | }, 131 | { 132 | 'size': [300, 200], 133 | 'rect': [-1500, 100, -1300, 200], 134 | 'placement': [0, 1, [-1, -1], [-1, -1], [100, 100, 400, 300]], 135 | 'id': 3, 136 | 'name': 'Floating window 2', 137 | 'executable': 'C:\\Program Files\\MyProgram2\\floating.exe', 138 | }, 139 | { 140 | 'size': [970, 1045], 141 | 'rect': [-1925, 0, -955, 1045], 142 | 'placement': [2, 1, [-32000, -32000], [-1, -1], [-2403, 548, -1433, 1186]], 143 | 'id': 4, 144 | 'name': 'Snapped LHS window 2', # left hand side 145 | 'executable': 'C:\\Program Files\\MyProgram2\\snapped-lhs.exe', 146 | }, 147 | { 148 | 'size': [970, 520], 149 | 'rect': [-965, 0, 5, 520], 150 | 'placement': [2, 1, [-32000, -32000], [-1, -1], [-443, 25, 527, 663]], 151 | 'id': 5, 152 | 'name': 'Snapped RUQ window 2', # right upper quarter 153 | 'executable': 'C:\\Program Files\\MyProgram2\\snapped-ruq.exe', 154 | }, 155 | ] 156 | WINDOWS = WINDOWS1 + WINDOWS2 157 | assert len(WINDOWS1) == len(WINDOWS2), 'should be same number of windows per config' 158 | 159 | 160 | @pytest.fixture(params=WINDOWS) 161 | def window_json(request: pytest.FixtureRequest): 162 | return request.param 163 | 164 | 165 | @pytest.fixture() 166 | def window_cls(window_json): 167 | return common.Window.from_json(window_json) 168 | 169 | 170 | RULES1 = [] 171 | RULES2 = [] 172 | 173 | 174 | def populate_rules(): 175 | for w_list, r_list in zip((WINDOWS1, WINDOWS2), (RULES1, RULES2)): 176 | for window in w_list: 177 | rule = deepcopy(window) 178 | del rule['id'] 179 | rule['rule_name'] = rule['name'].replace('window', 'rule').strip() 180 | rule['name'] = rule['name'].lower().replace('window ', '').strip() 181 | r_list.append(rule) 182 | 183 | 184 | # saves cleaning up all the loop vars 185 | populate_rules() 186 | del populate_rules 187 | RULES = RULES1 + RULES2 188 | assert len(RULES1) == len(RULES2), 'should be same number of rules per config' 189 | 190 | 191 | @pytest.fixture(params=RULES) 192 | def rule_json(request: pytest.FixtureRequest): 193 | return request.param 194 | 195 | 196 | @pytest.fixture 197 | def rule_cls(rule_json): 198 | return common.Rule.from_json(rule_json) 199 | 200 | 201 | @pytest.fixture(params=((DISPLAYS1, WINDOWS1, RULES1), (DISPLAYS2, WINDOWS2, RULES2), (DISPLAYS, WINDOWS, RULES))) 202 | def snapshot_json(request: pytest.FixtureRequest): 203 | """ 204 | Combines `DISPLAYS*` and both `WINDOWS*` and `RULES*` lists 205 | """ 206 | displays, windows, rules = request.param 207 | return { 208 | 'displays': deepcopy(displays), 209 | 'history': [{'time': 1677924200, 'windows': deepcopy(windows)}], 210 | 'mru': None, 211 | 'rules': deepcopy(rules), 212 | 'phony': '', 213 | } 214 | 215 | 216 | @pytest.fixture 217 | def snapshot_cls(snapshot_json) -> common.Snapshot: 218 | return common.Snapshot.from_json(snapshot_json) 219 | 220 | 221 | @pytest.fixture 222 | def snapshots() -> list[common.Snapshot]: 223 | snapshots = [] 224 | for d, w, r in ((DISPLAYS1, WINDOWS1, RULES1), (DISPLAYS2, WINDOWS2, RULES2), (DISPLAYS, WINDOWS, RULES)): 225 | snapshots.append( 226 | common.Snapshot.from_json( 227 | { 228 | 'displays': deepcopy(d), 229 | 'history': [{'time': 1677924200, 'windows': deepcopy(w)}], 230 | 'mru': None, 231 | 'rules': deepcopy(r), 232 | 'phony': '', 233 | } 234 | ) 235 | ) 236 | return snapshots 237 | -------------------------------------------------------------------------------- /test/test_common.py: -------------------------------------------------------------------------------- 1 | import operator 2 | import re 3 | import sys 4 | import types 5 | import typing 6 | from collections.abc import Iterable 7 | from copy import deepcopy 8 | from dataclasses import dataclass, is_dataclass 9 | from pathlib import Path 10 | from unittest.mock import Mock, patch 11 | 12 | from pytest_mock import MockerFixture 13 | import win32gui 14 | from test.conftest import DISPLAYS1, DISPLAYS2, RULES1, RULES2, WINDOWS1, WINDOWS2 15 | 16 | import pytest 17 | from pytest import MonkeyPatch 18 | 19 | sys.path.insert(0, str((Path(__file__).parent / '../').resolve())) 20 | from src import common # noqa:E402 21 | from src.common import Display, Rect, Rule, Snapshot, Window, WindowType # noqa:E402 22 | 23 | 24 | def test_local_path(monkeypatch: MonkeyPatch): 25 | def lp(*a, **kw): 26 | return Path(common.local_path(*a, **kw)) 27 | 28 | base_dir = Path(__file__).parent.parent 29 | assert lp('./') == base_dir 30 | assert lp('./test') == base_dir / 'test' 31 | assert lp('./../') == (base_dir / '..').resolve() 32 | 33 | monkeypatch.setattr(sys, 'frozen', True, raising=False) 34 | monkeypatch.setattr(sys, '_MEIPASS', str(base_dir / 'test'), raising=False) 35 | 36 | assert lp('./') == Path(sys.executable).parent, 'base path should be executable when frozen' 37 | assert lp('./', asset=True) == base_dir / 'test' 38 | 39 | 40 | def test_single_call(): 41 | var = 0 42 | 43 | @common.single_call 44 | def increment(): 45 | nonlocal var 46 | var += 1 47 | 48 | assert var == 0 49 | increment() 50 | assert var == 1, 'should increment counter on first call' 51 | increment() 52 | assert var == 1, 'should not increment counter after first call' 53 | 54 | 55 | @pytest.mark.parametrize('rect', ((0, 0, 1920, 1080), (-1920, 1080, 2160, 1440))) 56 | def test_size_from_rect(rect: Rect): 57 | size = common.size_from_rect(rect) 58 | assert isinstance(size, tuple) 59 | assert size[0] == rect[2] - rect[0] 60 | assert size[1] == rect[3] - rect[1] 61 | 62 | 63 | def test_reverse_dict_lookup(): 64 | d1 = {'abc': '123'} 65 | assert common.reverse_dict_lookup(d1, '123') == 'abc' 66 | 67 | 68 | def test_match(): 69 | from src.common import match 70 | 71 | case = 'returns 1 when any param is None' 72 | assert match(0, None) == 1, case 73 | assert match(None, 0) == 1, case 74 | assert match(None, None) == 1, 'returns 1 when both params are None' 75 | 76 | case = 'returns 2 when both params are equal' 77 | assert match(123, 123) == 2, case 78 | assert match('456', '456') == 2, case 79 | 80 | case = 'returns 2 on absolute integer match' 81 | assert match(-123, 123) == 2, case 82 | assert match(-0, 0) == 2, case 83 | assert match(123, 456) == 0, 'returns 0 on integer mismatch' 84 | 85 | case = 'returns 1 on regex match' 86 | assert match(r'[a-c]{3}', 'abc') == 1, case 87 | assert match(r'[A-C]{3}', 'abc') == 1, case 88 | assert match(r'[A-C]{5}', 'abc') == 0, 'returns 0 on regex mismatch' 89 | 90 | case = 'returns 0 on regex compile error' 91 | regex = r'(\w{3}\)' 92 | with pytest.raises(re.error): 93 | # check that our regex DOES indeed raise an error 94 | re.compile(regex) 95 | assert match(regex, 'abc') == 0, 'returns 0 on regex error' 96 | 97 | 98 | class TestStrToOp: 99 | valid_ops = {'lt': operator.lt, 'le': operator.le, 'eq': operator.eq, 'ge': operator.ge, 'gt': operator.gt} 100 | 101 | @pytest.mark.parametrize('name,func', valid_ops.items()) 102 | def test_valid(self, name, func): 103 | assert common.str_to_op(name) is func 104 | 105 | @pytest.mark.parametrize( 106 | 'name', 107 | (i for i in dir(operator) if i not in TestStrToOp.valid_ops), # noqa: F821 # type: ignore 108 | ) 109 | def test_invalid(self, name): 110 | with pytest.raises(ValueError): 111 | common.str_to_op(name) 112 | 113 | 114 | @pytest.mark.parametrize( 115 | 'input,expected', 116 | (([1, 2, 3], (1, 2, 3)), ([1, [2, 3]], (1, (2, 3))), ([1, [2, [3, [4, 5], 6]]], (1, (2, (3, (4, 5), 6))))), 117 | ) 118 | def test_tuple_convert(input, expected: tuple): 119 | from src.common import tuple_convert 120 | 121 | assert tuple_convert(input) == expected 122 | assert tuple_convert(expected, from_=tuple, to=list) == input 123 | 124 | 125 | def recursive_type_check(field, value, v_type): 126 | sub_types = typing.get_args(v_type) 127 | if not sub_types: 128 | # no parameterized types, eg: int or str 129 | assert isinstance(value, v_type) 130 | return 131 | 132 | # get the original type from the generic 133 | # tuple[int, int] -> tuple 134 | o_type = typing.get_origin(v_type) 135 | if issubclass(o_type, types.UnionType): 136 | assert isinstance(value, sub_types) 137 | return 138 | else: 139 | assert isinstance(value, o_type) 140 | 141 | if field != 'comparison_params': 142 | # some typing guides for better dataclasses 143 | assert o_type != dict, 'use a dataclass instead of dict' 144 | if o_type == list: 145 | assert len(sub_types) == 1, 'lists should be homogeneous' 146 | 147 | # sub_types is truthy, so value must be iterable 148 | # check each item matches it's corresponding sub_type 149 | for index, item in enumerate(value): 150 | # lists are homogeneous, others are positional 151 | sub = sub_types[0 if o_type == list else index] 152 | # get origin of sub_type in case of nested param generics 153 | o_sub = typing.get_origin(sub) or sub 154 | if issubclass(o_sub, Iterable): 155 | recursive_type_check(field, item, sub) 156 | else: 157 | assert isinstance(item, sub) 158 | 159 | 160 | class TestJSONType: 161 | @pytest.fixture 162 | def klass(self): 163 | @dataclass 164 | class Sample(common.JSONType): 165 | a: int 166 | b: tuple[int, str, bool] 167 | 168 | return Sample 169 | 170 | @pytest.fixture( 171 | params=[{'a': 1, 'b': (2, '3', False)}, {'a': '4', 'b': [5, '6', True]}, {'a': 7, 'b': ['8', 9, 10]}], 172 | ids=['standard', 'compliant-types', 'tuple-sub-types'], 173 | ) 174 | def sample_json(self, request): 175 | return request.param 176 | 177 | class TestFromJson: 178 | def test_basic(self, klass: common.JSONType, sample_json): 179 | base = klass.from_json(sample_json) 180 | # check base was initialized correctly 181 | assert is_dataclass(base) 182 | assert isinstance(base, klass) 183 | 184 | # check each field 185 | hints = typing.get_type_hints(klass) 186 | for prop, p_type in hints.items(): 187 | assert hasattr(base, prop) 188 | value = getattr(base, prop) 189 | 190 | recursive_type_check(prop, value, p_type) 191 | 192 | def test_invalid(self, klass: common.JSONType): 193 | assert klass.from_json({}) is None 194 | 195 | def test_ignores_extra_info(self, klass: common.JSONType, sample_json): 196 | instance = klass.from_json({**sample_json, 'something': 'else'}) 197 | assert not hasattr(instance, 'something') 198 | 199 | 200 | # includes `Window` type, since they are pretty much the same 201 | class TestWindowType(TestJSONType): 202 | @pytest.fixture(params=[WindowType, Window]) 203 | def klass(self, request): 204 | return request.param 205 | 206 | @pytest.fixture 207 | def sample_json(self, window_json): 208 | return window_json 209 | 210 | @pytest.fixture 211 | def sample_cls(self, window_cls): 212 | return window_cls 213 | 214 | def test_fits_display(self, klass: WindowType, mocker: MockerFixture, sample_json, display_json, expected=None): 215 | if expected is None: 216 | expected = (sample_json in WINDOWS1 and display_json in DISPLAYS1) or ( 217 | sample_json in WINDOWS2 and display_json in DISPLAYS2 218 | ) 219 | instance = klass.from_json(sample_json) 220 | mocker.patch.object(instance, 'get_border_and_shadow_thickness', Mock(spec=True, return_value=8)) 221 | display_json = Display.from_json(display_json) 222 | assert instance.fits_display(display_json) is expected 223 | 224 | def test_fits_display_config(self, sample_cls: WindowType, mocker: MockerFixture, displays: list[Display]): 225 | mocker.patch.object(sample_cls, 'get_border_and_shadow_thickness', Mock(spec=True, return_value=8)) 226 | assert sample_cls.fits_display_config(displays) is True 227 | 228 | 229 | class TestRule(TestWindowType): 230 | @pytest.fixture 231 | def klass(self): 232 | return Rule 233 | 234 | @pytest.fixture 235 | def sample_json(self, rule_json): 236 | return rule_json 237 | 238 | @pytest.fixture 239 | def sample_cls(self, rule_cls): 240 | return rule_cls 241 | 242 | def test_post_init(self, klass: Rule): 243 | rule = deepcopy(RULES1[0]) 244 | del rule['rule_name'] 245 | instance = klass.from_json(rule) 246 | assert instance.name is not None 247 | assert isinstance(instance.name, str) 248 | 249 | def test_fits_display(self, klass: Rule, mocker: MockerFixture, sample_json, display_json): 250 | expected = (sample_json in RULES1 and display_json in DISPLAYS1) or ( 251 | sample_json in RULES2 and display_json in DISPLAYS2 252 | ) 253 | return super().test_fits_display(klass, mocker, sample_json, display_json, expected) 254 | 255 | 256 | class TestSnapshot(TestJSONType): 257 | @pytest.fixture 258 | def klass(self): 259 | return Snapshot 260 | 261 | @pytest.fixture 262 | def sample_json(self, snapshot_json): 263 | return snapshot_json 264 | 265 | @pytest.fixture 266 | def sample_cls(self, snapshot_cls): 267 | return snapshot_cls 268 | 269 | class TestLastKnownProcessInstance: 270 | def test_basic(self, snapshots: list[Snapshot]): 271 | window = snapshots[0].history[-1].windows[0] 272 | assert snapshots[0].last_known_process_instance(window) is window 273 | 274 | def test_returns_most_recent_window(self, snapshots: list[Snapshot]): 275 | snapshot = deepcopy(snapshots[0]) 276 | snapshot.history.append(deepcopy(snapshot.history[0])) 277 | 278 | window = snapshot.history[-1].windows[0] 279 | other_window = snapshot.history[0].windows[0] 280 | lkp = snapshot.last_known_process_instance(window) 281 | 282 | assert lkp is window 283 | assert lkp is not other_window 284 | 285 | def test_returns_none_if_window_not_found(self, snapshots: list[Snapshot]): 286 | window = deepcopy(snapshots[0].history[0].windows[0]) 287 | window.executable = 'does-not-exist.exe' 288 | assert snapshots[0].last_known_process_instance(window) is None 289 | 290 | class TestMatchTitleKwarg: 291 | @pytest.fixture 292 | def sample(self) -> Snapshot: 293 | snap = Snapshot.from_json( 294 | { 295 | 'history': [ 296 | { 297 | 'time': 0, 298 | 'windows': [ 299 | { 300 | **WINDOWS1[0], 301 | 'name': 'Some Other Website - Web Browser', 302 | 'executable': 'browser.exe', 303 | }, 304 | {**WINDOWS1[1], 'name': '12 Reminder(s)', 'executable': 'email.exe'}, 305 | ], 306 | }, 307 | { 308 | 'time': 1, 309 | 'windows': [ 310 | { 311 | **WINDOWS1[0], 312 | 'name': 'Some Other Website - Web Browser', 313 | 'executable': 'browser.exe', 314 | }, 315 | {**WINDOWS1[2], 'name': 'My Website - Web Browser', 'executable': 'browser.exe'}, 316 | {**WINDOWS1[3], 'name': 'Appointment - Email Client', 'executable': 'email.exe'}, 317 | {**WINDOWS1[4], 'name': 'Inbox - Email Client', 'executable': 'email.exe'}, 318 | ], 319 | }, 320 | ] 321 | } 322 | ) 323 | assert snap is not None 324 | return snap 325 | 326 | @pytest.mark.parametrize('title', ['12 Reminder(s)', '1 Reminder(s)', 'Email Reminder(s)']) 327 | def test_returns_high_overlap_title_matches(self, sample: Snapshot, title: str): 328 | window = deepcopy(sample.history[0].windows[0]) 329 | window.executable = 'email.exe' 330 | window.name = title 331 | lkp = sample.last_known_process_instance(window, match_title=True) 332 | assert lkp is not None 333 | assert lkp.id == WINDOWS1[1]['id'] 334 | 335 | def test_still_filters_by_process(self, sample: Snapshot): 336 | window = deepcopy(sample.history[0].windows[0]) 337 | window.executable = 'doodad.exe' 338 | window.name = '12 Reminder(s)' 339 | lkp = sample.last_known_process_instance(window, match_title=True) 340 | assert lkp is None 341 | 342 | class TestMatchResizabilityKwarg: 343 | @pytest.fixture 344 | def sample(self) -> Snapshot: 345 | snap = Snapshot.from_json( 346 | { 347 | 'history': [ 348 | { 349 | 'time': 0, 350 | 'windows': [{**WINDOWS1[0], 'name': 'My Document - My Program', 'resizable': True}], 351 | }, 352 | { 353 | 'time': 1, 354 | 'windows': [{**WINDOWS1[0], 'name': 'Splash Screen - My Program', 'resizable': False}], 355 | }, 356 | ] 357 | } 358 | ) 359 | assert snap is not None 360 | return snap 361 | 362 | def test_returns_windows_with_same_resizability(self, sample: Snapshot): 363 | window = deepcopy(sample.history[0].windows[0]) 364 | window.resizable = False 365 | lkp = sample.last_known_process_instance(window, match_resizability=True) 366 | assert lkp is not None 367 | assert 'Splash Screen' in lkp.name 368 | window.resizable = True 369 | lkp2 = sample.last_known_process_instance(window, match_resizability=True) 370 | assert lkp2 is not None 371 | assert 'Splash Screen' not in lkp2.name 372 | 373 | class TestMatchesDisplayConfig: 374 | def test_basic(self, snapshots: list[Snapshot]): 375 | assert snapshots[0].matches_display_config(snapshots[2]) is True 376 | assert snapshots[0].matches_display_config(snapshots[1]) is False 377 | 378 | def test_config_param_types(self, snapshot_cls: Snapshot): 379 | assert snapshot_cls.matches_display_config(snapshot_cls) is True 380 | assert snapshot_cls.matches_display_config(snapshot_cls.displays) is True 381 | 382 | @pytest.mark.parametrize('param,expected', (('any', True), ('all', False))) 383 | def test_comparison_params(self, snapshots: list[Snapshot], param, expected): 384 | snapshots[2].comparison_params['displays'] = param 385 | assert snapshots[2].matches_display_config(snapshots[0]) is expected 386 | 387 | class TestSquashHistory: 388 | @pytest.fixture 389 | def squashable(self) -> Snapshot: 390 | # create copy of WINDOWS2 but with different hwnds because history squash 391 | # uses identical hwnds as factor in deciding which windows to squash 392 | windows2 = [] 393 | for i, window in enumerate(deepcopy(WINDOWS2)): 394 | window['id'] = i + len(WINDOWS1) 395 | windows2.append(window) 396 | snap = Snapshot.from_json( 397 | {'history': [{'time': 0, 'windows': WINDOWS1[1:-1]}, {'time': 0, 'windows': WINDOWS1}]} 398 | ) 399 | assert snap is not None 400 | return snap 401 | 402 | def test_previous_frames_that_overlap_are_removed(self, squashable: Snapshot): 403 | lesser, greater = squashable.history 404 | with patch.object(win32gui, 'IsWindow', Mock(return_value=1)): 405 | squashable.squash_history() 406 | assert greater in squashable.history 407 | assert lesser not in squashable.history 408 | 409 | def test_newer_frames_that_overlap_are_removed(self, squashable: Snapshot): 410 | lesser, greater = squashable.history 411 | squashable.history = list(reversed(squashable.history)) 412 | with patch.object(win32gui, 'IsWindow', Mock(return_value=1)): 413 | squashable.squash_history(False) 414 | assert greater in squashable.history 415 | assert lesser not in squashable.history 416 | 417 | def test_pruning(self, squashable: Snapshot): 418 | lesser, greater = squashable.history 419 | squashable.history = list(reversed(squashable.history)) 420 | with patch.object(win32gui, 'IsWindow', Mock(return_value=1)): 421 | squashable.squash_history() 422 | assert len(squashable.history) == 1 423 | assert greater == lesser, 'greater should have had dead windows pruned' 424 | -------------------------------------------------------------------------------- /test/test_window.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import sys 3 | from pathlib import Path 4 | 5 | import pytest 6 | import win32con 7 | from pytest_mock import MockerFixture 8 | 9 | sys.path.insert(0, str((Path(__file__).parent / '../src').resolve())) 10 | from src import common, window # noqa:E402 11 | 12 | 13 | class TestIsWindowValid: 14 | @pytest.fixture 15 | def mock_checks(self, mocker: MockerFixture, request: pytest.FixtureRequest): 16 | patches = { 17 | 'win32gui.IsWindow': True, 18 | 'win32gui.IsWindowVisible': True, 19 | 'win32gui.GetWindowText': 'abc', 20 | 'win32gui.GetWindowRect': (1, 2, 3, 4), 21 | # need full "module" name for mock to work 22 | 'src.window.is_window_cloaked': False, 23 | } 24 | for name, ret_val in patches.items(): 25 | mocker.patch(name, return_value=ret_val) 26 | return patches 27 | 28 | def test_basic_success(self, mock_checks): 29 | assert window.is_window_valid(1234) is True 30 | 31 | @pytest.mark.parametrize( 32 | 'index,value', 33 | ((0, False), (1, False), (2, ''), (3, (0, 0, 0, 0)), (4, True)), 34 | ids=['is-not-window', 'window-not-visible', 'empty-title-bar', 'zeroed-rect', 'cloaked-window'], 35 | ) 36 | def test_basic_rejections(self, mocker: MockerFixture, mock_checks: dict, index: int, value): 37 | func_name = tuple(mock_checks.keys())[index] 38 | mocker.patch(func_name, return_value=value) 39 | assert window.is_window_valid(1234) is False 40 | 41 | @pytest.mark.parametrize( 42 | 'state,expected', 43 | ( 44 | (0, True), 45 | (win32con.STATE_SYSTEM_FOCUSABLE, True), 46 | (win32con.STATE_SYSTEM_INVISIBLE, False), 47 | (win32con.STATE_SYSTEM_INVISIBLE | win32con.STATE_SYSTEM_FOCUSABLE, False), 48 | ), 49 | ids=['no-state', 'not-state-invisible', 'state-invisible', 'state-includes-invisible'], 50 | ) 51 | def test_titlebar_rejection(self, mocker: MockerFixture, mock_checks, state, expected): 52 | def fake_titlebar_info(_, titlebar_ref): 53 | titlebar_ref._obj.rgState[0] = state # nonlocal 54 | return 0 55 | 56 | mocker.patch('ctypes.windll.user32.GetTitleBarInfo', new=fake_titlebar_info) 57 | assert window.is_window_valid(1234) is expected 58 | 59 | 60 | class TestFindMatchingRules: 61 | def test_sorting_typeerror(self, rule_cls: common.Rule, window_cls: common.Window): 62 | # copy first rule, which matches first window 63 | rule_cls = [rule_cls, dataclasses.replace(rule_cls)] 64 | # make them slightly unequal so any sort would have to use gt/lt compare 65 | rule_cls[1].rule_name = 'Unnamed rule' 66 | try: 67 | window.find_matching_rules(rule_cls, window_cls) 68 | except TypeError as e: 69 | pytest.fail(f'should not raise {e!r}') 70 | -------------------------------------------------------------------------------- /tools/choco_package.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import shutil 3 | import sys 4 | import os 5 | import hashlib 6 | 7 | sys.path.insert(0, 'src') 8 | from _version import __version__ # noqa:E402 9 | 10 | tools_dir = pathlib.Path('tools') 11 | build_dir = pathlib.Path('build') 12 | build_out = build_dir / 'RestoreWindowPos' 13 | dist_dir = pathlib.Path('dist') 14 | dist_out = dist_dir / 'choco' 15 | installer_src = dist_dir / 'RestoreWindowPos_install.exe' 16 | 17 | installer_hash = hashlib.sha256(open(installer_src, 'rb').read()).hexdigest() 18 | 19 | if build_out.exists(): 20 | shutil.rmtree(build_out) 21 | dist_dir.mkdir(exist_ok=True) 22 | shutil.copytree(tools_dir / 'choco_template', build_out) 23 | 24 | for root, _, files in os.walk(build_out): 25 | for file in files: 26 | if not file.endswith(('.ps1', '.nuspec')): 27 | continue 28 | file = os.path.join(root, file) 29 | with open(file, 'r', encoding='utf-8') as f: 30 | contents = f.read() 31 | with open(file, 'w', encoding='utf-8') as f: 32 | f.write( 33 | contents.replace( 34 | '@@InstallerChecksum@@', installer_hash 35 | ).replace( 36 | '@@PackageVersion@@', __version__ 37 | ) 38 | ) 39 | 40 | 41 | shutil.copyfile(installer_src, 42 | f'{build_out}/tools/RestoreWindowPos_install.exe') 43 | shutil.copyfile('LICENSE', f'{build_out}/tools/LICENSE.txt') 44 | 45 | os.system( 46 | f'choco pack {build_dir}/RestoreWindowPos/restorewindowpos.nuspec --outdir {dist_dir}') 47 | -------------------------------------------------------------------------------- /tools/choco_template/restorewindowpos.nuspec: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @@PackageVersion@@ 6 | 7 | Crozzers 8 | 2024 Crozzers 9 | true 10 | Restore window positions when displays are connected and disconnected 11 | restorewindowpos 12 | Crozzers 13 | RestoreWindowPos (Install) 14 | https://github.com/Crozzers/RestoreWindowPos 15 | https://raw.githubusercontent.com/Crozzers/RestoreWindowPos/main/assets/icon256.png 16 | https://raw.githubusercontent.com/Crozzers/RestoreWindowPos/main/LICENSE 17 | https://github.com/Crozzers/RestoreWindowPos/issues 18 | https://github.com/Crozzers/RestoreWindowPos/tree/main/tools/choco_template 19 | restorewindowpos system-tweak window-management 20 | 21 | Whenever I connect/disconnect a monitor, all of my windows jump around, resize and teleport to places they are not meant to be in. 22 | 23 | This project aims to fix this behaviour by taking regular snapshots of window positions. Once it detects a display being connected/disconnected, it will restore windows to their last known positions on that display. 24 | 25 | You can also define rules for windows with specific titles and/or created by specific programs. Rules will be automatically applied to matching windows that are not part of your current snapshot (eg: windows that have been created since a snapshot was last taken). 26 | You can also give these rules memorable names, and apply any and/or all of them at any time 27 | 28 | Chocolatey packages are auto-generated each release using [GitHub actions](https://github.com/Crozzers/RestoreWindowPos/actions). The packages are then submitted to Chocolatey for review and to be published. This process does take time, so the Chocolatey version of the package may lag behind the latest GitHub release. 29 | 30 | #### Package Parameters 31 | 32 | | Parameter | Descrption | 33 | |----------------------|---------------------------------------------------| 34 | | `/StartAfterInstall` | Launch the program after installation is finished | 35 | | `/DesktopShortcut` | Create a desktop shortcut for the program | 36 | | `/StartMenuShortcut` | Create a start menu shortcut for the program | 37 | 38 | Example: 39 | ``` 40 | choco install restorewindowpos --params '"/StartAfterInstall /DesktopShortcut /StartMenuShortcut"' 41 | ``` 42 | 43 | https://github.com/Crozzers/RestoreWindowPos/releases 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /tools/choco_template/tools/VERIFICATION.txt: -------------------------------------------------------------------------------- 1 | VERIFICATION 2 | Verification is intended to assist the Chocolatey moderators and community 3 | in verifying that this package's contents are trustworthy. 4 | 5 | ### Verifying the downloaded EXE 6 | 7 | You can use the checksum package from chocolatey, or something like Python to get the checksum. 8 | 9 | Using the checksum package: 10 | ``` 11 | choco install checksum 12 | checksum -t sha256 C:/path/to/file.exe 13 | ``` 14 | Using Python: 15 | ``` 16 | import hashlib 17 | print(hashlib.sha256(open('C:/path/to/file.exe', 'rb').read()).hexdigest()) 18 | ``` 19 | 20 | The executable is generated using Pyinstaller and the chocolatey package is built through GitHub Actions. 21 | You can view the build pipelines [here](https://github.com/Crozzers/RestoreWindowPos/actions). 22 | 23 | ### Verifying the software vendor 24 | 25 | This software is available through the official [GitHub page](https://github.com/Crozzers/RestoreWindowPos) 26 | and through this Chocolatey package. If you wish to verify the vendor of the software, send an email 27 | to [the author](mailto:captaincrozzers@gmail.com). 28 | -------------------------------------------------------------------------------- /tools/choco_template/tools/chocolateybeforemodify.ps1: -------------------------------------------------------------------------------- 1 | Write-Information "Attempting to kill any running RestoreWindowPos.exe" 2 | taskkill /IM "RestoreWindowPos.exe" 3 | Write-Information "Sleep 2 seconds to make sure process is shut down correctly" 4 | Start-Sleep 2 5 | -------------------------------------------------------------------------------- /tools/choco_template/tools/chocolateyinstall.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = 'Stop' 2 | $toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)" 3 | $fileLocation = Join-Path $toolsDir 'RestoreWindowPos_install.exe' 4 | 5 | $silentArgs = "/S" 6 | $pp = Get-PackageParameters 7 | if ($pp['StartAfterInstall']) { 8 | $silentArgs += ' /StartAfterInstall' 9 | } 10 | if ($pp['DesktopShortcut']) { 11 | $silentArgs += ' /DesktopShortcut' 12 | } 13 | if ($pp['StartMenuShortcut']) { 14 | $silentArgs += ' /StartMenuShortcut' 15 | } 16 | 17 | $packageArgs = @{ 18 | packageName = $env:ChocolateyPackageName 19 | unzipLocation = $toolsDir 20 | fileType = 'exe' 21 | file = $fileLocation 22 | 23 | softwareName = 'RestoreWindowPos*' 24 | 25 | checksum = '@@InstallerChecksum@@' 26 | checksumType = 'sha256' 27 | 28 | validExitCodes= @(0) 29 | silentArgs = $silentArgs 30 | } 31 | 32 | Install-ChocolateyInstallPackage @packageArgs 33 | -------------------------------------------------------------------------------- /tools/choco_template/tools/chocolateyuninstall.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = 'Stop' 2 | $packageArgs = @{ 3 | packageName = $env:ChocolateyPackageName 4 | softwareName = 'RestoreWindowPos*' 5 | fileType = 'exe' 6 | validExitCodes= @(0) 7 | silentArgs = '/S' 8 | } 9 | 10 | [array]$key = Get-UninstallRegistryKey -SoftwareName $packageArgs['softwareName'] 11 | 12 | if ($key.Count -eq 1) { 13 | $key | % { 14 | $packageArgs['file'] = "$($_.UninstallString)" 15 | Uninstall-ChocolateyPackage @packageArgs 16 | } 17 | } elseif ($key.Count -eq 0) { 18 | Write-Warning "$packageName has already been uninstalled by other means." 19 | } elseif ($key.Count -gt 1) { 20 | Write-Warning "$($key.Count) matches found!" 21 | Write-Warning "To prevent accidental data loss, no programs will be uninstalled." 22 | Write-Warning "Please alert package maintainer the following keys were matched:" 23 | $key | % {Write-Warning "- $($_.DisplayName)"} 24 | } 25 | -------------------------------------------------------------------------------- /tools/compile.bat: -------------------------------------------------------------------------------- 1 | python tools/version_file.py 2 | pyinstaller -w -F --exclude-module numpy --version-file "build/versionfile.txt" --add-data "./assets/icon32.ico;./assets" --add-data "./LICENSE;./" -i "assets/icon256.ico" -n RestoreWindowPos "src/main.py" 3 | makensis "tools/installer.nsi" 4 | python tools\choco_package.py 5 | -------------------------------------------------------------------------------- /tools/format.bat: -------------------------------------------------------------------------------- 1 | python -m isort src 2 | python -m ruff format src test 3 | -------------------------------------------------------------------------------- /tools/installer.nsi: -------------------------------------------------------------------------------- 1 | ; Based off of the EdgeDeflector installer 2 | ; https://github.com/da2x/EdgeDeflector/blob/master/EdgeDeflector/resources/nsis_installer.nsi 3 | Unicode true 4 | ; UTF-8 BOM! 5 | 6 | !include "MUI2.nsh" 7 | !include "FileFunc.nsh" 8 | !include "LogicLib.nsh" 9 | !include "nsDialogs.nsh" 10 | 11 | RequestExecutionLevel user 12 | ShowInstDetails show 13 | 14 | ; Installer for RestoreWindowPos 15 | BrandingText "RestoreWindowPos By Crozzers" 16 | 17 | !define PRODUCT "RestoreWindowPos" 18 | !define DESCRIPTION "Restore window positions when displays are connected and disconnected" 19 | !getdllversion "..\dist\${PRODUCT}.exe" VERSION_ 20 | !define VERSION "${VERSION_1}.${VERSION_2}.${VERSION_3}.${VERSION_4}" 21 | 22 | VIAddVersionKey "ProductName" "${PRODUCT} Installer" 23 | VIAddVersionKey "FileVersion" "${VERSION}" 24 | VIAddVersionKey "FileDescription" "Install ${PRODUCT} ${VERSION}" 25 | VIAddVersionKey "LegalCopyright" "© Crozzers (github.com/Crozzers) 2024" 26 | 27 | ; use the version thing pyinstaller in the future 28 | ; https://stackoverflow.com/questions/14624245/what-does-a-version-file-look-like 29 | VIFileVersion "${VERSION}" 30 | VIProductVersion "${VERSION}" 31 | 32 | Name "${PRODUCT} Installer" 33 | 34 | OutFile "..\dist\${PRODUCT}_install.exe" 35 | 36 | ; Default installation directory 37 | InstallDir $LOCALAPPDATA\Programs\${PRODUCT} 38 | 39 | ; Store install dir in the registry 40 | InstallDirRegKey HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\${PRODUCT}.exe" "Path" 41 | 42 | ; MUI Config 43 | !define MUI_ICON "../assets/icon256.ico" 44 | 45 | ; Installer pages 46 | !insertmacro MUI_PAGE_DIRECTORY 47 | Page custom ShortcutPage ShortcutPageLeave 48 | !insertmacro MUI_PAGE_INSTFILES 49 | !define MUI_FINISHPAGE_RUN "$INSTDIR\${PRODUCT}" 50 | !define MUI_FINISHPAGE_RUN_TEXT "Launch ${PRODUCT}" 51 | !insertmacro MUI_PAGE_FINISH 52 | 53 | ; Uninstaller pages 54 | UninstPage uninstConfirm 55 | UninstPage instfiles 56 | 57 | 58 | Var Parameters 59 | Var StartMenuShortcut 60 | Var DesktopShortcut 61 | 62 | Function ShortcutPage 63 | nsDialogs::Create 1018 64 | Pop $0 65 | 66 | # create checkboxes and set default state to checked 67 | ${NSD_CreateCheckbox} 10u 10u 200u 12u "Create Start-Menu Shortcut" 68 | Pop $1 69 | ${NSD_Check} $1 70 | ${NSD_CreateCheckbox} 10u 30u 200u 12u "Create Desktop Shortcut" 71 | Pop $2 72 | 73 | # Show the dialog 74 | nsDialogs::Show 75 | FunctionEnd 76 | 77 | 78 | Function ShortcutPageLeave 79 | # store checkbox states in vars 80 | ${NSD_GetState} $1 $StartMenuShortcut 81 | ${NSD_GetState} $2 $DesktopShortcut 82 | FunctionEnd 83 | 84 | 85 | Function getParams 86 | ${GetParameters} $Parameters 87 | ClearErrors 88 | FunctionEnd 89 | 90 | 91 | Function checkLaunchParam 92 | Call getParams 93 | ${GetOptions} $Parameters "/StartAfterInstall" $1 94 | ${IfNot} ${Errors} 95 | Exec "$INSTDIR\${PRODUCT}.exe" 96 | ${EndIf} 97 | FunctionEnd 98 | 99 | 100 | Section "Installer" 101 | SetAutoClose false 102 | AddSize 8 103 | 104 | ; Set output path to the installation directory. 105 | SetOutPath $INSTDIR 106 | 107 | ; Install the program 108 | File "..\dist\${PRODUCT}.exe" 109 | 110 | ; Path registration 111 | WriteRegStr HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\${PRODUCT}.exe" "" "$INSTDIR\${PRODUCT}.exe" 112 | WriteRegStr HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\${PRODUCT}.exe" "Path" "$INSTDIR" 113 | 114 | ; Program class registration 115 | WriteRegStr HKCU "SOFTWARE\Classes\${PRODUCT}\Application" "ApplicationName" "${PRODUCT}" 116 | WriteRegStr HKCU "SOFTWARE\Classes\${PRODUCT}\DefaultIcon" "" "$INSTDIR\${PRODUCT}.exe,0" 117 | WriteRegStr HKCU "SOFTWARE\Classes\${PRODUCT}\shell\open\command" "" '"$INSTDIR\${PRODUCT}.exe"' 118 | WriteRegStr HKCU "SOFTWARE\Classes\${PRODUCT}\Capabilities" "ApplicationName" "${PRODUCT}" 119 | WriteRegStr HKCU "SOFTWARE\Classes\${PRODUCT}\Capabilities" "ApplicationIcon" "$INSTDIR\${PRODUCT}.exe,0" 120 | WriteRegStr HKCU "SOFTWARE\Classes\${PRODUCT}\Capabilities" "ApplicationDescription" "${DESCRIPTION}" 121 | 122 | ; Application registration 123 | WriteRegStr HKCU "SOFTWARE\Classes\Applications\${PRODUCT}.exe\DefaultIcon" "" "$INSTDIR\${PRODUCT}.exe,0" 124 | 125 | ; Program registration 126 | WriteRegStr HKCU "SOFTWARE\RegisteredApplications" "${PRODUCT}" "SOFTWARE\Classes\${PRODUCT}\Capabilities" 127 | 128 | ; Run on startup 129 | WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "RestoreWindowPos" '"$InstDir\${PRODUCT}.exe"' 130 | 131 | Call getParams 132 | 133 | ; Add start menu shortcut if option enabled 134 | ${GetOptions} $Parameters "/StartMenuShortcut" $1 135 | ${IF} $StartMenuShortcut <> 0 136 | ${OrIfNot} ${Errors} 137 | createShortCut "$SMPROGRAMS\${PRODUCT}.lnk" "$INSTDIR\${PRODUCT}.exe" "--open-gui" "" "" SW_SHOWNORMAL 138 | ${ENDIF} 139 | 140 | ${GetOptions} $Parameters "/DesktopShortcut" $1 141 | ${IF} $DesktopShortcut <> 0 142 | ${OrIfNot} ${Errors} 143 | createShortCut "$DESKTOP\${PRODUCT}.lnk" "$INSTDIR\${PRODUCT}.exe" "--open-gui" "" "" SW_SHOWNORMAL 144 | ${ENDIF} 145 | 146 | ; Install the uninstaller 147 | WriteUninstaller "${PRODUCT}_uninstall.exe" 148 | 149 | ; Register the uninstaller 150 | WriteRegStr HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT}" "DisplayName" "${PRODUCT}" 151 | WriteRegStr HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT}" "DisplayIcon" "$INSTDIR\${PRODUCT}.exe,0" 152 | WriteRegStr HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT}" "DisplayVersion" "${VERSION}" 153 | 154 | WriteRegStr HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT}" "InstallLocation" "$INSTDIR" 155 | WriteRegStr HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT}" "UninstallString" "$INSTDIR\${PRODUCT}_uninstall.exe" 156 | 157 | WriteRegDWORD HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT}" "NoModify" 1 158 | WriteRegDWORD HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT}" "NoRepair" 1 159 | 160 | ; Estimated installation size 161 | SectionGetSize 0 $0 162 | WriteRegDWORD HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT}" "EstimatedSize" $0 163 | 164 | ; Only autostart program if in silent mode because GUI has option to launch it anyway 165 | ${If} ${Silent} 166 | Call checkLaunchParam 167 | ${EndIf} 168 | SectionEnd 169 | 170 | ;-------------------------------- 171 | 172 | 173 | Section "Uninstall" 174 | ; Remove program 175 | Delete "$INSTDIR\${PRODUCT}.exe" 176 | 177 | ; Remove shortcuts 178 | Delete "$SMPROGRAMS\${PRODUCT}.lnk" 179 | Delete "$DESKTOP\${PRODUCT}.lnk" 180 | 181 | ; Remove registry keys 182 | DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Run\${PRODUCT}" 183 | DeleteRegKey HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\${PRODUCT}.exe" 184 | DeleteRegKey HKCU "SOFTWARE\Classes\${PRODUCT}" 185 | DeleteRegKey HKCU "SOFTWARE\Classes\Applications\${PRODUCT}.exe" 186 | DeleteRegValue HKCU "SOFTWARE\RegisteredApplications" "${PRODUCT}" 187 | 188 | ; Remove uninstaller 189 | DeleteRegKey HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT}" 190 | Delete "$INSTDIR\${PRODUCT}_uninstall.exe" 191 | 192 | RMDir /r "$INSTDIR" 193 | SectionEnd 194 | -------------------------------------------------------------------------------- /tools/test-installer.ps1: -------------------------------------------------------------------------------- 1 | param ([String]$confirm='n') 2 | 3 | $ErrorActionPreference = "Stop" 4 | 5 | Write-Warning "This script will mess up your existing RestoreWindowPos install." 6 | Write-Warning "Do not run this script except for testing/development purposes" 7 | if ($confirm -ne 'y') { 8 | $confirm = Read-Host "Are you Sure You Want To Proceed?" 9 | if ($confirm -ne 'y') { 10 | exit 1 11 | } 12 | } 13 | 14 | # AppData includes Roaming. Escape to parent 15 | $INSTALL_DIR="$env:AppData\..\Local\Programs\RestoreWindowPos" 16 | 17 | function TestPostInstall { 18 | if (!(Test-Path $INSTALL_DIR)) { 19 | Write-Error "program not installed" 20 | } elseif (!(Test-Path "$INSTALL_DIR\RestoreWindowPos.exe")) { 21 | Write-Error "main executable not installed" 22 | } elseif (!(Test-Path "$INSTALL_DIR\RestoreWindowPos_uninstall.exe")) { 23 | Write-Error "uninstaller not installed" 24 | } else { 25 | return 26 | } 27 | exit 1 28 | } 29 | 30 | function GetLastAccess { 31 | return (Get-ChildItem $INSTALL_DIR -rec | Where-Object { 32 | $_.Name -match 'RestoreWindowPos.*\.exe$' 33 | } | Select-Object FullName, LastAccessTime) 34 | } 35 | 36 | function DoInstallRWP { 37 | choco install restorewindowpos -s dist -y -f 38 | TestPostInstall 39 | } 40 | 41 | function DoUpgradeRWP { 42 | param ($ExtraParams) 43 | $lastaccess=$(GetLastAccess) 44 | Invoke-Expression "choco upgrade restorewindowpos -s dist -y -f $ExtraParams" 45 | TestPostInstall 46 | $newaccess=$(GetLastAccess) 47 | $accessdiff=$(Compare-Object -ReferenceObject $lastaccess -DifferenceObject $newaccess -IncludeEqual -ExcludeDifferent) 48 | if (@($accessdiff).Length -eq 0) { 49 | Write-Error "upgrade operation did not overwrite installer and uninstaller" 50 | exit 1 51 | } 52 | } 53 | 54 | function DoUninstallRWP { 55 | Invoke-Expression "$INSTALL_DIR\RestoreWindowPos_uninstall.exe /S" 56 | Start-Sleep 3 57 | if (Test-Path "$INSTALL_DIR\RestoreWindowPos.exe") { 58 | Write-Error "main executable not uninstalled" 59 | } elseif (Test-Path "$INSTALL_DIR\RestoreWindowPos_uninstall.exe") { 60 | Write-Error "uninstaller not uninstalled" 61 | } else { 62 | return 63 | } 64 | exit 1 65 | } 66 | 67 | Write-Warning "Test install" 68 | if (Test-Path ($INSTALL_DIR + "\RestoreWindowPos.exe")) { 69 | Write-Error "Program is already installed" 70 | exit 1 71 | } 72 | DoInstallRWP 73 | Write-Warning "Test program has not started after install without being asked" 74 | if ((Get-Process "RestoreWindowPos" -ea SilentlyContinue) -ne $Null) { 75 | Write-Error "program has been started without asking" 76 | exit 1 77 | } 78 | Write-Warning "Test program will start after install when asked" 79 | DoUpgradeRWP -ExtraParams "--params '`"/StartAfterInstall`"'" 80 | Start-Sleep 5 81 | if ((Get-Process "RestoreWindowPos" -ea SilentlyContinue) -eq $Null) { 82 | Write-Error "program did not start after install" 83 | exit 1 84 | } 85 | Write-Warning "Test we can shut down running instances and still upgrade" 86 | DoUpgradeRWP 87 | if ((Get-Process "RestoreWindowPos" -ea SilentlyContinue) -ne $Null) { 88 | Write-Error "program was not shut down before install" 89 | exit 1 90 | } 91 | Write-Warning "Test uninstaller" 92 | DoUninstallRWP 93 | -------------------------------------------------------------------------------- /tools/version_file.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | 5 | import pyinstaller_versionfile 6 | from packaging.version import Version 7 | 8 | sys.path.insert(0, 'src') 9 | from _version import __version__ # noqa: E402 10 | __basic_version__ = __version__.split('-')[0] 11 | 12 | OUTPUT_FILE = 'build/versionfile.txt' 13 | 14 | build = 0 15 | if os.path.isdir('build'): 16 | if os.path.isfile(OUTPUT_FILE): 17 | with open(OUTPUT_FILE) as f: 18 | ver_str = re.search(r'filevers=\((\d+,\d+,\d+),(\d+)\)', f.read(), re.M) 19 | 20 | last_version = Version(ver_str.group(1).replace(',', '.')) 21 | last_build = int(ver_str.group(2)) 22 | 23 | if Version(__basic_version__) == last_version: 24 | build = last_build + 1 25 | else: 26 | os.mkdir('build') 27 | 28 | pyinstaller_versionfile.create_versionfile( 29 | output_file=OUTPUT_FILE, 30 | version=f'{__basic_version__}.{build}', 31 | file_description='RestoreWindowPos', 32 | legal_copyright='© Crozzers (github.com/Crozzers) 2024', 33 | product_name='RestoreWindowPos' 34 | ) 35 | --------------------------------------------------------------------------------