tk-uia 0.6.3__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.
tk_uia/__init__.py ADDED
@@ -0,0 +1,224 @@
1
+ """tk-uia: make Tkinter widgets visible to Windows accessibility clients.
2
+
3
+ Tk 8.6 gives every widget an empty accessible name and mostly the wrong control
4
+ type. `enable(root)` annotates each widget through MSAA, which Windows bridges
5
+ to UI Automation. Importing this reaches for neither `tkinter` nor
6
+ `ctypes.windll`, so the platform is untouched until `enable()` runs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from typing import TYPE_CHECKING
13
+
14
+ from tk_uia.annotate import (
15
+ AnnotationRefused,
16
+ Annotator,
17
+ InertAnnotator,
18
+ Installation,
19
+ )
20
+ from tk_uia.describe import Description, Gap, WidgetDescription
21
+ from tk_uia.describe import describe as _describe
22
+ from tk_uia.layout import NamedByTheLayout
23
+ from tk_uia.layout import infer_names_from_layout as _names_the_layout_implies
24
+ from tk_uia.roles import ROLE_FOR_TK_CLASS, Role
25
+ from tk_uia.tkversion import Strategy
26
+
27
+ if TYPE_CHECKING:
28
+ import tkinter
29
+
30
+ __version__ = "0.6.3"
31
+
32
+ __all__ = [
33
+ "ROLE_FOR_TK_CLASS",
34
+ "AnnotationRefused",
35
+ "Description",
36
+ "Gap",
37
+ "NamedByTheLayout",
38
+ "Role",
39
+ "Strategy",
40
+ "WidgetDescription",
41
+ "__version__",
42
+ "add_acc_object",
43
+ "bind_text_variable",
44
+ "bind_value_variable",
45
+ "check_screenreader",
46
+ "describe",
47
+ "enable",
48
+ "forget",
49
+ "infer_names_from_layout",
50
+ "label_for",
51
+ "set_acc_action",
52
+ "set_acc_description",
53
+ "set_acc_help",
54
+ "set_acc_name",
55
+ "set_acc_role",
56
+ "set_acc_state",
57
+ "set_acc_value",
58
+ "set_automation_id",
59
+ ]
60
+
61
+ _installed: Installation | None = None
62
+
63
+
64
+ def enable(root: tkinter.Misc, *, roles: Mapping[str, Role] | None = None) -> Strategy:
65
+ """Annotate this application's widgets, and say which way it went.
66
+
67
+ Idempotent: a second call reports what the first one did and installs
68
+ nothing further. One installation covers every window the application opens.
69
+ """
70
+ global _installed
71
+ if _installed is not None:
72
+ return _installed.strategy
73
+ from tk_uia._accprop import AccPropServicesStore
74
+ from tk_uia._overlay import Win32Overlays
75
+ from tk_uia._tkstrip import TkTabStrip, is_a_notebook
76
+ from tk_uia._tkvars import a_variable_the_application_owns
77
+ from tk_uia.annotate import install
78
+ from tk_uia.tabs import Notebooks, TabHandles
79
+
80
+ store = AccPropServicesStore()
81
+ notebooks = Notebooks(
82
+ TabHandles(store, Win32Overlays()),
83
+ lambda widget: TkTabStrip(widget) if is_a_notebook(widget) else None,
84
+ )
85
+ _installed = install(root, store, roles, notebooks, a_variable_the_application_owns)
86
+ return _installed.strategy
87
+
88
+
89
+ def add_acc_object(widget: tkinter.Misc) -> None:
90
+ """Annotate one widget now, re-reading its class, its `-text` and its tabs.
91
+
92
+ Needed after `config(text=...)`, and after a tab is added, removed or
93
+ renamed: Tk announces neither, so nothing re-annotates without this call.
94
+ """
95
+ _annotator().add(widget)
96
+ _the_installation().tabs.refresh(widget)
97
+
98
+
99
+ def set_acc_role(widget: tkinter.Misc, role: Role) -> None:
100
+ """Say what kind of control this is, overriding the inferred role."""
101
+ _annotator().set_role(widget, role)
102
+
103
+
104
+ def set_acc_name(widget: tkinter.Misc, name: str) -> None:
105
+ """Say what a screen reader should call this widget.
106
+
107
+ Raises `AnnotationRefused` for a toplevel, which `wm title` names instead.
108
+ """
109
+ _annotator().set_name(widget, name)
110
+
111
+
112
+ def label_for(label: tkinter.Misc, widget: tkinter.Misc) -> None:
113
+ """Say that this label is the caption for that widget, and name it accordingly.
114
+
115
+ `label_for(tk.Label(text="Host:"), entry)` names the entry `'Host'`, and
116
+ follows the label's `-textvariable` where it declares one. A label showing
117
+ nothing at all raises `AnnotationRefused`.
118
+ """
119
+ _annotator().label_for(label, widget)
120
+
121
+
122
+ def infer_names_from_layout(root: tkinter.Misc) -> tuple[NamedByTheLayout, ...]:
123
+ """Name what the layout of this window says its controls are, and report what it did.
124
+
125
+ A guess read off the widgets *around* each control, which is why `enable()`
126
+ does not do it. Nothing an application named itself is touched.
127
+ """
128
+ return _names_the_layout_implies(root, _the_installation())
129
+
130
+
131
+ def set_acc_value(widget: tkinter.Misc, value: str) -> None:
132
+ """Say what a client reads out of this widget, as an edit control's contents."""
133
+ _annotator().set_value(widget, value)
134
+
135
+
136
+ def set_acc_description(widget: tkinter.Misc, description: str) -> None:
137
+ """Say more about this widget than its name has room for."""
138
+ _annotator().set_description(widget, description)
139
+
140
+
141
+ def set_acc_action(widget: tkinter.Misc, action: str) -> None:
142
+ """Say what activating this widget would do, as a verb ("Press").
143
+
144
+ Advertising it does not make it activatable: `InvokePattern` on a Tk button
145
+ returns cleanly and presses nothing.
146
+ """
147
+ _annotator().set_action(widget, action)
148
+
149
+
150
+ def set_acc_help(widget: tkinter.Misc, help_text: str) -> None:
151
+ """Say what a client should offer as this widget's help text."""
152
+ _annotator().set_help(widget, help_text)
153
+
154
+
155
+ def set_acc_state(widget: tkinter.Misc, state: int) -> None:
156
+ """Say what state this widget is in, as `oleacc.h`'s `STATE_SYSTEM_*` bits.
157
+
158
+ Written once and never tracked: nothing here notices a widget being
159
+ disabled or re-enabled.
160
+ """
161
+ _annotator().set_state(widget, state)
162
+
163
+
164
+ def bind_text_variable(widget: tkinter.Misc, variable: tkinter.Variable) -> None:
165
+ """Keep a widget's accessible name in step with a variable of your choosing.
166
+
167
+ Replaces the binding `enable()` made from the widget's own `-textvariable`
168
+ rather than joining it.
169
+ """
170
+ _annotator().bind_text_variable(widget, variable)
171
+
172
+
173
+ def bind_value_variable(widget: tkinter.Misc, variable: tkinter.Variable) -> None:
174
+ """Keep a widget's accessible value in step with a variable of your choosing."""
175
+ _annotator().bind_value_variable(widget, variable)
176
+
177
+
178
+ def set_automation_id(widget: tkinter.Misc, automation_id: int) -> None:
179
+ """Give this widget a stable id for a test suite to pin a locator to.
180
+
181
+ Writes `GWLP_ID`, the control id Win32 puts in `WM_COMMAND.wParam`; a
182
+ non-zero existing id raises `AnnotationRefused` rather than being overwritten.
183
+ """
184
+ _annotator().set_automation_id(widget, automation_id)
185
+
186
+
187
+ def forget(widget: tkinter.Misc | str) -> None:
188
+ """Take every annotation back off a widget, and stop following its variables.
189
+
190
+ Takes the widget or its Tk path, since `<Destroy>` carries only the path
191
+ once the widget object has gone.
192
+ """
193
+ _annotator().forget(widget)
194
+
195
+
196
+ def describe(root: tkinter.Misc) -> Description:
197
+ """Say what this application has told Windows about the widgets under `root`.
198
+
199
+ Reports what tk-uia believes it wrote, which is not evidence that a client
200
+ can read it. `print()` it for the report, or read `.widgets` for the same
201
+ thing as data. Raises `AnnotationRefused` where `enable()` has never run.
202
+ """
203
+ return _describe(root, _the_installation())
204
+
205
+
206
+ def check_screenreader() -> bool:
207
+ """Whether Windows believes something is reading the screen aloud."""
208
+ from tk_uia._accprop import screen_reader_running
209
+
210
+ return screen_reader_running()
211
+
212
+
213
+ def _annotator() -> Annotator | InertAnnotator:
214
+ return _the_installation().annotator
215
+
216
+
217
+ def _the_installation() -> Installation:
218
+ if _installed is None:
219
+ raise AnnotationRefused(
220
+ "tk_uia.enable(root) has not been called, so there is nothing to "
221
+ "annotate through; call it once after building the window and "
222
+ "before saying anything about the widgets in it"
223
+ )
224
+ return _installed
tk_uia/_accprop.py ADDED
@@ -0,0 +1,288 @@
1
+ """The `oleacc` calls an annotation is actually made of.
2
+
3
+ Two details here return `S_OK` and do nothing at all when they are wrong, so
4
+ neither is guessed at:
5
+
6
+ * every `PROPID_ACC_*` GUID is transcribed from `oleacc.h` in the Windows SDK
7
+ (10.0.22621.0), not recalled. `PROPID_ACC_HELP` in particular is not the value
8
+ intuition suggests;
9
+ * `MSAAPROPID` is `typedef GUID`, so `idProp` is passed **by value**. Passing a
10
+ pointer compiles, runs, returns `S_OK`, and annotates nothing.
11
+
12
+ `ctypes.HRESULT` and `ctypes.WINFUNCTYPE` do not exist off Windows, so every
13
+ prototype is built on first use rather than at import.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ctypes
19
+ import sys
20
+ from typing import Any
21
+
22
+ from tk_uia.annotate import PropId
23
+
24
+ _S_OK = 0
25
+ _S_FALSE = 1
26
+ _RPC_E_CHANGED_MODE = 0x80010106
27
+ _COINIT_APARTMENTTHREADED = 0x2
28
+ _CLSCTX_INPROC_SERVER = 1
29
+
30
+ _OBJID_CLIENT = 0xFFFFFFFC
31
+ _CHILDID_SELF = 0
32
+
33
+ # IAccPropServices, after IUnknown's three: SetPropValue 3, SetPropServer 4,
34
+ # ClearProps 5, then these. Verified by calling them, because a wrong slot with
35
+ # these signatures is an access violation rather than a quiet no-op.
36
+ _SLOT_SET_HWND_PROP = 6
37
+ _SLOT_SET_HWND_PROP_STR = 7
38
+ _SLOT_CLEAR_HWND_PROPS = 9
39
+
40
+ _VT_I4 = 3
41
+
42
+ # GWLP_ID: the control id Win32 puts in WM_COMMAND.wParam and WM_DRAWITEM.idCtl.
43
+ _GWLP_ID = -12
44
+
45
+ _SPI_GETSCREENREADER = 0x0046
46
+
47
+ _WINDOWS = "win32"
48
+
49
+ # A plain number rather than `ctypes.HRESULT`, which looks like the honest
50
+ # choice and is the wrong one: ctypes raises an `OSError` of its own on any
51
+ # negative HRESULT *before* the caller sees the value, so `_checked` never runs
52
+ # and the application gets a bare "[WinError -2147024891] Access is denied" with
53
+ # no way to tell which of eleven annotation calls refused. Read as a number, the
54
+ # code reaches `_checked`, which also catches the positive non-`S_OK` answers
55
+ # `ctypes.HRESULT` lets through.
56
+ _HOWEVER_COM_ANSWERED = ctypes.c_long
57
+
58
+
59
+ class _Guid(ctypes.Structure):
60
+ _fields_ = (
61
+ ("Data1", ctypes.c_ulong),
62
+ ("Data2", ctypes.c_ushort),
63
+ ("Data3", ctypes.c_ushort),
64
+ ("Data4", ctypes.c_ubyte * 8),
65
+ )
66
+
67
+
68
+ class _Variant(ctypes.Structure):
69
+ """VARIANT, in the only shape used here: 24 bytes, holding a long."""
70
+
71
+ _fields_ = (
72
+ ("vt", ctypes.c_ushort),
73
+ ("reserved1", ctypes.c_ushort),
74
+ ("reserved2", ctypes.c_ushort),
75
+ ("reserved3", ctypes.c_ushort),
76
+ ("value", ctypes.c_longlong),
77
+ ("padding", ctypes.c_longlong),
78
+ )
79
+
80
+
81
+ def _guid(first: int, second: int, third: int, *rest: int) -> _Guid:
82
+ return _Guid(first, second, third, (ctypes.c_ubyte * 8)(*rest))
83
+
84
+
85
+ # Transcribed from oleacc.h, 10.0.22621.0.
86
+ _CLSID_ACC_PROP_SERVICES = _guid(
87
+ 0xB5F8350B, 0x0548, 0x48B1, 0xA6, 0xEE, 0x88, 0xBD, 0x00, 0xB4, 0xA5, 0xE7
88
+ )
89
+ _IID_IACC_PROP_SERVICES = _guid(
90
+ 0x6E26E776, 0x04F0, 0x495D, 0x80, 0xE4, 0x33, 0x30, 0x35, 0x2E, 0x31, 0x69
91
+ )
92
+
93
+ _GUID_FOR_PROP = {
94
+ PropId.NAME: _guid(
95
+ 0x608D3DF8, 0x8128, 0x4AA7, 0xA4, 0x28, 0xF5, 0x5E, 0x49, 0x26, 0x72, 0x91
96
+ ),
97
+ PropId.VALUE: _guid(
98
+ 0x123FE443, 0x211A, 0x4615, 0x95, 0x27, 0xC4, 0x5A, 0x7E, 0x93, 0x71, 0x7A
99
+ ),
100
+ PropId.DESCRIPTION: _guid(
101
+ 0x4D48DFE4, 0xBD3F, 0x491F, 0xA6, 0x48, 0x49, 0x2D, 0x6F, 0x20, 0xC5, 0x88
102
+ ),
103
+ PropId.ROLE: _guid(
104
+ 0xCB905FF2, 0x7BD1, 0x4C05, 0xB3, 0xC8, 0xE6, 0xC2, 0x41, 0x36, 0x4D, 0x70
105
+ ),
106
+ PropId.STATE: _guid(
107
+ 0xA8D4D5B0, 0x0A21, 0x42D0, 0xA5, 0xC0, 0x51, 0x4E, 0x98, 0x4F, 0x45, 0x7B
108
+ ),
109
+ PropId.HELP: _guid(
110
+ 0xC831E11F, 0x44DB, 0x4A99, 0x97, 0x68, 0xCB, 0x8F, 0x97, 0x8B, 0x72, 0x31
111
+ ),
112
+ PropId.DEFAULT_ACTION: _guid(
113
+ 0x180C072B, 0xC27F, 0x43C7, 0x99, 0x22, 0xF6, 0x35, 0x62, 0xA4, 0x63, 0x2B
114
+ ),
115
+ }
116
+
117
+
118
+ class AccPropServicesStore:
119
+ """Annotations, written where the MSAA-to-UIA bridge reads them."""
120
+
121
+ def __init__(self) -> None:
122
+ # Nothing is reached for here: `enable()` builds one of these before the
123
+ # version gate has run, and on a machine with no MSAA it is never used.
124
+ self._services: ctypes.c_void_p | None = None
125
+
126
+ def set_string(self, hwnd: int, prop: PropId, value: str) -> None:
127
+ services = self._reached()
128
+ call = _method(services, _SLOT_SET_HWND_PROP_STR, _set_hwnd_prop_str())
129
+ _checked(
130
+ call(
131
+ services,
132
+ ctypes.c_void_p(hwnd),
133
+ _OBJID_CLIENT,
134
+ _CHILDID_SELF,
135
+ _GUID_FOR_PROP[prop],
136
+ value,
137
+ ),
138
+ f"SetHwndPropStr({prop.name})",
139
+ )
140
+
141
+ def set_number(self, hwnd: int, prop: PropId, value: int) -> None:
142
+ holder = _Variant()
143
+ holder.vt = _VT_I4
144
+ holder.value = value
145
+ services = self._reached()
146
+ call = _method(services, _SLOT_SET_HWND_PROP, _set_hwnd_prop())
147
+ _checked(
148
+ call(
149
+ services,
150
+ ctypes.c_void_p(hwnd),
151
+ _OBJID_CLIENT,
152
+ _CHILDID_SELF,
153
+ _GUID_FOR_PROP[prop],
154
+ holder,
155
+ ),
156
+ f"SetHwndProp({prop.name})",
157
+ )
158
+
159
+ def control_id(self, hwnd: int) -> int:
160
+ return int(_window_long()(ctypes.c_void_p(hwnd), _GWLP_ID))
161
+
162
+ def set_control_id(self, hwnd: int, control_id: int) -> None:
163
+ _set_window_long()(ctypes.c_void_p(hwnd), _GWLP_ID, control_id)
164
+
165
+ def clear(self, hwnd: int) -> None:
166
+ props = list(_GUID_FOR_PROP.values())
167
+ everything = (_Guid * len(props))(*props)
168
+ services = self._reached()
169
+ call = _method(services, _SLOT_CLEAR_HWND_PROPS, _clear_hwnd_props())
170
+ _checked(
171
+ call(
172
+ services,
173
+ ctypes.c_void_p(hwnd),
174
+ _OBJID_CLIENT,
175
+ _CHILDID_SELF,
176
+ everything,
177
+ len(props),
178
+ ),
179
+ "ClearHwndProps",
180
+ )
181
+
182
+ def _reached(self) -> ctypes.c_void_p:
183
+ if self._services is None:
184
+ self._services = _acc_prop_services()
185
+ return self._services
186
+
187
+
188
+ def screen_reader_running() -> bool:
189
+ """Whether Windows believes something is reading the screen aloud."""
190
+ if sys.platform != _WINDOWS:
191
+ return False
192
+ listening = ctypes.c_int(0)
193
+ _user32().SystemParametersInfoW(_SPI_GETSCREENREADER, 0, ctypes.byref(listening), 0)
194
+ return bool(listening.value)
195
+
196
+
197
+ def _acc_prop_services() -> ctypes.c_void_p:
198
+ ole32 = ctypes.windll.ole32
199
+ started = ole32.CoInitializeEx(None, _COINIT_APARTMENTTHREADED)
200
+ # S_FALSE means this thread was already in an apartment and
201
+ # RPC_E_CHANGED_MODE means it is in a different one. Both are somebody
202
+ # else's apartment to close, which is why CoUninitialize is never called.
203
+ if started not in (_S_OK, _S_FALSE) and (started & 0xFFFFFFFF) != (
204
+ _RPC_E_CHANGED_MODE
205
+ ):
206
+ raise OSError(f"CoInitializeEx failed 0x{started & 0xFFFFFFFF:08X}")
207
+ services = ctypes.c_void_p()
208
+ _checked(
209
+ ole32.CoCreateInstance(
210
+ ctypes.byref(_CLSID_ACC_PROP_SERVICES),
211
+ None,
212
+ _CLSCTX_INPROC_SERVER,
213
+ ctypes.byref(_IID_IACC_PROP_SERVICES),
214
+ ctypes.byref(services),
215
+ ),
216
+ "CoCreateInstance(AccPropServices)",
217
+ )
218
+ return services
219
+
220
+
221
+ def _method(services: ctypes.c_void_p, slot: int, prototype: Any) -> Any:
222
+ vtable = ctypes.cast(services, ctypes.POINTER(ctypes.c_void_p))[0]
223
+ return prototype(ctypes.cast(vtable, ctypes.POINTER(ctypes.c_void_p))[slot])
224
+
225
+
226
+ def _checked(result: int, what: str) -> None:
227
+ if result != _S_OK:
228
+ raise OSError(f"{what} failed 0x{result & 0xFFFFFFFF:08X}")
229
+
230
+
231
+ def _set_hwnd_prop() -> Any:
232
+ return ctypes.WINFUNCTYPE(
233
+ _HOWEVER_COM_ANSWERED,
234
+ ctypes.c_void_p,
235
+ ctypes.c_void_p,
236
+ ctypes.c_ulong,
237
+ ctypes.c_ulong,
238
+ _Guid,
239
+ _Variant,
240
+ )
241
+
242
+
243
+ def _set_hwnd_prop_str() -> Any:
244
+ return ctypes.WINFUNCTYPE(
245
+ _HOWEVER_COM_ANSWERED,
246
+ ctypes.c_void_p,
247
+ ctypes.c_void_p,
248
+ ctypes.c_ulong,
249
+ ctypes.c_ulong,
250
+ _Guid,
251
+ ctypes.c_wchar_p,
252
+ )
253
+
254
+
255
+ def _clear_hwnd_props() -> Any:
256
+ return ctypes.WINFUNCTYPE(
257
+ _HOWEVER_COM_ANSWERED,
258
+ ctypes.c_void_p,
259
+ ctypes.c_void_p,
260
+ ctypes.c_ulong,
261
+ ctypes.c_ulong,
262
+ ctypes.POINTER(_Guid),
263
+ ctypes.c_int,
264
+ )
265
+
266
+
267
+ def _window_long() -> Any:
268
+ user32 = _user32()
269
+ # GetWindowLongPtrW exists only in the 64-bit user32; on 32-bit Python the
270
+ # non-Ptr name is the whole of the API and is already pointer-sized.
271
+ read = getattr(user32, "GetWindowLongPtrW", None) or user32.GetWindowLongW
272
+ read.restype = ctypes.c_ssize_t
273
+ read.argtypes = (ctypes.c_void_p, ctypes.c_int)
274
+ return read
275
+
276
+
277
+ def _set_window_long() -> Any:
278
+ user32 = _user32()
279
+ write = getattr(user32, "SetWindowLongPtrW", None) or user32.SetWindowLongW
280
+ write.restype = ctypes.c_ssize_t
281
+ write.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_ssize_t)
282
+ return write
283
+
284
+
285
+ def _user32() -> Any:
286
+ # Per call rather than at import: `ctypes.windll` does not exist off
287
+ # Windows, and this module still has to import there.
288
+ return ctypes.windll.user32
tk_uia/_overlay.py ADDED
@@ -0,0 +1,107 @@
1
+ """The window handles a notebook's tabs borrow, made with four Win32 calls.
2
+
3
+ Both window styles are load-bearing. `WS_EX_TRANSPARENT` keeps the window out of
4
+ hit-testing, so the click a client aims at a tab reaches the notebook underneath
5
+ and Tk selects it. `SS_OWNERDRAW` makes the static ask its parent to paint it by
6
+ way of `WM_DRAWITEM`; Tk has never heard of this window and ignores the message,
7
+ so nothing is painted and the tab strip shows through. A plain static would
8
+ paint its background over the tab it is standing in for.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ctypes
14
+ from typing import Any
15
+
16
+ # The generic control class every Windows install has. A class of this
17
+ # package's own would leave a stale atom behind on every reload.
18
+ _A_PLAIN_STATIC = "STATIC"
19
+
20
+ _WS_CHILD = 0x40000000
21
+ _WS_VISIBLE = 0x10000000
22
+ _SS_OWNERDRAW = 0x0000000D
23
+ _WS_EX_TRANSPARENT = 0x00000020
24
+
25
+ _NO_MENU = None
26
+ _NO_MODULE = None
27
+ _NO_PARAMETER = None
28
+ _REPAINT_IT = True
29
+
30
+
31
+ class Win32Overlays:
32
+ """Real child windows over a real notebook's real tabs."""
33
+
34
+ def create(self, parent: int, left: int, top: int, width: int, height: int) -> int:
35
+ made = _create_window()(
36
+ _WS_EX_TRANSPARENT,
37
+ _A_PLAIN_STATIC,
38
+ None,
39
+ _WS_CHILD | _WS_VISIBLE | _SS_OWNERDRAW,
40
+ left,
41
+ top,
42
+ width,
43
+ height,
44
+ ctypes.c_void_p(parent),
45
+ _NO_MENU,
46
+ _NO_MODULE,
47
+ _NO_PARAMETER,
48
+ )
49
+ if not made:
50
+ raise OSError(
51
+ f"could not make a window for a tab of {parent:#x}: "
52
+ f"CreateWindowExW failed with {ctypes.get_last_error()}"
53
+ )
54
+ return int(made)
55
+
56
+ def move(self, hwnd: int, left: int, top: int, width: int, height: int) -> None:
57
+ _move_window()(ctypes.c_void_p(hwnd), left, top, width, height, _REPAINT_IT)
58
+
59
+ def destroy(self, hwnd: int) -> None:
60
+ _destroy_window()(ctypes.c_void_p(hwnd))
61
+
62
+
63
+ def _create_window() -> Any:
64
+ make = _user32().CreateWindowExW
65
+ make.restype = ctypes.c_void_p
66
+ make.argtypes = (
67
+ ctypes.c_uint32,
68
+ ctypes.c_wchar_p,
69
+ ctypes.c_wchar_p,
70
+ ctypes.c_uint32,
71
+ ctypes.c_int,
72
+ ctypes.c_int,
73
+ ctypes.c_int,
74
+ ctypes.c_int,
75
+ ctypes.c_void_p,
76
+ ctypes.c_void_p,
77
+ ctypes.c_void_p,
78
+ ctypes.c_void_p,
79
+ )
80
+ return make
81
+
82
+
83
+ def _move_window() -> Any:
84
+ move = _user32().MoveWindow
85
+ move.restype = ctypes.c_int
86
+ move.argtypes = (
87
+ ctypes.c_void_p,
88
+ ctypes.c_int,
89
+ ctypes.c_int,
90
+ ctypes.c_int,
91
+ ctypes.c_int,
92
+ ctypes.c_int,
93
+ )
94
+ return move
95
+
96
+
97
+ def _destroy_window() -> Any:
98
+ close = _user32().DestroyWindow
99
+ close.restype = ctypes.c_int
100
+ close.argtypes = (ctypes.c_void_p,)
101
+ return close
102
+
103
+
104
+ def _user32() -> Any:
105
+ # Per call rather than at import: `ctypes.WinDLL` cannot be built off
106
+ # Windows, and this module still has to import there.
107
+ return ctypes.WinDLL("user32", use_last_error=True)
tk_uia/_tkstrip.py ADDED
@@ -0,0 +1,53 @@
1
+ """A real `ttk.Notebook`, answering the three questions the tab scan asks.
2
+
3
+ There is no public API for a tab's rectangle. `index @x,y` is the same question
4
+ Tk answers for a real mouse click, so scanning with it is the one measurement
5
+ that cannot disagree with where the tab actually is.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import tkinter
11
+ from tkinter import ttk
12
+
13
+
14
+ class TkTabStrip:
15
+ """One notebook's strip, as the scan sees it."""
16
+
17
+ def __init__(self, notebook: ttk.Notebook) -> None:
18
+ self._notebook = notebook
19
+
20
+ def settle(self) -> None:
21
+ """Let Tk finish laying the strip out before anything measures it.
22
+
23
+ Idle tasks only, never `update()`, which would drain the event queue and
24
+ re-enter whatever binding is asking for this.
25
+ """
26
+ self._notebook.update_idletasks()
27
+
28
+ def tab_at(self, x: int, y: int) -> int | None:
29
+ try:
30
+ return int(self._notebook.index(f"@{x},{y}"))
31
+ except (tkinter.TclError, ValueError):
32
+ # Tk refuses rather than answers for a point that is not on a tab,
33
+ # which is most of a notebook. Not an error here: it is the answer.
34
+ return None
35
+
36
+ def text_of(self, index: int) -> str:
37
+ return str(self._notebook.tab(index, "text"))
38
+
39
+ def size(self) -> tuple[int, int]:
40
+ return (self._notebook.winfo_width(), self._notebook.winfo_height())
41
+
42
+
43
+ def is_a_notebook(widget: object) -> bool:
44
+ """Whether this widget is one whose tabs need handles of their own.
45
+
46
+ Asked of Tk, never of Python's type system. `isinstance` against
47
+ `ttk.Notebook` breaks the moment a host re-imports `tkinter.ttk` and makes
48
+ a second, distinct class: IDLE's `idlelib/run.py` does exactly that, and
49
+ every notebook it builds failed the check while carrying the right class
50
+ name all along.
51
+ """
52
+ winfo_class = getattr(widget, "winfo_class", None)
53
+ return winfo_class is not None and winfo_class() == "TNotebook"