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 +224 -0
- tk_uia/_accprop.py +288 -0
- tk_uia/_overlay.py +107 -0
- tk_uia/_tkstrip.py +53 -0
- tk_uia/_tkvars.py +56 -0
- tk_uia/annotate.py +914 -0
- tk_uia/describe.py +636 -0
- tk_uia/layout.py +208 -0
- tk_uia/py.typed +0 -0
- tk_uia/roles.py +93 -0
- tk_uia/tabs.py +254 -0
- tk_uia/tkversion.py +76 -0
- tk_uia-0.6.3.dist-info/METADATA +126 -0
- tk_uia-0.6.3.dist-info/RECORD +16 -0
- tk_uia-0.6.3.dist-info/WHEEL +4 -0
- tk_uia-0.6.3.dist-info/licenses/LICENSE +21 -0
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"
|