taskbargap 0.1.0__py3-none-any.whl

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.
taskbargap/__init__.py ADDED
@@ -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
+ ]
taskbargap/_detect.py ADDED
@@ -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
taskbargap/_place.py ADDED
@@ -0,0 +1,74 @@
1
+ """Move an existing window into the gap as a top-most tool window."""
2
+ from __future__ import annotations
3
+
4
+ from . import _win32
5
+ from ._detect import Gap, find_gap
6
+ from ._geometry import fit
7
+
8
+ #: A gap window wants no taskbar button and no Alt-Tab entry, and it must sit in
9
+ #: the top-most band. WS_EX_APPWINDOW is cleared because it would force the
10
+ #: taskbar button back on.
11
+ _EX_ADD = _win32.WS_EX_TOOLWINDOW | _win32.WS_EX_TOPMOST
12
+ _EX_REMOVE = _win32.WS_EX_APPWINDOW
13
+
14
+
15
+ def place(
16
+ hwnd: int,
17
+ *,
18
+ align: str = "right",
19
+ margin: int = 12,
20
+ min_width: int = 160,
21
+ width: int | None = None,
22
+ height: int | None = None,
23
+ gap: Gap | None = None,
24
+ nonblocking: bool = False,
25
+ ) -> bool:
26
+ """Size and position `hwnd` inside the taskbar gap as a top-most tool window.
27
+
28
+ All sizes are physical pixels. `align` is "left" | "center" | "right".
29
+ `width` defaults to filling the gap; `height` to the taskbar's own height.
30
+ Pass `gap` to place against an already-detected gap instead of re-detecting.
31
+
32
+ Returns False, and touches nothing, when there is no gap or `min_width`
33
+ doesn't fit inside it; and False if Windows refuses the move (a window owned
34
+ by a higher-integrity process, or destroyed since the call began), which is
35
+ logged. The rectangle it asks for is never wider than the gap, so it cannot
36
+ ask to cover the app buttons or the tray. Whether "didn't fit" should mean
37
+ hiding is the caller's call, since that is app policy rather than placement.
38
+
39
+ The window is shown with SW_SHOWNA, so placing it never steals focus.
40
+
41
+ Call it from the thread that owns `hwnd` (the usual case) and the move has
42
+ happened by the time it returns. From any other thread pass
43
+ `nonblocking=True`: the move is posted to the owning thread instead, so a
44
+ busy or stalled UI thread can't hang the caller. It then takes effect when
45
+ that thread next pumps messages, and `True` means "asked", not "moved".
46
+
47
+ One step has no asynchronous form: applying the tool-window/top-most
48
+ extended styles sends WM_STYLECHANGED to the owning thread and waits. It
49
+ only ever runs when the styles aren't already right, that is the first time a
50
+ given window is placed, so call `place()` (or `GapWatcher.start()`) once
51
+ from the owning thread and every later call is fully non-blocking.
52
+ """
53
+ if not _win32.is_window(hwnd):
54
+ return False
55
+ if gap is None:
56
+ gap = find_gap()
57
+ if gap is None:
58
+ return False
59
+ box = fit(gap.left, gap.right, align=align, margin=margin,
60
+ min_width=min_width, width=width)
61
+ if box is None:
62
+ return False
63
+ x, w = box
64
+ h = gap.height if height is None else height
65
+
66
+ # Windows only drops an existing taskbar button / Alt-Tab entry when the
67
+ # window is hidden across the style change, so hide first if we changed it.
68
+ # A changed style also needs SWP_FRAMECHANGED to take effect on the frame,
69
+ # which the move below folds in.
70
+ styled = _win32.set_ex_styles(hwnd, add=_EX_ADD, remove=_EX_REMOVE)
71
+ if styled:
72
+ _win32.hide(hwnd, nonblocking)
73
+ return _win32.move_topmost(hwnd, x, gap.top, w, h, nonblocking=nonblocking,
74
+ frame_changed=styled)
taskbargap/_win32.py ADDED
@@ -0,0 +1,354 @@
1
+ """ctypes bindings for the small slice of user32 this library needs.
2
+
3
+ Two deliberate choices:
4
+
5
+ * **A private `user32` handle.** We load our own `ctypes.WinDLL("user32")`
6
+ rather than annotating the process-wide `ctypes.windll.user32`, because a
7
+ library must not mutate global ctypes state that the host app (pywebview,
8
+ WinForms, another package) also uses.
9
+ * **Importable anywhere.** No `ctypes.wintypes`, no `windll` at import time, so
10
+ `import taskbargap` works on any OS and the pure geometry can be tested there.
11
+ The Windows requirement only bites when you actually call something.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import ctypes
16
+ import logging
17
+ import sys
18
+
19
+ log = logging.getLogger("taskbargap")
20
+
21
+ # --- constants ---------------------------------------------------------------
22
+
23
+ GWL_EXSTYLE = -20
24
+ WS_EX_TOPMOST = 0x00000008
25
+ WS_EX_TOOLWINDOW = 0x00000080
26
+ WS_EX_APPWINDOW = 0x00040000
27
+ WS_EX_NOACTIVATE = 0x08000000
28
+
29
+ HWND_TOPMOST = -1
30
+ SWP_NOSIZE, SWP_NOMOVE, SWP_NOACTIVATE = 0x0001, 0x0002, 0x0010
31
+ #: Required for a SetWindowLong style change to take effect on the window frame.
32
+ SWP_FRAMECHANGED = 0x0020
33
+ #: Post the change to the owning thread instead of waiting for it to handle it.
34
+ #: Without this, a SetWindowPos/ShowWindow issued from a thread that does not own
35
+ #: the window blocks until the owner pumps its message loop - so a background
36
+ #: watcher would hang for as long as the UI thread is busy.
37
+ SWP_ASYNCWINDOWPOS = 0x4000
38
+ SWP_TOPMOST_ONLY = SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE
39
+
40
+ SW_HIDE, SW_SHOW, SW_SHOWNA = 0, 5, 8
41
+
42
+ MONITOR_DEFAULTTONEAREST = 2
43
+ DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4
44
+ #: DPI_AWARENESS enum. Per-monitor-v2 also reports as PER_MONITOR_AWARE here.
45
+ DPI_AWARENESS_UNAWARE = 0
46
+ DPI_AWARENESS_SYSTEM_AWARE = 1
47
+ DPI_AWARENESS_PER_MONITOR_AWARE = 2
48
+
49
+
50
+ # --- structures (hand-rolled: ctypes.wintypes is Windows-only) ----------------
51
+
52
+ class RECT(ctypes.Structure):
53
+ _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long),
54
+ ("right", ctypes.c_long), ("bottom", ctypes.c_long)]
55
+
56
+
57
+ class MONITORINFO(ctypes.Structure):
58
+ _fields_ = [("cbSize", ctypes.c_ulong), ("rcMonitor", RECT),
59
+ ("rcWork", RECT), ("dwFlags", ctypes.c_ulong)]
60
+
61
+
62
+ _HWND = ctypes.c_void_p
63
+ _INT = ctypes.c_int
64
+ _UINT = ctypes.c_uint
65
+ _DWORD = ctypes.c_ulong
66
+ _BOOL = ctypes.c_int
67
+ _LPCWSTR = ctypes.c_wchar_p
68
+
69
+ _user32 = None
70
+
71
+
72
+ class NotWindowsError(OSError):
73
+ """Raised when a Win32 call is attempted on a non-Windows platform."""
74
+
75
+
76
+ def user32():
77
+ """Return the annotated, process-private user32 handle (cached)."""
78
+ global _user32
79
+ if _user32 is None:
80
+ if not sys.platform.startswith("win"):
81
+ raise NotWindowsError(
82
+ "taskbargap is Windows-only: it is built on Win32 shell windows "
83
+ f"(running on {sys.platform!r})")
84
+ u = ctypes.WinDLL("user32", use_last_error=True)
85
+ _annotate(u)
86
+ _user32 = u
87
+ return _user32
88
+
89
+
90
+ def _annotate(u) -> None:
91
+ """Set argtypes/restypes. Handles are pointer-sized: without this they are
92
+ truncated to 32 bits on 64-bit Python and every call silently fails."""
93
+ u.FindWindowW.argtypes = [_LPCWSTR, _LPCWSTR]
94
+ u.FindWindowW.restype = _HWND
95
+ u.FindWindowExW.argtypes = [_HWND, _HWND, _LPCWSTR, _LPCWSTR]
96
+ u.FindWindowExW.restype = _HWND
97
+ u.GetWindowRect.argtypes = [_HWND, ctypes.POINTER(RECT)]
98
+ u.GetWindowRect.restype = _BOOL
99
+ u.IsWindow.argtypes = [_HWND]
100
+ u.IsWindow.restype = _BOOL
101
+ u.IsWindowVisible.argtypes = [_HWND]
102
+ u.IsWindowVisible.restype = _BOOL
103
+ u.SetWindowPos.argtypes = [_HWND, _HWND, _INT, _INT, _INT, _INT, _UINT]
104
+ u.SetWindowPos.restype = _BOOL
105
+ u.ShowWindow.argtypes = [_HWND, _INT]
106
+ u.ShowWindow.restype = _BOOL
107
+ u.ShowWindowAsync.argtypes = [_HWND, _INT]
108
+ u.ShowWindowAsync.restype = _BOOL
109
+ u.GetWindowLongW.argtypes = [_HWND, _INT]
110
+ u.GetWindowLongW.restype = ctypes.c_int32
111
+ u.SetWindowLongW.argtypes = [_HWND, _INT, ctypes.c_int32]
112
+ u.SetWindowLongW.restype = ctypes.c_int32
113
+ u.MonitorFromWindow.argtypes = [_HWND, _DWORD]
114
+ u.MonitorFromWindow.restype = _HWND
115
+ u.GetMonitorInfoW.argtypes = [_HWND, ctypes.POINTER(MONITORINFO)]
116
+ u.GetMonitorInfoW.restype = _BOOL
117
+ try: # Win10 1607+; absent on older builds
118
+ u.GetDpiForWindow.argtypes = [_HWND]
119
+ u.GetDpiForWindow.restype = _UINT
120
+ u.GetDpiForSystem.restype = _UINT
121
+ u.GetThreadDpiAwarenessContext.restype = ctypes.c_void_p
122
+ u.GetAwarenessFromDpiAwarenessContext.argtypes = [ctypes.c_void_p]
123
+ u.GetAwarenessFromDpiAwarenessContext.restype = _INT
124
+ except AttributeError:
125
+ pass
126
+
127
+
128
+ # --- helpers -----------------------------------------------------------------
129
+
130
+ def _handle(value) -> int:
131
+ """Normalise a returned HWND/HMONITOR (int or None) to a plain int."""
132
+ return int(value or 0)
133
+
134
+
135
+ def _as_i32(value: int) -> int:
136
+ """Window longs are signed 32-bit; style bit-sets are naturally unsigned."""
137
+ value &= 0xFFFFFFFF
138
+ return value - 0x100000000 if value > 0x7FFFFFFF else value
139
+
140
+
141
+ def find_window(class_name: str) -> int:
142
+ """Top-level window by class name; 0 if absent."""
143
+ return _handle(user32().FindWindowW(class_name, None))
144
+
145
+
146
+ def find_child(parent: int, class_name: str) -> int:
147
+ """Direct child by class name; 0 if absent."""
148
+ return _handle(user32().FindWindowExW(parent, None, class_name, None))
149
+
150
+
151
+ def find_descendant(parent: int, class_name: str, max_depth: int = 3) -> int:
152
+ """Breadth-first search of `parent`'s descendants for a class name; 0 if absent.
153
+
154
+ Depth-limited: the taskbar tree is shallow, and an unbounded walk of shell
155
+ windows is both slow and a good way to hang on a busy Explorer.
156
+ """
157
+ u = user32()
158
+ level = [parent]
159
+ for _ in range(max_depth):
160
+ nxt = []
161
+ for hwnd in level:
162
+ found = _handle(u.FindWindowExW(hwnd, None, class_name, None))
163
+ if found:
164
+ return found
165
+ child = None
166
+ while True:
167
+ child = u.FindWindowExW(hwnd, child, None, None)
168
+ if not child:
169
+ break
170
+ nxt.append(_handle(child))
171
+ if not nxt:
172
+ break
173
+ level = nxt
174
+ return 0
175
+
176
+
177
+ def window_rect(hwnd: int) -> tuple[int, int, int, int] | None:
178
+ """`(left, top, right, bottom)` in physical px, or None if the call fails."""
179
+ if not hwnd:
180
+ return None
181
+ r = RECT()
182
+ if not user32().GetWindowRect(hwnd, ctypes.byref(r)):
183
+ return None
184
+ return r.left, r.top, r.right, r.bottom
185
+
186
+
187
+ def is_window(hwnd: int) -> bool:
188
+ return bool(hwnd) and bool(user32().IsWindow(hwnd))
189
+
190
+
191
+ def is_window_visible(hwnd: int) -> bool:
192
+ return bool(hwnd) and bool(user32().IsWindowVisible(hwnd))
193
+
194
+
195
+ def monitor_from_window(hwnd: int) -> int:
196
+ return _handle(user32().MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST))
197
+
198
+
199
+ def monitor_rect(hmonitor: int) -> tuple[int, int, int, int] | None:
200
+ if not hmonitor:
201
+ return None
202
+ mi = MONITORINFO()
203
+ mi.cbSize = ctypes.sizeof(MONITORINFO)
204
+ if not user32().GetMonitorInfoW(hmonitor, ctypes.byref(mi)):
205
+ return None
206
+ r = mi.rcMonitor
207
+ return r.left, r.top, r.right, r.bottom
208
+
209
+
210
+ def system_scale(fallback: float = 1.0) -> float:
211
+ """DPI factor of the primary monitor (or of the process, if unaware)."""
212
+ try:
213
+ dpi = user32().GetDpiForSystem()
214
+ except (AttributeError, OSError):
215
+ return fallback
216
+ return dpi / 96.0 if dpi else fallback
217
+
218
+
219
+ def process_dpi_awareness() -> int:
220
+ """This process's `DPI_AWARENESS_*` value, or PER_MONITOR when unknowable."""
221
+ u = user32()
222
+ try:
223
+ ctx = u.GetThreadDpiAwarenessContext()
224
+ return u.GetAwarenessFromDpiAwarenessContext(ctx)
225
+ except (AttributeError, OSError):
226
+ # Pre-1607: no way to ask, and no per-monitor DPI to get wrong either.
227
+ return DPI_AWARENESS_PER_MONITOR_AWARE
228
+
229
+
230
+ def process_is_dpi_aware() -> bool:
231
+ """True unless this process is DPI-*un*aware, i.e. gets virtualised coordinates."""
232
+ return process_dpi_awareness() != DPI_AWARENESS_UNAWARE
233
+
234
+
235
+ def window_scale(hwnd: int, fallback: float | None = None) -> float:
236
+ """DPI factor of the monitor `hwnd` is on, *as this process sees it*.
237
+
238
+ The subtlety: `GetDpiForWindow` answers for the **window**, and the taskbar
239
+ belongs to per-monitor-aware Explorer, so it reports the taskbar monitor's
240
+ real DPI to anyone who asks, including a process living in a virtualised
241
+ coordinate space where that number is wrong. Measured on a 175% display:
242
+ an unaware process is told the taskbar is 168 DPI while it sees a 2194px
243
+ desktop, and dividing by 1.75 would put the window a third of the way across
244
+ the screen from where it belongs.
245
+
246
+ Only a per-monitor-aware process gets real per-monitor coordinates, so only
247
+ it may use the window's DPI. Everyone else gets `GetDpiForSystem`, which is
248
+ documented to answer in the caller's own terms: 96 for an unaware process,
249
+ the system DPI for a system-aware one, which is exactly the space each of
250
+ them addresses windows in.
251
+ """
252
+ if process_dpi_awareness() == DPI_AWARENESS_PER_MONITOR_AWARE:
253
+ try:
254
+ dpi = user32().GetDpiForWindow(hwnd)
255
+ if dpi:
256
+ return dpi / 96.0
257
+ except (AttributeError, OSError):
258
+ pass
259
+ return system_scale() if fallback is None else system_scale(fallback)
260
+
261
+
262
+ def set_ex_styles(hwnd: int, add: int = 0, remove: int = 0) -> bool:
263
+ """OR in / mask out extended styles. True if they were actually changed.
264
+
265
+ False means either "already correct" or "Windows refused". The caller only
266
+ needs it to decide whether a frame recalculation is due, and a refusal is
267
+ logged rather than raised so one un-styleable window can't take down a
268
+ watcher loop.
269
+ """
270
+ u = user32()
271
+ cur = u.GetWindowLongW(hwnd, GWL_EXSTYLE) & 0xFFFFFFFF
272
+ new = (cur | add) & ~remove & 0xFFFFFFFF
273
+ if new == cur:
274
+ return False
275
+ ctypes.set_last_error(0)
276
+ previous = u.SetWindowLongW(hwnd, GWL_EXSTYLE, _as_i32(new))
277
+ err = ctypes.get_last_error()
278
+ if previous == 0 and err: # 0 is a legal previous value; 0 + error is not
279
+ log.warning("taskbargap: could not set extended styles on window %s "
280
+ "(WinError %d) - it may keep its taskbar button", hwnd, err)
281
+ return False
282
+ return True
283
+
284
+
285
+ def move_topmost(hwnd: int, x: int, y: int, w: int, h: int,
286
+ show: int = SW_SHOWNA, nonblocking: bool = False,
287
+ frame_changed: bool = False) -> bool:
288
+ """Move and size a window, pin it to the topmost band, and show it.
289
+
290
+ Shown with SW_SHOWNA by default, so it never steals focus. Pass
291
+ `nonblocking=True` from a thread that doesn't own the window, and
292
+ `frame_changed=True` after a style change, because Win32 requires SWP_FRAMECHANGED
293
+ for one to take effect on the window frame, and folding it into this call
294
+ saves a second round trip to the owning thread.
295
+
296
+ Returns whether Windows accepted the request. `ShowWindow`'s return value is
297
+ the window's *previous* visibility, not a success flag, so it isn't consulted.
298
+ """
299
+ u = user32()
300
+ flags = SWP_NOACTIVATE
301
+ if nonblocking:
302
+ flags |= SWP_ASYNCWINDOWPOS
303
+ if frame_changed:
304
+ flags |= SWP_FRAMECHANGED
305
+ ctypes.set_last_error(0)
306
+ ok = bool(u.SetWindowPos(hwnd, HWND_TOPMOST, x, y, w, h, flags))
307
+ if not ok:
308
+ log.warning("taskbargap: could not place window %s (WinError %d)",
309
+ hwnd, ctypes.get_last_error())
310
+ show_window(hwnd, show, nonblocking)
311
+ return ok
312
+
313
+
314
+ def assert_topmost(hwnd: int, nonblocking: bool = False) -> None:
315
+ """Re-pin to the topmost band without moving, resizing, or activating."""
316
+ flags = SWP_TOPMOST_ONLY | (SWP_ASYNCWINDOWPOS if nonblocking else 0)
317
+ user32().SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, flags)
318
+
319
+
320
+ def show_window(hwnd: int, show: int, nonblocking: bool = False) -> None:
321
+ u = user32()
322
+ (u.ShowWindowAsync if nonblocking else u.ShowWindow)(hwnd, show)
323
+
324
+
325
+ def hide(hwnd: int, nonblocking: bool = False) -> None:
326
+ show_window(hwnd, SW_HIDE, nonblocking)
327
+
328
+
329
+ def enable_dpi_awareness() -> bool:
330
+ """Opt into per-monitor-v2 DPI awareness. Call once, before creating windows.
331
+
332
+ Without it, Windows lies to the process about coordinates on any monitor
333
+ whose scaling differs from the primary, and a window aimed at the taskbar
334
+ gap lands somewhere else. Falls back to system-DPI awareness on Windows
335
+ older than 1703.
336
+
337
+ Returns whether the process ends up DPI-aware, rather than whether this particular
338
+ call was the one that did it. Awareness can only be set once per process, so
339
+ a second call, or a process already aware via its manifest (pywebview, a
340
+ frozen exe), still answers True rather than reporting a failure that isn't.
341
+ """
342
+ u = user32()
343
+ try:
344
+ if u.SetProcessDpiAwarenessContext(
345
+ ctypes.c_void_p(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)):
346
+ return True
347
+ except (AttributeError, OSError):
348
+ pass
349
+ try:
350
+ if u.SetProcessDPIAware():
351
+ return True
352
+ except (AttributeError, OSError):
353
+ pass
354
+ return process_is_dpi_aware()
taskbargap/py.typed ADDED
File without changes
taskbargap/watcher.py ADDED
@@ -0,0 +1,193 @@
1
+ """Keep a window fitted to the gap and top-most as the taskbar changes."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import threading
6
+ from collections.abc import Callable
7
+
8
+ from . import _win32
9
+ from ._detect import Gap, find_gap
10
+ from ._place import place
11
+
12
+ log = logging.getLogger("taskbargap")
13
+
14
+ #: Ignore sub-pixel-ish jitter in the detected edges; re-place only on a real move.
15
+ MOVE_TOLERANCE = 8
16
+ #: DPI factors are floats; compare with a tolerance, not ==.
17
+ SCALE_TOLERANCE = 0.01
18
+
19
+
20
+ class GapWatcher:
21
+ """Background watcher that re-fits `hwnd` when the taskbar gap changes.
22
+
23
+ Apps open and close, Explorer restarts, the screen changes resolution, the
24
+ window gets dragged to a monitor with different scaling. All of these move
25
+ the gap out from under a window placed once at startup. The watcher polls
26
+ (there is no reliable event for "the button strip resized"), re-asserts
27
+ top-most every tick, and re-places only when the gap has actually moved.
28
+
29
+ `on_change(gap)` fires from the watcher thread whenever the gap moves. The
30
+ initial placement in `start()` doesn't count, and a change is reported even
31
+ when the new gap is too narrow to place into, because "the gap shrank" is
32
+ exactly when an app wants to apply its own policy (yield when crowded, hide
33
+ over fullscreen). A raising callback is logged, not fatal.
34
+
35
+ Every window change the loop makes is posted, not sent (SWP_ASYNCWINDOWPOS /
36
+ ShowWindowAsync), because the window belongs to the app's UI thread: a
37
+ synchronous SetWindowPos from here would block until that thread pumped its
38
+ message loop, and a busy one would silently freeze the watcher. The flip
39
+ side is that a re-fit lands when the app next pumps, which is normal for a GUI app,
40
+ but nothing moves while the UI thread is blocked.
41
+
42
+ Not started automatically. Owns one daemon thread between `start()` and
43
+ `stop()`; usable as a context manager.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ hwnd: int,
49
+ *,
50
+ align: str = "right",
51
+ margin: int = 12,
52
+ min_width: int = 160,
53
+ width: int | None = None,
54
+ height: int | None = None,
55
+ interval: float = 1.0,
56
+ on_change: Callable[[Gap], None] | None = None,
57
+ ) -> None:
58
+ if interval <= 0:
59
+ raise ValueError(f"interval must be > 0, got {interval!r}")
60
+ self.hwnd = hwnd
61
+ self.align = align
62
+ self.margin = margin
63
+ self.min_width = min_width
64
+ self.width = width
65
+ self.height = height
66
+ self.interval = interval
67
+ self.on_change = on_change
68
+ self._thread: threading.Thread | None = None
69
+ # One Event per run, owned by the thread that run started. A shared Event
70
+ # could be cleared by a restart while an older thread was still inside a
71
+ # tick, which would revive it and leave two loops fighting over the window.
72
+ self._stop: threading.Event | None = None
73
+ self._gap: Gap | None = None
74
+ self._last_error: BaseException | None = None
75
+
76
+ # --- state ---------------------------------------------------------------
77
+
78
+ @property
79
+ def gap(self) -> Gap | None:
80
+ """The gap as of the last time it changed; None until the first tick."""
81
+ return self._gap
82
+
83
+ @property
84
+ def running(self) -> bool:
85
+ return self._thread is not None and self._thread.is_alive()
86
+
87
+ @property
88
+ def last_error(self) -> BaseException | None:
89
+ """The last exception the loop swallowed, so a caller can surface it.
90
+
91
+ The loop keeps going after an error (a transient Win32 failure during an
92
+ Explorer restart must not silently kill the watcher for the rest of the
93
+ session), but it does not hide it: every error is logged and kept here.
94
+ """
95
+ return self._last_error
96
+
97
+ # --- lifecycle -----------------------------------------------------------
98
+
99
+ def start(self) -> None:
100
+ """Place the window now, then start watching.
101
+
102
+ The first placement runs synchronously, on *your* thread, on purpose:
103
+ it is the call that applies the tool-window/top-most styles, and a style
104
+ change is the one Win32 operation here with no asynchronous form, and it
105
+ would block the watcher thread until the app pumped messages. Doing it
106
+ here means the loop only ever moves an already-styled window, which it
107
+ can do without waiting on anyone.
108
+
109
+ Call it from the thread that owns the window (normally the UI thread).
110
+ """
111
+ if self.running:
112
+ raise RuntimeError("GapWatcher is already running")
113
+ gap = find_gap()
114
+ if gap is not None:
115
+ self._gap = gap
116
+ self._place(gap, nonblocking=False)
117
+ stop = threading.Event()
118
+ self._stop = stop
119
+ self._thread = threading.Thread(
120
+ target=self._loop, args=(stop,), name="taskbargap-watcher", daemon=True)
121
+ self._thread.start()
122
+
123
+ def stop(self, timeout: float | None = 5.0) -> bool:
124
+ """Ask the thread to finish and wait for it. Safe to call when stopped.
125
+
126
+ Returns whether it actually finished. A thread that outlives `timeout`
127
+ is kept rather than forgotten, so `running` keeps telling the truth, and it
128
+ will still exit on its own, because the Event it waits on is its own and
129
+ stays set no matter how many times the watcher is restarted.
130
+ """
131
+ if self._stop is not None:
132
+ self._stop.set()
133
+ thread = self._thread
134
+ if thread is None or thread is threading.current_thread():
135
+ self._thread = None
136
+ return True
137
+ thread.join(timeout)
138
+ if thread.is_alive():
139
+ log.warning("taskbargap: watcher thread did not stop within %ss; "
140
+ "it will exit after its current tick", timeout)
141
+ return False
142
+ self._thread = None
143
+ return True
144
+
145
+ # Annotated by name rather than `typing.Self`, which needs 3.11 or a
146
+ # typing_extensions dependency. This package has no dependencies and the
147
+ # floor is 3.10, so the postponed annotation stays a plain string.
148
+ def __enter__(self) -> GapWatcher: # noqa: PYI034
149
+ self.start()
150
+ return self
151
+
152
+ def __exit__(self, *exc) -> None:
153
+ self.stop()
154
+
155
+ # --- loop ----------------------------------------------------------------
156
+
157
+ def _changed(self, gap: Gap) -> bool:
158
+ old = self._gap
159
+ return (
160
+ old is None
161
+ or abs(gap.left - old.left) > MOVE_TOLERANCE
162
+ or abs(gap.right - old.right) > MOVE_TOLERANCE
163
+ or gap.top != old.top
164
+ or gap.bottom != old.bottom
165
+ or abs(gap.scale - old.scale) > SCALE_TOLERANCE
166
+ )
167
+
168
+ def _place(self, gap: Gap, nonblocking: bool) -> bool:
169
+ return place(self.hwnd, align=self.align, margin=self.margin,
170
+ min_width=self.min_width, width=self.width,
171
+ height=self.height, gap=gap, nonblocking=nonblocking)
172
+
173
+ def _loop(self, stop: threading.Event) -> None:
174
+ while not stop.wait(self.interval):
175
+ try:
176
+ if not _win32.is_window(self.hwnd):
177
+ log.debug("taskbargap: window %s is gone, watcher exiting", self.hwnd)
178
+ return
179
+ _win32.assert_topmost(self.hwnd, nonblocking=True)
180
+ gap = find_gap()
181
+ if gap is None or not self._changed(gap):
182
+ continue
183
+ self._gap = gap
184
+ self._place(gap, nonblocking=True)
185
+ if self.on_change is not None:
186
+ try:
187
+ self.on_change(gap)
188
+ except Exception as exc: # caller's callback
189
+ self._last_error = exc
190
+ log.exception("taskbargap: on_change callback raised")
191
+ except Exception as exc: # keep watching
192
+ self._last_error = exc
193
+ log.exception("taskbargap: watcher tick failed")
@@ -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,12 @@
1
+ taskbargap/__init__.py,sha256=dJQg-ealIA9E28PYBr4tOJdOeeRFYXzrjyYkT7xFLNU,795
2
+ taskbargap/_detect.py,sha256=qQLaE_GneBJuriIZSwv80eKcJu0fYo48yZm7Wl_KVGg,4546
3
+ taskbargap/_geometry.py,sha256=o-p9oxjbsVwffZRotvvr7iY_1G_zSMJpdOM3-HpXPJU,5149
4
+ taskbargap/_place.py,sha256=vfLUe2GKN2Ykm42uQsFvcvKkUuTcvXtb8fUS2O4ZW84,3235
5
+ taskbargap/_win32.py,sha256=goTyHeMJdlF6aVh9mdrK6Z7df9rNqxTwafoq9yb3RLI,13298
6
+ taskbargap/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ taskbargap/watcher.py,sha256=VggncibF5rOKMOcM1Q7wIu29mObOw4go_1Xipp4rpEI,8056
8
+ taskbargap-0.1.0.dist-info/licenses/LICENSE,sha256=RFeN2Gtr2SBl8CR4ye_x7X28v7TRk9yNonWzqmS9KTY,1063
9
+ taskbargap-0.1.0.dist-info/METADATA,sha256=dxdqUeTkYbZ0LlIeuGmH4HrXPTRZ4Fe7ZsbyKQoc45s,8751
10
+ taskbargap-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
11
+ taskbargap-0.1.0.dist-info/top_level.txt,sha256=rZ5Eb72xtD1gLMm4mreF-pa6CFEpeRgCed_-zW0flVk,11
12
+ taskbargap-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ taskbargap