├── .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 |
--------------------------------------------------------------------------------