Find the empty stretch of the Windows taskbar — the gap between your app buttons and the system tray — and place a top-most window in it, correctly, across multiple monitors and per-monitor DPI scaling.
Windows leaves that strip unused, and putting a small always-on widget there costs
no screen space. Doing it right is surprisingly fiddly: detecting the gap across
Windows 10 and 11 taskbar internals, handling per-monitor DPI (physical vs logical
pixels), keeping the window fitted as apps open and close, and getting a top-most
tool window that owns its own input without grabbing a taskbar button. taskbargap
packages exactly that, and nothing else.
Windows 10/11 only. It's built on Win32 APIs. There is no macOS or Linux build. The package imports anywhere, but calling into it off Windows raises
NotWindowsError.
Not published yet. v0.1.0 is built and tested, but it isn't on PyPI, so
pip install taskbargap does not work today. From a clone:
pip install .
Only dependency: the standard library (ctypes). No third-party packages. It
writes nothing — no files, no registry, no config — and makes no network calls;
see SECURITY.md for the full list of Win32 calls it makes.
Before relying on it, read what has and hasn't been validated.
import taskbargap
taskbargap.enable_dpi_awareness() # call once, before creating windows
gap = taskbargap.find_gap() # -> Gap | None
if gap:
print(gap.left, gap.right, gap.width, gap.scale)
# Place a window (by HWND) into the gap, right-aligned near the tray:
taskbargap.place(my_hwnd, align="right", margin=12)Keep it fitted as the taskbar changes (apps open/close, Explorer restarts, resolution changes):
watcher = taskbargap.GapWatcher(my_hwnd, align="right")
watcher.start() # places it now, then re-fits on change and re-asserts top-most
# ...
watcher.stop()start() places the window immediately, on your thread, and returns; the polling
happens on a daemon thread. Pass on_change=fn to be told when the gap moves —
including when it becomes too narrow to fit, which is when an app usually wants to
get out of the way.
| Object | Purpose |
|---|---|
find_gap() -> Gap | None |
Detect the empty taskbar gap on the primary monitor. None if there isn't a usable one. |
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. |
GapWatcher(hwnd, *, align, margin, min_width, width, height, interval=1.0, on_change=None) |
Background watcher: keeps the window fitted and top-most as the taskbar changes. .start() / .stop() -> bool, or use it as a context manager. |
enable_dpi_awareness() -> bool |
Opt into per-monitor-v2 DPI awareness (falls back gracefully on old Windows). |
Gap |
Frozen dataclass: left, right, top, bottom (physical px), scale (DPI factor), monitor (HMONITOR), measured, plus width / height. |
NotWindowsError |
Raised by any Win32 call when you're not on Windows. |
Every coordinate the library hands you is a physical pixel in the coordinate
space your process actually sees. Gap.scale is the divisor for toolkits that
scale window position by DPI (pywebview, WinForms):
x_logical = round(gap.left / gap.scale)Call enable_dpi_awareness() before creating any window. Without it Windows
virtualises coordinates — a 3840px screen at 175% looks 2194px wide — and a window
aimed at the gap lands somewhere else.
Whatever you do, scale describes the space your process addresses windows in,
not the monitor's spec sheet: 1.0 for a DPI-unaware process, the system DPI for a
system-aware one, and the taskbar monitor's own factor only for a per-monitor-aware
one. (The distinction is not academic — GetDpiForWindow reports the taskbar's real
175% to an unaware caller that sees a 2194px desktop, and dividing by that puts the
window a third of the screen from where it belongs.) So the library stays
self-consistent in every mode; it's just that only the per-monitor mode gets you
real screen pixels and an unscaled window.
- No metrics, rendering, or UI — you bring the window, it does the placement.
- No decision about whether to be visible (fullscreen-hide, yielding to a crowded
taskbar) — that's app policy;
find_gap()gives you the facts to decide. - No cross-platform panels — Windows taskbar only.
- When the button strip can't be measured. The app-button edge is read from the
taskbar's own
ReBarWindow32/MSTaskListWClasswindows. On stock Windows 11 (build 26200) those exist and track the buttons in both left-aligned and centred layouts — measured here both ways, so the common cases are the measured ones. Where a shell doesn't host them,find_gap()falls back to assuming the buttons end 30% across the bar and setsmeasured=False. Be clear-eyed about that fallback: it's a guess inherited from GlintBar, it has never been exercised on a real machine, and it can name a left edge that still has buttons on it. CheckGap.measuredif covering a button would matter to you. - Rects are sanity-checked, so detection degrades rather than lies. A taskbar
child that reports a dead or off-bar rectangle (Explorer restarting, a stale
hidden window) is ignored rather than believed, and you get the heuristic with
measured=False. A plausible edge that leaves no room is respected: a genuinely full taskbar returnsNone, because inventing a gap there would cover buttons. - Primary taskbar only. Secondary monitors get their own
Shell_SecondaryTrayWndbars; v0.1 reads the primaryShell_TrayWnd. The gap it returns is on whichever monitor that taskbar is on, at that monitor's DPI. - Horizontal taskbars only. A taskbar docked left or right (Windows 10) has no
horizontal gap to speak of;
find_gap()returnsNonerather than guess. - The watcher needs your app to pump messages. It posts its moves rather than sending them, so it can never be blocked by a busy UI thread — the flip side is that a re-fit lands the next time your app processes messages. Normal GUI apps do that constantly; a wedged one won't move.
- Auto-hide taskbars aren't special-cased: you get the gap of the bar wherever it currently is, sliding included.
MIT.