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 +26 -0
- taskbargap/_detect.py +111 -0
- taskbargap/_geometry.py +126 -0
- taskbargap/_place.py +74 -0
- taskbargap/_win32.py +354 -0
- taskbargap/py.typed +0 -0
- taskbargap/watcher.py +193 -0
- taskbargap-0.1.0.dist-info/METADATA +167 -0
- taskbargap-0.1.0.dist-info/RECORD +12 -0
- taskbargap-0.1.0.dist-info/WHEEL +5 -0
- taskbargap-0.1.0.dist-info/licenses/LICENSE +21 -0
- taskbargap-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|
taskbargap/_geometry.py
ADDED
|
@@ -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
|
+
[](#honest-limitations)
|
|
30
|
+
[](https://pypi.org/project/taskbargap/)
|
|
31
|
+
[](https://github.com/paone9/taskbargap/actions/workflows/ci.yml)
|
|
32
|
+
[](https://github.com/paone9/taskbargap/actions/workflows/codeql.yml)
|
|
33
|
+
[](https://scorecard.dev/viewer/?uri=github.com/paone9/taskbargap)
|
|
34
|
+
[](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,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
|