deskband11lib 1.5.0__py3-none-win_amd64.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.
@@ -0,0 +1,32 @@
1
+ """Host Python UI windows inside the Windows 11 taskbar."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .dpi import enable_per_monitor_dpi_awareness
6
+ from .enums import Easing, HostEventKind, ResultCode, TaskbarAlignment, TaskbarContentPlacement, TaskbarEdge
7
+ from .errors import Deskband11LibError
8
+ from .events import EventHook, HostEvent
9
+ from .host import TaskbarContentHost, is_window_alive
10
+ from .options import STRETCH, TaskbarContentHostOptions
11
+
12
+ try:
13
+ __version__ = version("deskband11lib")
14
+ except PackageNotFoundError:
15
+ __version__ = "0.0.0"
16
+
17
+ __all__ = [
18
+ "STRETCH",
19
+ "Deskband11LibError",
20
+ "Easing",
21
+ "EventHook",
22
+ "HostEvent",
23
+ "HostEventKind",
24
+ "ResultCode",
25
+ "TaskbarAlignment",
26
+ "TaskbarContentHost",
27
+ "TaskbarContentHostOptions",
28
+ "TaskbarContentPlacement",
29
+ "TaskbarEdge",
30
+ "enable_per_monitor_dpi_awareness",
31
+ "is_window_alive",
32
+ ]
@@ -0,0 +1,106 @@
1
+ """ctypes declarations for deskband11lib.dll, mirroring include/deskband11lib.h."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import functools
7
+ import os
8
+ import sysconfig
9
+ from pathlib import Path
10
+
11
+ ABI_VERSION = 1
12
+ LIBRARY_PATH_ENVIRONMENT_VARIABLE = "DESKBAND11LIB_LIBRARY_PATH"
13
+ _RUNTIME_IDENTIFIERS = {"win-amd64": "win-x64", "win-arm64": "win-arm64", "win32": "win-x86"}
14
+
15
+
16
+ class NativeOptions(ctypes.Structure):
17
+ _fields_ = [
18
+ ("struct_size", ctypes.c_uint32),
19
+ ("animate_layout_changes", ctypes.c_int32),
20
+ ("high_refresh_rate_mode", ctypes.c_int32),
21
+ ("layout_animation_easing", ctypes.c_int32),
22
+ ("track_taskbar_buttons", ctypes.c_int32),
23
+ ("track_notification_area", ctypes.c_int32),
24
+ ("preferred_monitor_identity", ctypes.c_int32),
25
+ ("allow_fixed_slot_resize", ctypes.c_int32),
26
+ ("placement", ctypes.c_int32),
27
+ ("manual_slot_priority", ctypes.c_uint32),
28
+ ("layout_animation_duration", ctypes.c_double),
29
+ ("preferred_width", ctypes.c_double),
30
+ ("preferred_height", ctypes.c_double),
31
+ ("layout_refresh_interval", ctypes.c_double),
32
+ ]
33
+
34
+
35
+ class NativeEvent(ctypes.Structure):
36
+ _fields_ = [
37
+ ("kind", ctypes.c_int32),
38
+ ("result", ctypes.c_int32),
39
+ ("taskbar_edge", ctypes.c_int32),
40
+ ("reserved", ctypes.c_int32),
41
+ ("max_width", ctypes.c_double),
42
+ ("width", ctypes.c_double),
43
+ ("height", ctypes.c_double),
44
+ ]
45
+
46
+
47
+ EventCallback = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(NativeEvent))
48
+
49
+
50
+ def resolve_library_path() -> Path:
51
+ override = os.environ.get(LIBRARY_PATH_ENVIRONMENT_VARIABLE)
52
+ if override:
53
+ return Path(override)
54
+
55
+ platform = sysconfig.get_platform()
56
+ runtime_identifier = _RUNTIME_IDENTIFIERS.get(platform)
57
+ if runtime_identifier is None:
58
+ raise OSError(f"deskband11lib does not support the {platform} platform.")
59
+ return Path(__file__).resolve().parent / "bin" / runtime_identifier / "deskband11lib.dll"
60
+
61
+
62
+ @functools.cache
63
+ def load_library() -> ctypes.CDLL:
64
+ path = resolve_library_path()
65
+ if not path.is_file():
66
+ raise OSError(f"The deskband11lib native library was not found at {path}.")
67
+
68
+ library = ctypes.CDLL(str(path))
69
+ _declare_functions(library)
70
+ abi_version = library.db11l_get_abi_version()
71
+ if abi_version != ABI_VERSION:
72
+ raise OSError(f"{path} implements ABI version {abi_version}, but this package requires version {ABI_VERSION}.")
73
+ return library
74
+
75
+
76
+ def get_last_error_message() -> str:
77
+ library = load_library()
78
+ size = library.db11l_get_last_error_message(None, 0)
79
+ buffer = ctypes.create_string_buffer(size)
80
+ library.db11l_get_last_error_message(buffer, size)
81
+ return buffer.value.decode("utf-8", "replace")
82
+
83
+
84
+ def _declare_functions(library: ctypes.CDLL) -> None:
85
+ host = ctypes.c_void_p
86
+ int32_pointer = ctypes.POINTER(ctypes.c_int32)
87
+ signatures = {
88
+ "db11l_get_abi_version": (ctypes.c_uint32, []),
89
+ "db11l_get_last_error_message": (ctypes.c_int32, [ctypes.c_char_p, ctypes.c_int32]),
90
+ "db11l_options_init": (ctypes.c_int32, [ctypes.POINTER(NativeOptions), ctypes.c_uint32]),
91
+ "db11l_host_create": (ctypes.c_int32, [ctypes.c_void_p, ctypes.POINTER(NativeOptions), EventCallback, ctypes.c_void_p, ctypes.POINTER(host)]),
92
+ "db11l_host_destroy": (ctypes.c_int32, [host]),
93
+ "db11l_host_attach": (ctypes.c_int32, [host]),
94
+ "db11l_host_attach_when_layout_ready": (ctypes.c_int32, [host]),
95
+ "db11l_host_detach": (ctypes.c_int32, [host]),
96
+ "db11l_host_refresh_layout": (ctypes.c_int32, [host]),
97
+ "db11l_host_poll_event": (ctypes.c_int32, [host, ctypes.POINTER(NativeEvent)]),
98
+ "db11l_host_is_attached": (ctypes.c_int32, [host, int32_pointer]),
99
+ "db11l_host_get_taskbar_edge": (ctypes.c_int32, [host, int32_pointer]),
100
+ "db11l_host_get_taskbar_alignment": (ctypes.c_int32, [host, int32_pointer]),
101
+ "db11l_is_window_alive": (ctypes.c_int32, [ctypes.c_void_p]),
102
+ }
103
+ for name, (result_type, argument_types) in signatures.items():
104
+ function = getattr(library, name)
105
+ function.restype = result_type
106
+ function.argtypes = argument_types
deskband11lib/dpi.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import ctypes
4
+
5
+ _PER_MONITOR_AWARE_V2 = -4
6
+
7
+
8
+ def enable_per_monitor_dpi_awareness() -> bool:
9
+ """Makes the process per-monitor DPI aware, which hosted windows require.
10
+
11
+ Call this before any window is created. Qt 6 already does this. Returns False when the process DPI
12
+ awareness was set earlier, for example by a UI toolkit or by the manifest of the executable.
13
+ """
14
+ user32 = ctypes.WinDLL("user32", use_last_error=True)
15
+ user32.SetProcessDpiAwarenessContext.argtypes = [ctypes.c_void_p]
16
+ user32.SetProcessDpiAwarenessContext.restype = ctypes.c_int
17
+ return user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(_PER_MONITOR_AWARE_V2)) != 0
deskband11lib/enums.py ADDED
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import IntEnum
4
+ from typing import TypeVar
5
+
6
+ _EnumType = TypeVar("_EnumType", bound=IntEnum)
7
+
8
+
9
+ class ResultCode(IntEnum):
10
+ OK = 0
11
+ NO_EVENT = 1
12
+ INVALID_ARGUMENT = -1
13
+ INVALID_HANDLE = -2
14
+ WRONG_THREAD = -3
15
+ INVALID_STATE = -4
16
+ UNSUPPORTED_DPI_AWARENESS = -5
17
+ WIN32_ERROR = -6
18
+ UNEXPECTED = -100
19
+
20
+
21
+ class TaskbarContentPlacement(IntEnum):
22
+ AUTO = 0
23
+ LEFT_EDGE = 1
24
+ BEFORE_NOTIFICATION_AREA = 2
25
+ BEFORE_START_BUTTON = 3
26
+
27
+
28
+ class Easing(IntEnum):
29
+ LINEAR = 0
30
+ SINE_IN = 1
31
+ SINE_OUT = 2
32
+ SINE_IN_OUT = 3
33
+ QUADRATIC_IN = 4
34
+ QUADRATIC_OUT = 5
35
+ QUADRATIC_IN_OUT = 6
36
+ CUBIC_IN = 7
37
+ CUBIC_OUT = 8
38
+ CUBIC_IN_OUT = 9
39
+ QUARTIC_IN = 10
40
+ QUARTIC_OUT = 11
41
+ QUARTIC_IN_OUT = 12
42
+ QUINTIC_IN = 13
43
+ QUINTIC_OUT = 14
44
+ QUINTIC_IN_OUT = 15
45
+ EXPONENTIAL_IN = 16
46
+ EXPONENTIAL_OUT = 17
47
+ EXPONENTIAL_IN_OUT = 18
48
+ CIRCLE_IN = 19
49
+ CIRCLE_OUT = 20
50
+ CIRCLE_IN_OUT = 21
51
+
52
+
53
+ class TaskbarEdge(IntEnum):
54
+ UNKNOWN = 0
55
+ LEFT = 1
56
+ TOP = 2
57
+ RIGHT = 3
58
+ BOTTOM = 4
59
+
60
+ @property
61
+ def is_vertical(self) -> bool:
62
+ return self in (TaskbarEdge.LEFT, TaskbarEdge.RIGHT)
63
+
64
+
65
+ class TaskbarAlignment(IntEnum):
66
+ UNKNOWN = 0
67
+ LEFT = 1
68
+ CENTER = 2
69
+
70
+
71
+ class HostEventKind(IntEnum):
72
+ NONE = 0
73
+ ATTACHED = 1
74
+ ATTACH_FAILED = 2
75
+ TASKBAR_WINDOW_RECREATED = 3
76
+ TASKBAR_WINDOW_DISAPPEARED = 4
77
+ TASKBAR_DPI_CHANGED = 5
78
+ TASKBAR_WINDOW_RECREATION_REQUIRED = 6
79
+ TASKBAR_EDGE_CHANGED = 7
80
+ CONTENT_BOUNDS_CHANGED = 8
81
+ ERROR = 9
82
+
83
+
84
+ def to_enum(enum_type: type[_EnumType], value: int, default: _EnumType | int) -> _EnumType | int:
85
+ """Converts a value reported by the native library, keeping values added by newer library versions usable."""
86
+ try:
87
+ return enum_type(value)
88
+ except ValueError:
89
+ return default
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from ._bindings import get_last_error_message
4
+ from .enums import ResultCode, to_enum
5
+
6
+
7
+ class Deskband11LibError(RuntimeError):
8
+ """Raised when the native library reports an error. result holds the ResultCode of the failure."""
9
+
10
+ def __init__(self, result: ResultCode | int, message: str) -> None:
11
+ super().__init__(message)
12
+ self.result = result
13
+
14
+
15
+ def check_result(result: int) -> int:
16
+ if result < 0:
17
+ raise Deskband11LibError(to_enum(ResultCode, result, result), get_last_error_message() or f"The native call failed with result {result}.")
18
+ return result
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from typing import Generic, TypeVar
7
+
8
+ from .enums import HostEventKind, ResultCode, TaskbarEdge
9
+
10
+ _logger = logging.getLogger("deskband11lib")
11
+ _ValueType = TypeVar("_ValueType")
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class HostEvent:
16
+ """An event raised by a TaskbarContentHost.
17
+
18
+ max_width, width and height are set for CONTENT_BOUNDS_CHANGED and are in effective pixels.
19
+ error_message is set when result is negative, for ATTACH_FAILED and ERROR.
20
+ """
21
+
22
+ kind: HostEventKind | int
23
+ result: ResultCode | int
24
+ taskbar_edge: TaskbarEdge | int
25
+ max_width: float = 0.0
26
+ width: float = 0.0
27
+ height: float = 0.0
28
+ error_message: str | None = None
29
+
30
+
31
+ class EventHook(Generic[_ValueType]):
32
+ """Handlers called in the order they were connected. An exception from one handler is logged and does not stop the others."""
33
+
34
+ def __init__(self) -> None:
35
+ self._handlers: list[Callable[[_ValueType], object]] = []
36
+
37
+ def connect(self, handler: Callable[[_ValueType], object]) -> Callable[[_ValueType], object]:
38
+ """Adds a handler and returns it, so connect can also be used as a decorator."""
39
+ self._handlers.append(handler)
40
+ return handler
41
+
42
+ def disconnect(self, handler: Callable[[_ValueType], object]) -> None:
43
+ self._handlers.remove(handler)
44
+
45
+ def emit(self, value: _ValueType) -> None:
46
+ for handler in list(self._handlers):
47
+ try:
48
+ handler(value)
49
+ except Exception:
50
+ _logger.exception("An event handler raised an exception.")
deskband11lib/host.py ADDED
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ import ctypes
4
+ import dataclasses
5
+ import logging
6
+ from collections.abc import Callable
7
+ from concurrent.futures import Future
8
+
9
+ from ._bindings import EventCallback, NativeEvent, get_last_error_message, load_library
10
+ from .enums import HostEventKind, ResultCode, TaskbarAlignment, TaskbarEdge, to_enum
11
+ from .errors import Deskband11LibError, check_result
12
+ from .events import EventHook, HostEvent
13
+ from .options import TaskbarContentHostOptions
14
+
15
+ _logger = logging.getLogger("deskband11lib")
16
+
17
+
18
+ class TaskbarContentHost:
19
+ """Places a top-level window inside the Windows 11 taskbar and keeps it aligned with the taskbar layout.
20
+
21
+ A host belongs to the thread that creates it, and that thread must run a Win32 message loop, such as the
22
+ main loop of Qt, Tk or wxPython. Call every method on that thread. Events are raised on the same thread
23
+ from the message loop, never from inside another call to the host.
24
+
25
+ The window must be per-monitor DPI aware. Create it hidden and without a frame, attach it, then show it
26
+ without activating it. When taskbar_window_recreation_required is raised, close the host, create the
27
+ window again and create a new host for it.
28
+
29
+ Events:
30
+ attached: attach_when_layout_ready() completed.
31
+ attach_failed: attach_when_layout_ready() failed. The event carries the error message.
32
+ taskbar_window_recreated: Explorer restarted and recreated the taskbar.
33
+ taskbar_window_disappeared: The taskbar the window was placed on disappeared, for example because its monitor was disconnected.
34
+ taskbar_dpi_changed: The system DPI changed. The window has been hidden and detached.
35
+ taskbar_window_recreation_required: Raised together with each of the three events above.
36
+ taskbar_edge_changed: The taskbar moved to another screen edge, including the first layout after attaching.
37
+ content_bounds_changed: The window was given a new size.
38
+ error: An unexpected error occurred while the host updated the layout.
39
+ """
40
+
41
+ def __init__(self, window_handle: int, options: TaskbarContentHostOptions | None = None) -> None:
42
+ self._library = load_library()
43
+ self._window_handle = int(window_handle)
44
+ self._options = options if options is not None else TaskbarContentHostOptions()
45
+ self._host_handle: int | None = None
46
+ self._pending_attaches: list[Future[None]] = []
47
+ self._event_callback = EventCallback(self._on_native_event)
48
+
49
+ self.attached: EventHook[HostEvent] = EventHook()
50
+ self.attach_failed: EventHook[HostEvent] = EventHook()
51
+ self.taskbar_window_recreated: EventHook[HostEvent] = EventHook()
52
+ self.taskbar_window_disappeared: EventHook[HostEvent] = EventHook()
53
+ self.taskbar_dpi_changed: EventHook[HostEvent] = EventHook()
54
+ self.taskbar_window_recreation_required: EventHook[HostEvent] = EventHook()
55
+ self.taskbar_edge_changed: EventHook[HostEvent] = EventHook()
56
+ self.content_bounds_changed: EventHook[HostEvent] = EventHook()
57
+ self.error: EventHook[HostEvent] = EventHook()
58
+ self._event_hooks = {
59
+ HostEventKind.ATTACHED: self.attached,
60
+ HostEventKind.ATTACH_FAILED: self.attach_failed,
61
+ HostEventKind.TASKBAR_WINDOW_RECREATED: self.taskbar_window_recreated,
62
+ HostEventKind.TASKBAR_WINDOW_DISAPPEARED: self.taskbar_window_disappeared,
63
+ HostEventKind.TASKBAR_DPI_CHANGED: self.taskbar_dpi_changed,
64
+ HostEventKind.TASKBAR_WINDOW_RECREATION_REQUIRED: self.taskbar_window_recreation_required,
65
+ HostEventKind.TASKBAR_EDGE_CHANGED: self.taskbar_edge_changed,
66
+ HostEventKind.CONTENT_BOUNDS_CHANGED: self.content_bounds_changed,
67
+ HostEventKind.ERROR: self.error,
68
+ }
69
+
70
+ native_options = self._options.to_native()
71
+ host_handle = ctypes.c_void_p()
72
+ check_result(self._library.db11l_host_create(self._window_handle, ctypes.byref(native_options), self._event_callback, None, ctypes.byref(host_handle)))
73
+ self._host_handle = host_handle.value
74
+
75
+ def __enter__(self) -> TaskbarContentHost:
76
+ return self
77
+
78
+ def __exit__(self, *_: object) -> None:
79
+ self.close()
80
+
81
+ @property
82
+ def window_handle(self) -> int:
83
+ return self._window_handle
84
+
85
+ @property
86
+ def options(self) -> TaskbarContentHostOptions:
87
+ return dataclasses.replace(self._options)
88
+
89
+ @property
90
+ def is_closed(self) -> bool:
91
+ return self._host_handle is None
92
+
93
+ @property
94
+ def is_attached(self) -> bool:
95
+ return self._read_value(self._library.db11l_host_is_attached) != 0
96
+
97
+ @property
98
+ def taskbar_edge(self) -> TaskbarEdge | int:
99
+ """The edge the taskbar sits on. UNKNOWN until the first layout."""
100
+ return to_enum(TaskbarEdge, self._read_value(self._library.db11l_host_get_taskbar_edge), TaskbarEdge.UNKNOWN)
101
+
102
+ @property
103
+ def is_taskbar_vertical(self) -> bool:
104
+ return self.taskbar_edge in (TaskbarEdge.LEFT, TaskbarEdge.RIGHT)
105
+
106
+ @property
107
+ def taskbar_alignment(self) -> TaskbarAlignment | int:
108
+ return to_enum(TaskbarAlignment, self._read_value(self._library.db11l_host_get_taskbar_alignment), TaskbarAlignment.UNKNOWN)
109
+
110
+ def attach(self) -> None:
111
+ """Attaches the window immediately. Raises Deskband11LibError when the taskbar window cannot be found."""
112
+ check_result(self._library.db11l_host_attach(self._require_handle()))
113
+
114
+ def attach_when_layout_ready(self) -> Future[None]:
115
+ """Starts attaching once the taskbar layout can be measured and returns immediately.
116
+
117
+ The returned future completes on the host thread when attached or attach_failed is raised.
118
+ """
119
+ host_handle = self._require_handle()
120
+ future: Future[None] = Future()
121
+ self._pending_attaches.append(future)
122
+ try:
123
+ check_result(self._library.db11l_host_attach_when_layout_ready(host_handle))
124
+ except BaseException:
125
+ self._pending_attaches.remove(future)
126
+ raise
127
+ return future
128
+
129
+ def detach(self) -> None:
130
+ """Restores the original parent and style of the window."""
131
+ check_result(self._library.db11l_host_detach(self._require_handle()))
132
+
133
+ def refresh_layout(self) -> None:
134
+ """Recalculates the layout immediately instead of waiting for the next refresh."""
135
+ check_result(self._library.db11l_host_refresh_layout(self._require_handle()))
136
+
137
+ def close(self) -> None:
138
+ """Detaches the window when attached and releases the host. Can be called from an event handler."""
139
+ if self._host_handle is None:
140
+ return
141
+
142
+ check_result(self._library.db11l_host_destroy(self._host_handle))
143
+ self._host_handle = None
144
+ pending_attaches, self._pending_attaches = self._pending_attaches, []
145
+ for future in pending_attaches:
146
+ future.cancel()
147
+
148
+ def _require_handle(self) -> int:
149
+ if self._host_handle is None:
150
+ raise Deskband11LibError(ResultCode.INVALID_HANDLE, "The host is closed.")
151
+ return self._host_handle
152
+
153
+ def _read_value(self, function: Callable[..., int]) -> int:
154
+ value = ctypes.c_int32()
155
+ check_result(function(self._require_handle(), ctypes.byref(value)))
156
+ return value.value
157
+
158
+ def _on_native_event(self, _user_data: object, native_event_pointer: ctypes._Pointer[NativeEvent]) -> None:
159
+ # The native caller cannot receive Python exceptions, so failures are logged here.
160
+ try:
161
+ self._dispatch_event(self._create_event(native_event_pointer.contents))
162
+ except Exception:
163
+ _logger.exception("Failed to dispatch a taskbar host event.")
164
+
165
+ @staticmethod
166
+ def _create_event(native_event: NativeEvent) -> HostEvent:
167
+ return HostEvent(
168
+ kind=to_enum(HostEventKind, native_event.kind, native_event.kind),
169
+ result=to_enum(ResultCode, native_event.result, native_event.result),
170
+ taskbar_edge=to_enum(TaskbarEdge, native_event.taskbar_edge, TaskbarEdge.UNKNOWN),
171
+ max_width=native_event.max_width,
172
+ width=native_event.width,
173
+ height=native_event.height,
174
+ error_message=get_last_error_message() if native_event.result < 0 else None,
175
+ )
176
+
177
+ def _dispatch_event(self, event: HostEvent) -> None:
178
+ event_hook = self._event_hooks.get(event.kind)
179
+ if event_hook is not None:
180
+ event_hook.emit(event)
181
+
182
+ if event.kind not in (HostEventKind.ATTACHED, HostEventKind.ATTACH_FAILED):
183
+ return
184
+
185
+ pending_attaches, self._pending_attaches = self._pending_attaches, []
186
+ for future in pending_attaches:
187
+ if future.done():
188
+ continue
189
+ if event.kind == HostEventKind.ATTACHED:
190
+ future.set_result(None)
191
+ else:
192
+ future.set_exception(Deskband11LibError(event.result, event.error_message or "Attaching to the taskbar failed."))
193
+
194
+
195
+ def is_window_alive(window_handle: int) -> bool:
196
+ """Returns whether window_handle still refers to an existing window, for example after Explorer restarted."""
197
+ return load_library().db11l_is_window_alive(int(window_handle)) != 0
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import ctypes
4
+ import sys
5
+ from dataclasses import dataclass
6
+
7
+ from ._bindings import NativeOptions, load_library
8
+ from .enums import Easing, TaskbarContentPlacement
9
+ from .errors import check_result
10
+
11
+ STRETCH = sys.float_info.max
12
+ """A preferred_width that fills the space left after fixed-width hosts are served."""
13
+
14
+
15
+ @dataclass
16
+ class TaskbarContentHostOptions:
17
+ """Options for a TaskbarContentHost. Sizes are in effective pixels and durations in milliseconds.
18
+
19
+ preferred_width is the length along the taskbar and preferred_height the size across it, so on a left or
20
+ right taskbar they map to the height and the width of the window.
21
+ """
22
+
23
+ preferred_width: float = 360.0
24
+ """Length along the taskbar. Use STRETCH to fill the space left by fixed-width hosts."""
25
+
26
+ preferred_height: float = 48.0
27
+ """Size across the taskbar, capped by its thickness."""
28
+
29
+ placement: TaskbarContentPlacement = TaskbarContentPlacement.AUTO
30
+ preferred_monitor_identity: int = 0
31
+ """0 is the primary taskbar. 1 and above are secondary taskbars ordered from left to right."""
32
+
33
+ manual_slot_priority: int = 65535
34
+ """0 to 65535. Lower values are placed first when several hosts share an area. 65535 orders by arrival."""
35
+
36
+ allow_fixed_slot_resize: bool = True
37
+ """Lets fixed-width hosts shrink proportionally when the available space runs out."""
38
+
39
+ animate_layout_changes: bool = True
40
+ high_refresh_rate_mode: bool = True
41
+ """Matches the animation frame interval to the refresh rate of the monitor."""
42
+
43
+ layout_animation_duration: float = 500.0
44
+ layout_animation_easing: Easing = Easing.CIRCLE_OUT
45
+ track_taskbar_buttons: bool = True
46
+ """Measures the taskbar buttons through UI Automation so the window never covers them."""
47
+
48
+ track_notification_area: bool = True
49
+ layout_refresh_interval: float = 500.0
50
+
51
+ def to_native(self) -> NativeOptions:
52
+ if not 0 <= self.manual_slot_priority <= 65535:
53
+ raise ValueError("manual_slot_priority must be between 0 and 65535.")
54
+
55
+ native_options = NativeOptions()
56
+ check_result(load_library().db11l_options_init(ctypes.byref(native_options), ctypes.sizeof(NativeOptions)))
57
+ native_options.preferred_width = self.preferred_width
58
+ native_options.preferred_height = self.preferred_height
59
+ native_options.placement = int(self.placement)
60
+ native_options.preferred_monitor_identity = self.preferred_monitor_identity
61
+ native_options.manual_slot_priority = self.manual_slot_priority
62
+ native_options.allow_fixed_slot_resize = int(self.allow_fixed_slot_resize)
63
+ native_options.animate_layout_changes = int(self.animate_layout_changes)
64
+ native_options.high_refresh_rate_mode = int(self.high_refresh_rate_mode)
65
+ native_options.layout_animation_duration = self.layout_animation_duration
66
+ native_options.layout_animation_easing = int(self.layout_animation_easing)
67
+ native_options.track_taskbar_buttons = int(self.track_taskbar_buttons)
68
+ native_options.track_notification_area = int(self.track_notification_area)
69
+ native_options.layout_refresh_interval = self.layout_refresh_interval
70
+ return native_options
deskband11lib/py.typed ADDED
File without changes
deskband11lib/qt.py ADDED
@@ -0,0 +1,151 @@
1
+ """Hosts PySide6 or PyQt6 widgets in the Windows 11 taskbar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from collections.abc import Callable
7
+ from types import ModuleType
8
+ from typing import Any
9
+
10
+ from .events import EventHook, HostEvent
11
+ from .host import TaskbarContentHost
12
+ from .options import TaskbarContentHostOptions
13
+
14
+ _QT_BINDINGS = ("PySide6", "PyQt6")
15
+
16
+
17
+ def prepare_widget(widget: Any) -> int:
18
+ """Turns a top-level widget into a frameless tool window and returns its native window handle.
19
+
20
+ The taskbar layout decides the size of the widget, so the minimum size of the widget and of its layout is
21
+ cleared; otherwise Qt would resize the window back whenever the taskbar gives it less room.
22
+
23
+ Call this before the widget is shown. Qt creates a new native window when window flags change, so the
24
+ flags must not change after the widget is attached.
25
+ """
26
+ qt = _import_qt_module(widget, "QtCore").Qt
27
+ widget.setWindowFlags(widget.windowFlags() | qt.WindowType.FramelessWindowHint | qt.WindowType.Tool)
28
+ widget.setAttribute(qt.WidgetAttribute.WA_ShowWithoutActivating, True)
29
+ layout = widget.layout()
30
+ if layout is not None:
31
+ layout.setSizeConstraint(_import_qt_module(widget, "QtWidgets").QLayout.SizeConstraint.SetNoConstraint)
32
+ widget.setMinimumSize(0, 0)
33
+ return int(widget.winId())
34
+
35
+
36
+ class QtTaskbarWidgetController:
37
+ """Creates a widget, hosts it in the taskbar, and creates it again whenever the taskbar requires it.
38
+
39
+ The taskbar destroys the hosted window when Explorer restarts or when the monitor it sits on is
40
+ disconnected, and the window is detached when the system DPI changes. In each case the controller
41
+ releases the host, waits recreation_delay milliseconds for the taskbar to settle, then creates a new
42
+ widget and host. The widget is shown once it is attached.
43
+
44
+ start() turns off QGuiApplication.quitOnLastWindowClosed, because the widget is closed and created again
45
+ while the taskbar recovers. Call start() and stop() on the Qt GUI thread.
46
+ """
47
+
48
+ def __init__(self, create_widget: Callable[[], Any], options: TaskbarContentHostOptions | None = None, recreation_delay: int = 1000) -> None:
49
+ self._create_widget = create_widget
50
+ self._options = options
51
+ self._recreation_delay = recreation_delay
52
+ self._widget: Any = None
53
+ self._host: TaskbarContentHost | None = None
54
+ self._content_size: tuple[int, int] | None = None
55
+ self._is_running = False
56
+
57
+ self.widget_created: EventHook[Any] = EventHook()
58
+ self.attached: EventHook[HostEvent] = EventHook()
59
+ self.attach_failed: EventHook[HostEvent] = EventHook()
60
+ self.taskbar_edge_changed: EventHook[HostEvent] = EventHook()
61
+ self.error: EventHook[HostEvent] = EventHook()
62
+
63
+ @property
64
+ def widget(self) -> Any:
65
+ """The current widget, or None while it is being created again."""
66
+ return self._widget
67
+
68
+ @property
69
+ def host(self) -> TaskbarContentHost | None:
70
+ return self._host
71
+
72
+ def start(self) -> None:
73
+ if self._is_running:
74
+ return
75
+
76
+ self._is_running = True
77
+ self._create()
78
+
79
+ def stop(self) -> None:
80
+ self._is_running = False
81
+ _dispose_widget(self._release())
82
+
83
+ def _create(self) -> None:
84
+ widget = self._create_widget()
85
+ try:
86
+ _import_qt_module(widget, "QtGui").QGuiApplication.setQuitOnLastWindowClosed(False)
87
+ host = TaskbarContentHost(prepare_widget(widget), self._options)
88
+ except BaseException:
89
+ _dispose_widget(widget)
90
+ raise
91
+
92
+ self._content_size = None
93
+ host.content_bounds_changed.connect(self._on_host_content_bounds_changed)
94
+ host.attached.connect(self._on_host_attached)
95
+ host.attach_failed.connect(self.attach_failed.emit)
96
+ host.taskbar_edge_changed.connect(self.taskbar_edge_changed.emit)
97
+ host.error.connect(self.error.emit)
98
+ host.taskbar_window_recreation_required.connect(self._on_host_taskbar_window_recreation_required)
99
+ self._widget = widget
100
+ self._host = host
101
+ self.widget_created.emit(widget)
102
+ host.attach_when_layout_ready()
103
+
104
+ def _release(self) -> Any:
105
+ host, widget = self._host, self._widget
106
+ self._host = None
107
+ self._widget = None
108
+ # Hiding first keeps the detached window from appearing as a top-level window before it is closed.
109
+ if widget is not None:
110
+ widget.hide()
111
+ if host is not None:
112
+ host.close()
113
+ return widget
114
+
115
+ def _on_host_content_bounds_changed(self, event: HostEvent) -> None:
116
+ self._content_size = (round(event.width), round(event.height))
117
+
118
+ def _on_host_attached(self, event: HostEvent) -> None:
119
+ widget = self._widget
120
+ if widget is not None:
121
+ # Qt fits a top-level widget that was never resized to its size hint when it is first shown, which would undo the size the taskbar layout applied.
122
+ if self._content_size is not None:
123
+ widget.resize(*self._content_size)
124
+ widget.show()
125
+ self.attached.emit(event)
126
+
127
+ def _on_host_taskbar_window_recreation_required(self, _event: HostEvent) -> None:
128
+ previous_widget = self._release()
129
+ timer = _import_qt_module(previous_widget, "QtCore").QTimer
130
+ timer.singleShot(self._recreation_delay, lambda: self._recreate(previous_widget))
131
+
132
+ def _recreate(self, previous_widget: Any) -> None:
133
+ if self._is_running and self._host is None:
134
+ self._create()
135
+ _dispose_widget(previous_widget)
136
+
137
+
138
+ def _dispose_widget(widget: Any) -> None:
139
+ if widget is None:
140
+ return
141
+
142
+ widget.close()
143
+ widget.deleteLater()
144
+
145
+
146
+ def _import_qt_module(widget: Any, module_name: str) -> ModuleType:
147
+ for widget_type in type(widget).__mro__:
148
+ binding = widget_type.__module__.split(".", 1)[0]
149
+ if binding in _QT_BINDINGS:
150
+ return importlib.import_module(f"{binding}.{module_name}")
151
+ raise TypeError("The widget must be a PySide6 or PyQt6 QWidget.")
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.5
2
+ Name: deskband11lib
3
+ Version: 1.5.0
4
+ Summary: Host Python UI windows inside the Windows 11 taskbar.
5
+ Project-URL: Homepage, https://db11l.kagamine-rin.com
6
+ Project-URL: Documentation, https://github.com/airtaxi/Deskband11Lib/blob/master/README.md
7
+ Project-URL: Repository, https://github.com/airtaxi/Deskband11Lib
8
+ Project-URL: Issues, https://github.com/airtaxi/Deskband11Lib/issues
9
+ Author: airtaxi
10
+ License-Expression: MIT
11
+ Keywords: deskband,pyqt6,pyside6,taskbar,widget,windows11
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Win32 (MS Windows)
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: Microsoft :: Windows :: Windows 11
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Desktop Environment
18
+ Requires-Python: >=3.10
19
+ Provides-Extra: pyqt6
20
+ Requires-Dist: pyqt6>=6.5; extra == 'pyqt6'
21
+ Provides-Extra: pyside6
22
+ Requires-Dist: pyside6>=6.5; extra == 'pyside6'
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=8; extra == 'test'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # deskband11lib for Python
28
+
29
+ [![PyPI](https://img.shields.io/pypi/v/deskband11lib.svg)](https://pypi.org/project/deskband11lib/)
30
+ [![Build and Publish Python Package](https://github.com/airtaxi/Deskband11Lib/actions/workflows/python-package.yml/badge.svg)](https://github.com/airtaxi/Deskband11Lib/actions/workflows/python-package.yml)
31
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/airtaxi/Deskband11Lib/blob/master/LICENSE)
32
+
33
+ deskband11lib places a Python window inside the Windows 11 taskbar. It finds the free space between the taskbar buttons and the notification area, moves the window there, and keeps it aligned when the taskbar layout changes, when the taskbar moves to another screen edge, and when other hosted windows share the same area.
34
+
35
+ The package wraps the Deskband11Lib native library through ctypes, so it works with any toolkit that can give you a window handle. A ready-made controller is included for PySide6 and PyQt6.
36
+
37
+ For how the library measures the taskbar, shares space between several hosted windows, and handles each taskbar position, see the [Deskband11Lib README](https://github.com/airtaxi/Deskband11Lib/blob/master/README.md). Deskband11Lib is also available for WinUI 3 and WPF as NuGet packages.
38
+
39
+ ## Requirements
40
+
41
+ - Windows 11
42
+ - Python 3.10 or later, x64 or ARM64
43
+ - A per-monitor DPI aware window. Qt 6 is per-monitor DPI aware by default. For Tk and other toolkits, call `deskband11lib.enable_per_monitor_dpi_awareness()` before any window is created.
44
+
45
+ ## Install
46
+
47
+ ```powershell
48
+ pip install deskband11lib
49
+
50
+ # With PySide6
51
+ pip install "deskband11lib[pyside6]"
52
+ ```
53
+
54
+ ## Qt
55
+
56
+ `QtTaskbarWidgetController` creates your widget, removes its frame, attaches it to the taskbar, and shows it once it is in place.
57
+
58
+ ```python
59
+ import sys
60
+
61
+ from PySide6.QtWidgets import QApplication, QLabel
62
+
63
+ from deskband11lib import TaskbarContentHostOptions
64
+ from deskband11lib.qt import QtTaskbarWidgetController
65
+
66
+ application = QApplication(sys.argv)
67
+ controller = QtTaskbarWidgetController(lambda: QLabel("Hello"), TaskbarContentHostOptions(preferred_width=120))
68
+ controller.start()
69
+ sys.exit(application.exec())
70
+ ```
71
+
72
+ The taskbar destroys hosted windows when Explorer restarts or when the monitor they sit on is disconnected, and hosted windows are detached when the system DPI changes. The controller handles all three cases by closing the old widget and calling your factory again, so keep the widget state you want to preserve outside the widget. Because the widget is closed and created again, `start()` turns off `quitOnLastWindowClosed`; give users another way to exit, such as a context menu.
73
+
74
+ Connect to the controller hooks to react to the taskbar:
75
+
76
+ ```python
77
+ @controller.taskbar_edge_changed.connect
78
+ def on_taskbar_edge_changed(event):
79
+ controller.widget.apply_taskbar_orientation(event.taskbar_edge.is_vertical)
80
+ ```
81
+
82
+ [`samples/pyside6_clock.py`](https://github.com/airtaxi/Deskband11Lib/blob/master/bindings/python/samples/pyside6_clock.py) shows a complete widget that follows the taskbar theme and switches its layout for vertical and thin taskbars.
83
+
84
+ ## Any window
85
+
86
+ `TaskbarContentHost` takes a window handle (HWND) from any toolkit.
87
+
88
+ ```python
89
+ from deskband11lib import TaskbarContentHost, TaskbarContentHostOptions
90
+
91
+ host = TaskbarContentHost(window_handle, TaskbarContentHostOptions(preferred_width=200))
92
+ host.taskbar_window_recreation_required.connect(on_recreation_required)
93
+ host.attach_when_layout_ready().add_done_callback(lambda future: show_window_without_activation())
94
+ ```
95
+
96
+ Follow these rules when you host a window yourself:
97
+
98
+ - Create the host on the UI thread and call every method on that thread. The thread must run a Win32 message loop; the main loop of Qt, Tk, or wxPython does.
99
+ - Create the window hidden and without a frame. Show it without activating it after the host attaches.
100
+ - Leave the size and position of the window to the host while it is attached. The host applies them only when the taskbar layout changes, so a size set by the toolkit stays until the next layout change. Toolkits that fit a window to its content when it is first shown need the window resized to the size from the latest `content_bounds_changed` event before it is shown.
101
+ - When `taskbar_window_recreation_required` is raised, close the host, create the window again, and create a new host for it.
102
+ - Call `close()` when you no longer need the host. It restores the original parent and style of the window.
103
+
104
+ ## Events
105
+
106
+ Each host exposes these hooks. Handlers receive a `HostEvent` and run on the UI thread from the message loop.
107
+
108
+ | Hook | Raised when |
109
+ | --- | --- |
110
+ | `attached` | `attach_when_layout_ready()` completed. |
111
+ | `attach_failed` | `attach_when_layout_ready()` failed. `error_message` describes why. |
112
+ | `taskbar_window_recreated` | Explorer restarted and created the taskbar again. |
113
+ | `taskbar_window_disappeared` | The taskbar the window sat on disappeared, for example because its monitor was disconnected. |
114
+ | `taskbar_dpi_changed` | The system DPI changed. The window has been hidden and detached. |
115
+ | `taskbar_window_recreation_required` | Raised together with each of the three events above. One handler covers all of them. |
116
+ | `taskbar_edge_changed` | The taskbar moved to another screen edge, including the first layout after attaching. |
117
+ | `content_bounds_changed` | The window received a new size, in effective pixels. |
118
+ | `error` | An unexpected error occurred while the layout was updated. |
119
+
120
+ ## Options
121
+
122
+ | Option | Default | Description |
123
+ | --- | --- | --- |
124
+ | `preferred_width` | `360` | Length along the taskbar in effective pixels: the width on a bottom or top taskbar, the height on a left or right taskbar. `STRETCH` fills the space left by fixed-width hosts. |
125
+ | `preferred_height` | `48` | Size across the taskbar, capped by its thickness. |
126
+ | `placement` | `AUTO` | `AUTO`, `LEFT_EDGE`, `BEFORE_NOTIFICATION_AREA`, or `BEFORE_START_BUTTON`. |
127
+ | `preferred_monitor_identity` | `0` | `0` is the primary taskbar. `1` and above are secondary taskbars ordered from left to right. |
128
+ | `manual_slot_priority` | `65535` | Order among hosts that share an area. Lower values are placed first. `65535` orders by arrival. |
129
+ | `allow_fixed_slot_resize` | `True` | Lets fixed-width hosts shrink proportionally when space runs out. |
130
+ | `animate_layout_changes` | `True` | Animates position and size changes. |
131
+ | `high_refresh_rate_mode` | `True` | Matches the animation frame interval to the monitor refresh rate. |
132
+ | `layout_animation_duration` | `500` | Animation duration in milliseconds. |
133
+ | `layout_animation_easing` | `CIRCLE_OUT` | One of the `Easing` values. |
134
+ | `track_taskbar_buttons` | `True` | Measures the taskbar buttons through UI Automation so the window never covers them. |
135
+ | `track_notification_area` | `True` | Keeps the window away from the notification area. |
136
+ | `layout_refresh_interval` | `500` | Milliseconds between layout refreshes. |
137
+
138
+ Invalid values raise `Deskband11LibError` with `result` set to `ResultCode.INVALID_ARGUMENT` and a message that names the option.
139
+
140
+ ## Building from source
141
+
142
+ Clone the [repository](https://github.com/airtaxi/Deskband11Lib). Building the native library requires the .NET 10 SDK and the Visual Studio C++ build tools.
143
+
144
+ ```powershell
145
+ cd bindings/python
146
+
147
+ # Publish the native library for the running interpreter and stage it in the package
148
+ python scripts/stage_native.py
149
+
150
+ # Or stage several architectures to build one wheel that covers all of them
151
+ python scripts/stage_native.py win-x64 win-arm64
152
+
153
+ python -m build --wheel
154
+ ```
155
+
156
+ Set `DESKBAND11LIB_LIBRARY_PATH` to load the native library from another location.
157
+
158
+ ## Tests
159
+
160
+ ```powershell
161
+ pip install -e ".[test,pyside6]"
162
+ pytest
163
+ ```
164
+
165
+ Tests that attach windows to the taskbar of the current session are skipped unless `DESKBAND11LIB_TASKBAR_TESTS` is set to `1`. The Qt tests use PySide6; set `DESKBAND11LIB_QT_BINDING` to `PyQt6` to run them with PyQt6 instead.
166
+
167
+ ## License
168
+
169
+ MIT
@@ -0,0 +1,14 @@
1
+ deskband11lib/__init__.py,sha256=ntAN5QEC3Vw92HtGoExXeVSgPgMyO2szLi2m6BAgkaU,947
2
+ deskband11lib/_bindings.py,sha256=LhSoI01DEONgqhVU8-KkDZl_mc87AXDbbP8S1irZoH0,4348
3
+ deskband11lib/dpi.py,sha256=Sv3ZRQfx1SV7yCvLeWiJz1TOqKvqihqIIvD-3JVPuQ4,717
4
+ deskband11lib/enums.py,sha256=wFhEhVaTm4JN4OJwZa4iOtpkvOZKLFWOmC3p-S2uuV4,1964
5
+ deskband11lib/errors.py,sha256=sNTzpYwnKpsO6CY53IieuUn1UKgEiWWiKG8svfZamZ0,644
6
+ deskband11lib/events.py,sha256=vM51Joe8W9sSTK-akrKv2ET4EvxlSMA31ZaG-DOlIJM,1710
7
+ deskband11lib/host.py,sha256=C3Y9PSlf5zlmVHT-IpeEHvlBqYSLM2Z5Th3fVH0KYcg,9462
8
+ deskband11lib/options.py,sha256=H2_aDyMSLtmT4PJgFHULvdv4VcPuICuAlD-UfzD8CCs,3310
9
+ deskband11lib/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ deskband11lib/qt.py,sha256=HeAkW3T7ZbcCe7Azmott36YjUsWLhhkIKNH4E09BYWY,6264
11
+ deskband11lib/bin/win-x64/deskband11lib.dll,sha256=z-aQMEY9X0jf7pRDeqjobNS9NSG4oVFxap_76S5CHlY,2392064
12
+ deskband11lib-1.5.0.dist-info/METADATA,sha256=qou1VsiamrB8qTfKcygnF1Q8V6MhGSO98omI--lCD0s,9008
13
+ deskband11lib-1.5.0.dist-info/WHEEL,sha256=lpoBGaIUIidvvdZ4qRAP61bBKYJdt4FI3WXFL50Gzyo,94
14
+ deskband11lib-1.5.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64