taskbargap 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 paone9
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: taskbargap
3
+ Version: 0.1.0
4
+ Summary: Find the empty gap in the Windows taskbar and place a top-most window in it, across monitors and DPI.
5
+ Author: paone9
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/paone9/taskbargap
8
+ Project-URL: Issues, https://github.com/paone9/taskbargap/issues
9
+ Project-URL: Changelog, https://github.com/paone9/taskbargap/blob/main/CHANGELOG.md
10
+ Keywords: windows,taskbar,win32,topmost,dpi,widget,taskbar-gap,shell
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Win32 (MS Windows)
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Topic :: Desktop Environment
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Provides-Extra: dev
22
+ Requires-Dist: ruff==0.16.1; extra == "dev"
23
+ Requires-Dist: bandit==1.9.4; extra == "dev"
24
+ Requires-Dist: pytest==9.1.1; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # taskbargap
28
+
29
+ [![Platform: Windows 10/11](https://img.shields.io/badge/platform-Windows%2010%20%7C%2011-0078d6)](#honest-limitations)
30
+ [![PyPI](https://img.shields.io/pypi/v/taskbargap)](https://pypi.org/project/taskbargap/)
31
+ [![CI](https://github.com/paone9/taskbargap/actions/workflows/ci.yml/badge.svg)](https://github.com/paone9/taskbargap/actions/workflows/ci.yml)
32
+ [![CodeQL](https://github.com/paone9/taskbargap/actions/workflows/codeql.yml/badge.svg)](https://github.com/paone9/taskbargap/actions/workflows/codeql.yml)
33
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/paone9/taskbargap/badge)](https://scorecard.dev/viewer/?uri=github.com/paone9/taskbargap)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
35
+
36
+ Find the empty stretch of the Windows taskbar, the gap between your app buttons
37
+ and the system tray, and put a top-most window in it. Correctly, across multiple
38
+ monitors and per-monitor DPI scaling.
39
+
40
+ Windows leaves that strip unused, so a small always-on widget there costs you no
41
+ screen space. Doing it properly turns out to be fiddly. You have to detect the gap
42
+ across two generations of taskbar internals, handle per-monitor DPI and the
43
+ difference between physical and logical pixels, keep the window fitted as apps
44
+ open and close, and end up with a top-most tool window that owns its input without
45
+ grabbing a taskbar button of its own. This library does that, and nothing else.
46
+
47
+ > **Windows 10/11 only.** It is built on Win32 APIs, and there is no macOS or Linux
48
+ > build. The package imports anywhere, but calling into it off Windows raises
49
+ > `NotWindowsError`.
50
+
51
+ ## Install
52
+
53
+ ```
54
+ pip install taskbargap
55
+ ```
56
+
57
+ The only dependency is the standard library (`ctypes`). It writes nothing at all:
58
+ no files, no registry keys, no config, no logs. It makes no network calls. See
59
+ [SECURITY.md](SECURITY.md) for the complete list of Win32 calls it makes.
60
+
61
+ This is an alpha, and it says so on the tin. Before you rely on it, read [what
62
+ has and hasn't been validated](DESIGN.md), which is specific about the
63
+ configurations it has never run on.
64
+
65
+ ## Quickstart
66
+
67
+ ```python
68
+ import taskbargap
69
+
70
+ taskbargap.enable_dpi_awareness() # call once, before creating windows
71
+
72
+ gap = taskbargap.find_gap() # -> Gap | None
73
+ if gap:
74
+ print(gap.left, gap.right, gap.width, gap.scale)
75
+
76
+ # Place a window (by HWND) into the gap, right-aligned near the tray:
77
+ taskbargap.place(my_hwnd, align="right", margin=12)
78
+ ```
79
+
80
+ To keep it fitted as the taskbar changes, when apps open and close, when Explorer
81
+ restarts, when the resolution changes:
82
+
83
+ ```python
84
+ watcher = taskbargap.GapWatcher(my_hwnd, align="right")
85
+ watcher.start() # places it now, then re-fits on change and re-asserts top-most
86
+ # ...
87
+ watcher.stop()
88
+ ```
89
+
90
+ `start()` places the window immediately, on your thread, and returns. The polling
91
+ happens on a daemon thread afterwards. Pass `on_change=fn` if you want to be told
92
+ when the gap moves, including when it gets too narrow to fit, which is usually
93
+ when an app wants to get out of the way.
94
+
95
+ ## API
96
+
97
+ | Object | Purpose |
98
+ |--------|---------|
99
+ | `find_gap() -> Gap \| None` | Detect the empty taskbar gap on the primary monitor. `None` if there isn't a usable one. |
100
+ | `place(hwnd, *, align="right", margin=12, min_width=160, width=None, height=None, gap=None, nonblocking=False) -> bool` | Size and position an existing window inside the gap as a top-most tool window. `width` defaults to filling the gap, `height` to the taskbar's own height. False means it did nothing: no gap, `min_width` didn't fit, or Windows refused the move. |
101
+ | `GapWatcher(hwnd, *, align, margin, min_width, width, height, interval=1.0, on_change=None)` | Background watcher that keeps the window fitted and top-most as the taskbar changes. `.start()` / `.stop() -> bool`, or use it as a context manager. |
102
+ | `enable_dpi_awareness() -> bool` | Opt into per-monitor-v2 DPI awareness. Falls back gracefully on old Windows. |
103
+ | `Gap` | Frozen dataclass: `left, right, top, bottom` (physical px), `scale` (DPI factor), `monitor` (HMONITOR), `measured`, plus `width` / `height`. |
104
+ | `NotWindowsError` | Raised by any Win32 call when you're not on Windows. |
105
+
106
+ ### Units, and the DPI contract
107
+
108
+ Every coordinate you get back is a **physical pixel** in the coordinate space your
109
+ process actually sees. `Gap.scale` is the divisor for toolkits that scale window
110
+ position by DPI, such as pywebview and WinForms:
111
+
112
+ ```python
113
+ x_logical = round(gap.left / gap.scale)
114
+ ```
115
+
116
+ Call `enable_dpi_awareness()` before you create any window. Without it Windows
117
+ virtualises coordinates (a 3840px screen at 175% looks 2194px wide) and a window
118
+ aimed at the gap lands somewhere else.
119
+
120
+ Whatever you do, `scale` describes the space your process addresses windows in,
121
+ rather than the monitor's spec sheet. That means `1.0` for a DPI-unaware process,
122
+ the system DPI for a system-aware one, and the taskbar monitor's own factor only
123
+ for a per-monitor-aware one. The distinction is not academic: `GetDpiForWindow`
124
+ will happily report the taskbar's real 175% to an unaware caller that sees a
125
+ 2194px desktop, and dividing by that number puts the window a third of the screen
126
+ away from where it belongs. The library stays self-consistent in every mode. It is
127
+ only in per-monitor mode that you get real screen pixels and an unscaled window.
128
+
129
+ ## What it deliberately does not do
130
+
131
+ - No metrics, rendering, or UI. You bring the window, it does the placement.
132
+ - No decision about *whether* to be visible. Fullscreen-hide and yielding to a
133
+ crowded taskbar are app policy, and `find_gap()` gives you the facts to decide.
134
+ - No cross-platform panels. Windows taskbar only.
135
+
136
+ ## Honest limitations
137
+
138
+ - **When the button strip can't be measured.** The app-button edge is read from the
139
+ taskbar's own `ReBarWindow32` and `MSTaskListWClass` windows. On stock Windows 11
140
+ (build 26200) those exist and track the buttons in both left-aligned and centred
141
+ layouts, measured here both ways, so the common cases are the measured ones.
142
+ Where a shell doesn't host them, `find_gap()` falls back to assuming the buttons
143
+ end 30% across the bar, and sets `measured=False`. Be clear-eyed about that
144
+ fallback. It is a guess inherited from the app this code was extracted from, it
145
+ has never run on a real machine, and it can name a left edge that still has
146
+ buttons on it. Check `Gap.measured` if covering a button would matter to you.
147
+ - **Rects are sanity-checked, so detection degrades rather than lies.** A taskbar
148
+ child reporting a dead or off-bar rectangle, which happens while Explorer is
149
+ restarting, is ignored rather than believed, and you get the heuristic with
150
+ `measured=False`. A plausible edge that leaves no room is respected: a genuinely
151
+ full taskbar returns `None`, because inventing a gap there would cover buttons.
152
+ - **Primary taskbar only.** Secondary monitors get their own
153
+ `Shell_SecondaryTrayWnd` bars, and v0.1 reads the primary `Shell_TrayWnd`. The
154
+ gap you get is on whichever monitor that taskbar is on, at that monitor's DPI.
155
+ - **Horizontal taskbars only.** A taskbar docked left or right, which Windows 10
156
+ allows, has no horizontal gap worth speaking of, so `find_gap()` returns `None`
157
+ rather than guess.
158
+ - **The watcher needs your app to pump messages.** It posts its moves rather than
159
+ sending them, so a busy UI thread can never block it. The flip side is that a
160
+ re-fit only lands the next time your app processes messages. Normal GUI apps do
161
+ that constantly, but a wedged one won't move.
162
+ - **Auto-hide taskbars** are not special-cased. You get the gap of the bar wherever
163
+ it currently is, mid-slide included.
164
+
165
+ ## License
166
+
167
+ MIT.
@@ -0,0 +1,141 @@
1
+ # taskbargap
2
+
3
+ [![Platform: Windows 10/11](https://img.shields.io/badge/platform-Windows%2010%20%7C%2011-0078d6)](#honest-limitations)
4
+ [![PyPI](https://img.shields.io/pypi/v/taskbargap)](https://pypi.org/project/taskbargap/)
5
+ [![CI](https://github.com/paone9/taskbargap/actions/workflows/ci.yml/badge.svg)](https://github.com/paone9/taskbargap/actions/workflows/ci.yml)
6
+ [![CodeQL](https://github.com/paone9/taskbargap/actions/workflows/codeql.yml/badge.svg)](https://github.com/paone9/taskbargap/actions/workflows/codeql.yml)
7
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/paone9/taskbargap/badge)](https://scorecard.dev/viewer/?uri=github.com/paone9/taskbargap)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
9
+
10
+ Find the empty stretch of the Windows taskbar, the gap between your app buttons
11
+ and the system tray, and put a top-most window in it. Correctly, across multiple
12
+ monitors and per-monitor DPI scaling.
13
+
14
+ Windows leaves that strip unused, so a small always-on widget there costs you no
15
+ screen space. Doing it properly turns out to be fiddly. You have to detect the gap
16
+ across two generations of taskbar internals, handle per-monitor DPI and the
17
+ difference between physical and logical pixels, keep the window fitted as apps
18
+ open and close, and end up with a top-most tool window that owns its input without
19
+ grabbing a taskbar button of its own. This library does that, and nothing else.
20
+
21
+ > **Windows 10/11 only.** It is built on Win32 APIs, and there is no macOS or Linux
22
+ > build. The package imports anywhere, but calling into it off Windows raises
23
+ > `NotWindowsError`.
24
+
25
+ ## Install
26
+
27
+ ```
28
+ pip install taskbargap
29
+ ```
30
+
31
+ The only dependency is the standard library (`ctypes`). It writes nothing at all:
32
+ no files, no registry keys, no config, no logs. It makes no network calls. See
33
+ [SECURITY.md](SECURITY.md) for the complete list of Win32 calls it makes.
34
+
35
+ This is an alpha, and it says so on the tin. Before you rely on it, read [what
36
+ has and hasn't been validated](DESIGN.md), which is specific about the
37
+ configurations it has never run on.
38
+
39
+ ## Quickstart
40
+
41
+ ```python
42
+ import taskbargap
43
+
44
+ taskbargap.enable_dpi_awareness() # call once, before creating windows
45
+
46
+ gap = taskbargap.find_gap() # -> Gap | None
47
+ if gap:
48
+ print(gap.left, gap.right, gap.width, gap.scale)
49
+
50
+ # Place a window (by HWND) into the gap, right-aligned near the tray:
51
+ taskbargap.place(my_hwnd, align="right", margin=12)
52
+ ```
53
+
54
+ To keep it fitted as the taskbar changes, when apps open and close, when Explorer
55
+ restarts, when the resolution changes:
56
+
57
+ ```python
58
+ watcher = taskbargap.GapWatcher(my_hwnd, align="right")
59
+ watcher.start() # places it now, then re-fits on change and re-asserts top-most
60
+ # ...
61
+ watcher.stop()
62
+ ```
63
+
64
+ `start()` places the window immediately, on your thread, and returns. The polling
65
+ happens on a daemon thread afterwards. Pass `on_change=fn` if you want to be told
66
+ when the gap moves, including when it gets too narrow to fit, which is usually
67
+ when an app wants to get out of the way.
68
+
69
+ ## API
70
+
71
+ | Object | Purpose |
72
+ |--------|---------|
73
+ | `find_gap() -> Gap \| None` | Detect the empty taskbar gap on the primary monitor. `None` if there isn't a usable one. |
74
+ | `place(hwnd, *, align="right", margin=12, min_width=160, width=None, height=None, gap=None, nonblocking=False) -> bool` | Size and position an existing window inside the gap as a top-most tool window. `width` defaults to filling the gap, `height` to the taskbar's own height. False means it did nothing: no gap, `min_width` didn't fit, or Windows refused the move. |
75
+ | `GapWatcher(hwnd, *, align, margin, min_width, width, height, interval=1.0, on_change=None)` | Background watcher that keeps the window fitted and top-most as the taskbar changes. `.start()` / `.stop() -> bool`, or use it as a context manager. |
76
+ | `enable_dpi_awareness() -> bool` | Opt into per-monitor-v2 DPI awareness. Falls back gracefully on old Windows. |
77
+ | `Gap` | Frozen dataclass: `left, right, top, bottom` (physical px), `scale` (DPI factor), `monitor` (HMONITOR), `measured`, plus `width` / `height`. |
78
+ | `NotWindowsError` | Raised by any Win32 call when you're not on Windows. |
79
+
80
+ ### Units, and the DPI contract
81
+
82
+ Every coordinate you get back is a **physical pixel** in the coordinate space your
83
+ process actually sees. `Gap.scale` is the divisor for toolkits that scale window
84
+ position by DPI, such as pywebview and WinForms:
85
+
86
+ ```python
87
+ x_logical = round(gap.left / gap.scale)
88
+ ```
89
+
90
+ Call `enable_dpi_awareness()` before you create any window. Without it Windows
91
+ virtualises coordinates (a 3840px screen at 175% looks 2194px wide) and a window
92
+ aimed at the gap lands somewhere else.
93
+
94
+ Whatever you do, `scale` describes the space your process addresses windows in,
95
+ rather than the monitor's spec sheet. That means `1.0` for a DPI-unaware process,
96
+ the system DPI for a system-aware one, and the taskbar monitor's own factor only
97
+ for a per-monitor-aware one. The distinction is not academic: `GetDpiForWindow`
98
+ will happily report the taskbar's real 175% to an unaware caller that sees a
99
+ 2194px desktop, and dividing by that number puts the window a third of the screen
100
+ away from where it belongs. The library stays self-consistent in every mode. It is
101
+ only in per-monitor mode that you get real screen pixels and an unscaled window.
102
+
103
+ ## What it deliberately does not do
104
+
105
+ - No metrics, rendering, or UI. You bring the window, it does the placement.
106
+ - No decision about *whether* to be visible. Fullscreen-hide and yielding to a
107
+ crowded taskbar are app policy, and `find_gap()` gives you the facts to decide.
108
+ - No cross-platform panels. Windows taskbar only.
109
+
110
+ ## Honest limitations
111
+
112
+ - **When the button strip can't be measured.** The app-button edge is read from the
113
+ taskbar's own `ReBarWindow32` and `MSTaskListWClass` windows. On stock Windows 11
114
+ (build 26200) those exist and track the buttons in both left-aligned and centred
115
+ layouts, measured here both ways, so the common cases are the measured ones.
116
+ Where a shell doesn't host them, `find_gap()` falls back to assuming the buttons
117
+ end 30% across the bar, and sets `measured=False`. Be clear-eyed about that
118
+ fallback. It is a guess inherited from the app this code was extracted from, it
119
+ has never run on a real machine, and it can name a left edge that still has
120
+ buttons on it. Check `Gap.measured` if covering a button would matter to you.
121
+ - **Rects are sanity-checked, so detection degrades rather than lies.** A taskbar
122
+ child reporting a dead or off-bar rectangle, which happens while Explorer is
123
+ restarting, is ignored rather than believed, and you get the heuristic with
124
+ `measured=False`. A plausible edge that leaves no room is respected: a genuinely
125
+ full taskbar returns `None`, because inventing a gap there would cover buttons.
126
+ - **Primary taskbar only.** Secondary monitors get their own
127
+ `Shell_SecondaryTrayWnd` bars, and v0.1 reads the primary `Shell_TrayWnd`. The
128
+ gap you get is on whichever monitor that taskbar is on, at that monitor's DPI.
129
+ - **Horizontal taskbars only.** A taskbar docked left or right, which Windows 10
130
+ allows, has no horizontal gap worth speaking of, so `find_gap()` returns `None`
131
+ rather than guess.
132
+ - **The watcher needs your app to pump messages.** It posts its moves rather than
133
+ sending them, so a busy UI thread can never block it. The flip side is that a
134
+ re-fit only lands the next time your app processes messages. Normal GUI apps do
135
+ that constantly, but a wedged one won't move.
136
+ - **Auto-hide taskbars** are not special-cased. You get the gap of the bar wherever
137
+ it currently is, mid-slide included.
138
+
139
+ ## License
140
+
141
+ MIT.
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "taskbargap"
7
+ version = "0.1.0"
8
+ description = "Find the empty gap in the Windows taskbar and place a top-most window in it, across monitors and DPI."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "paone9" }]
13
+ keywords = ["windows", "taskbar", "win32", "topmost", "dpi", "widget", "taskbar-gap", "shell"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Win32 (MS Windows)",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: Microsoft :: Windows",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Libraries",
21
+ "Topic :: Desktop Environment",
22
+ ]
23
+ dependencies = []
24
+
25
+ # Dev + CI tools, pinned to the tested versions. One dependency source for the
26
+ # whole project: `pip install .[dev]`, no separate requirements files.
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "ruff==0.16.1",
30
+ "bandit==1.9.4",
31
+ "pytest==9.1.1",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/paone9/taskbargap"
36
+ Issues = "https://github.com/paone9/taskbargap/issues"
37
+ Changelog = "https://github.com/paone9/taskbargap/blob/main/CHANGELOG.md"
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+
42
+ # PEP 561: the package is fully annotated, so say so - without the marker every
43
+ # consumer's type checker treats the whole API as untyped.
44
+ [tool.setuptools.package-data]
45
+ taskbargap = ["py.typed"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,26 @@
1
+ """taskbargap - place a top-most window in the empty Windows taskbar gap.
2
+
3
+ Windows 10/11 only. See README.md for usage and DESIGN.md for how it works.
4
+ Every coordinate a caller sees is in physical pixels; `Gap.scale` is the taskbar
5
+ monitor's DPI factor, for toolkits that want logical ones.
6
+
7
+ import taskbargap
8
+
9
+ taskbargap.enable_dpi_awareness() # once, before creating windows
10
+ gap = taskbargap.find_gap() # -> Gap | None
11
+ taskbargap.place(my_hwnd, align="right")
12
+ """
13
+ from ._detect import Gap, find_gap
14
+ from ._place import place
15
+ from ._win32 import NotWindowsError, enable_dpi_awareness
16
+ from .watcher import GapWatcher
17
+
18
+ __version__ = "0.1.0"
19
+ __all__ = [
20
+ "Gap",
21
+ "GapWatcher",
22
+ "NotWindowsError",
23
+ "enable_dpi_awareness",
24
+ "find_gap",
25
+ "place",
26
+ ]
@@ -0,0 +1,111 @@
1
+ """Find the empty span of the taskbar: `Shell_TrayWnd` -> tray edge, button edge."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from . import _win32
7
+ from ._geometry import BUTTON_FRACTION, gap_span, is_on_taskbar
8
+
9
+ #: The taskbar itself. (Secondary monitors get `Shell_SecondaryTrayWnd`; v0.1
10
+ #: reads the primary taskbar only. See DESIGN.md.)
11
+ TRAY_CLASS = "Shell_TrayWnd"
12
+ #: The notification/clock cluster on the right: the gap's right bound.
13
+ NOTIFY_CLASS = "TrayNotifyWnd"
14
+ #: The app-button strip: the gap's left bound. `ReBarWindow32` hosts it on both
15
+ #: Win10 and stock Win11; `MSTaskListWClass` is the button list itself, checked
16
+ #: as a fallback because some shells (and secondary taskbars, which use
17
+ #: `WorkerW`) host it elsewhere.
18
+ BUTTON_CLASSES = ("ReBarWindow32", "MSTaskListWClass")
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Gap:
23
+ """The empty span of the taskbar, in physical pixels.
24
+
25
+ `scale` is the DPI factor of the taskbar's monitor, exposed because toolkits
26
+ that scale window *position* by DPI (pywebview, WinForms) need the logical
27
+ value: `x_logical = round(gap.left / gap.scale)`.
28
+
29
+ `measured` is True when the app-button edge was read from a real window, and
30
+ False when it came from the fallback heuristic. Check it before trusting the
31
+ left edge not to sit on top of the buttons.
32
+ """
33
+ left: int
34
+ right: int
35
+ top: int
36
+ bottom: int
37
+ scale: float # DPI factor of the taskbar's monitor
38
+ monitor: int # HMONITOR
39
+ measured: bool # True if the button edge came from a real window
40
+
41
+ @property
42
+ def width(self) -> int:
43
+ return self.right - self.left
44
+
45
+ @property
46
+ def height(self) -> int:
47
+ return self.bottom - self.top
48
+
49
+
50
+ def _button_edge(tray: int, tray_rect: tuple[int, int, int, int]) -> int | None:
51
+ """Right edge of the app-button strip, or None if nothing measurable exists.
52
+
53
+ Every candidate is sanity-checked against the taskbar's own rect rather than
54
+ trusted on sight: the shell keeps stale and hidden button windows around
55
+ (this machine has an invisible `MSTaskListWClass`), and during an Explorer
56
+ restart a live handle can still report a zero rect. Candidates that don't
57
+ check out are skipped, so a good window later in the list still wins.
58
+
59
+ None means "no shell window would tell us", which sends the caller to the
60
+ heuristic. Stock Windows 11 (26200) does host these windows and moves them
61
+ with the buttons in both left and centred layouts, so on it the answer is
62
+ measured either way.
63
+ """
64
+ for cls in BUTTON_CLASSES:
65
+ hwnd = _win32.find_child(tray, cls) or _win32.find_descendant(tray, cls)
66
+ if not hwnd:
67
+ continue
68
+ rect = _win32.window_rect(hwnd)
69
+ if is_on_taskbar(rect, tray_rect):
70
+ return rect[2]
71
+ return None
72
+
73
+
74
+ def find_gap(fraction: float = BUTTON_FRACTION) -> Gap | None:
75
+ """Detect the empty taskbar gap. Returns a `Gap`, or `None` if there isn't one.
76
+
77
+ `None` covers every "no gap right now" state: no taskbar (Explorer
78
+ restarting), no measurable span, an auto-hidden bar mid-slide. Callers
79
+ can treat it as normal rather than exceptional. It does not swallow bugs:
80
+ on a non-Windows platform this raises `NotWindowsError`.
81
+
82
+ `fraction` overrides the fallback button-edge heuristic used when the button
83
+ strip can't be measured (native Windows 11).
84
+ """
85
+ tray = _win32.find_window(TRAY_CLASS)
86
+ if not tray:
87
+ return None
88
+ rect = _win32.window_rect(tray)
89
+ if not rect:
90
+ return None
91
+ tray_l, tray_t, tray_r, tray_b = rect
92
+ if tray_r <= tray_l or tray_b <= tray_t:
93
+ return None # Explorer restarting: a live handle with a dead rect
94
+ if (tray_b - tray_t) > (tray_r - tray_l):
95
+ return None # docked left/right (Win10): there is no horizontal gap
96
+
97
+ notify_rect = _win32.window_rect(_win32.find_child(tray, NOTIFY_CLASS))
98
+ # The tray cluster's *left* edge is the gap's right bound - and it gets the
99
+ # same sanity check, so a dead tray rect can't push the gap over the clock.
100
+ notify_left = notify_rect[0] if is_on_taskbar(notify_rect, rect) else None
101
+
102
+ span = gap_span(tray_l, tray_r, notify_left, _button_edge(tray, rect), fraction)
103
+ if span is None:
104
+ return None
105
+ left, right, measured = span
106
+ return Gap(
107
+ left=left, right=right, top=tray_t, bottom=tray_b,
108
+ scale=_win32.window_scale(tray),
109
+ monitor=_win32.monitor_from_window(tray),
110
+ measured=measured,
111
+ )
@@ -0,0 +1,126 @@
1
+ """Pure placement geometry: plain ints in, plain ints out, no Windows.
2
+
3
+ Deliberately free of ctypes so the arithmetic that decides *where the window
4
+ goes* can be unit-tested on any OS. The Win32 half (`_detect`, `_place`) reads
5
+ rects from the shell and hands them to these functions.
6
+
7
+ All coordinates are physical pixels.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ #: Fallback for the app-button edge when a shell hosts no measurable button
12
+ #: window: assume the buttons end 30% of the way across the taskbar. Inherited
13
+ #: from the app this code came from, where it shipped. Stock Windows 11 (26200)
14
+ #: does host those windows, in both left and centred layouts, so this is a
15
+ #: rarely-taken and unvalidated path. `Gap.measured` is False whenever it is used.
16
+ BUTTON_FRACTION = 0.30
17
+
18
+ ALIGNMENTS = ("left", "center", "right")
19
+
20
+
21
+ def is_on_taskbar(
22
+ rect: tuple[int, int, int, int] | None,
23
+ tray_rect: tuple[int, int, int, int],
24
+ ) -> bool:
25
+ """Is `rect` a live window rect that actually sits on this taskbar?
26
+
27
+ A window handle existing is not evidence that its rect means anything. The
28
+ shell keeps taskbar children around that are hidden or mid-teardown, and during
29
+ an Explorer restart `GetWindowRect` happily returns `(0, 0, 0, 0)`. A
30
+ zero rect read as a button edge puts the gap's left bound at the far left of
31
+ the screen, i.e. straight over the Start button and every app button.
32
+
33
+ So a candidate must be a non-empty rect that overlaps the taskbar's own.
34
+ Failing this means "couldn't measure", which sends `find_gap()` to its
35
+ heuristic, which is the honest answer. It deliberately does not reject a plausible
36
+ edge that leaves no room: a genuinely full taskbar has no gap, and inventing
37
+ one there would cover real buttons.
38
+ """
39
+ if rect is None:
40
+ return False
41
+ left, top, right, bottom = rect
42
+ if right <= left or bottom <= top:
43
+ return False # degenerate: not a live window
44
+ tray_left, tray_top, tray_right, tray_bottom = tray_rect
45
+ if right <= tray_left or left >= tray_right:
46
+ return False # not horizontally on this bar
47
+ return not (bottom <= tray_top or top >= tray_bottom)
48
+
49
+
50
+ def gap_span(
51
+ tray_left: int,
52
+ tray_right: int,
53
+ notify_left: int | None = None,
54
+ button_right: int | None = None,
55
+ fraction: float = BUTTON_FRACTION,
56
+ ) -> tuple[int, int, bool] | None:
57
+ """Compute the empty horizontal span of the taskbar.
58
+
59
+ `tray_left`/`tray_right` bound the whole taskbar. `notify_left` is the left
60
+ edge of the notification/clock cluster (the gap's right bound); `None` means
61
+ it wasn't found, so the taskbar's own right edge is used. `button_right` is
62
+ the right edge of the app-button strip (the gap's left bound); `None` means
63
+ it couldn't be measured, so `fraction` of the taskbar width is assumed.
64
+
65
+ Returns `(left, right, measured)` where `measured` is True only when the
66
+ button edge came from a real window, or `None` when there is no usable span.
67
+ """
68
+ if tray_right <= tray_left:
69
+ return None
70
+ if button_right is None:
71
+ left = tray_left + int((tray_right - tray_left) * fraction)
72
+ measured = False
73
+ else:
74
+ left = button_right
75
+ measured = True
76
+ right = tray_right if notify_left is None else notify_left
77
+ # Clamp into the taskbar: a stale or bogus child rect must not produce a
78
+ # span that reaches off the bar.
79
+ left = min(max(left, tray_left), tray_right)
80
+ right = min(max(right, tray_left), tray_right)
81
+ if right - left <= 0:
82
+ return None
83
+ return left, right, measured
84
+
85
+
86
+ def fit(
87
+ gap_left: int,
88
+ gap_right: int,
89
+ *,
90
+ align: str = "right",
91
+ margin: int = 12,
92
+ min_width: int = 160,
93
+ width: int | None = None,
94
+ ) -> tuple[int, int] | None:
95
+ """Compute `(x, width)` for a window inside the span `[gap_left, gap_right)`.
96
+
97
+ A `margin` of clear space is kept at each end. `width` is the desired width;
98
+ `None` means "fill the gap". The result never widens past the gap, so it can
99
+ never overlap the app buttons or the tray.
100
+
101
+ Returns `None` when `min_width` doesn't fit, leaving the caller to decide
102
+ what that means (hide, shrink, do nothing), because that is policy rather
103
+ than placement.
104
+ """
105
+ if align not in ALIGNMENTS:
106
+ raise ValueError(f"align must be one of {ALIGNMENTS!r}, got {align!r}")
107
+ if margin < 0:
108
+ raise ValueError(f"margin must be >= 0, got {margin!r}")
109
+ if min_width < 0:
110
+ raise ValueError(f"min_width must be >= 0, got {min_width!r}")
111
+ if width is not None and width <= 0:
112
+ raise ValueError(f"width must be > 0 or None, got {width!r}")
113
+
114
+ span = gap_right - gap_left
115
+ avail = span - 2 * margin
116
+ if avail < min_width or avail <= 0:
117
+ return None
118
+ w = avail if width is None else min(width, avail)
119
+ w = max(w, min_width) # avail >= min_width here, so w <= avail always
120
+ if align == "left":
121
+ x = gap_left + margin
122
+ elif align == "center":
123
+ x = gap_left + (span - w) // 2
124
+ else:
125
+ x = gap_right - margin - w
126
+ return x, w