One small form, and the calls that make it announce itself. Ten minutes.
docs/GUIDE.md is the reference: what is measured, what is not, and why. This page is the short way in. A form written the way you already write one, then the two or three lines that put it in the accessibility tree, then how to check that they worked.
Two captioned rows with a Browse... button each, a checkbox, a Create button,
and a status line driven by a StringVar. Nothing here is unusual, and the two
Browse... captions are identical on purpose. Most real dialogs have a pair.
import tkinter as tk
root = tk.Tk()
root.title("New Task")
status = tk.StringVar(value="Ready.")
overwrite = tk.IntVar(value=0)
def create_the_task():
status.set("Created 1 task.")
def browse_for_a_path():
... # a real form opens a file dialog here
file_row = tk.Frame(root)
file_caption = tk.Label(file_row, text="Task file:")
file_caption.pack(side="left")
task_file = tk.Entry(file_row, width=24)
task_file.pack(side="left", padx=4)
tk.Button(file_row, text="Browse...", command=browse_for_a_path).pack(side="left")
file_row.pack(fill="x", padx=8, pady=4)
folder_row = tk.Frame(root)
folder_caption = tk.Label(folder_row, text="Output folder:")
folder_caption.pack(side="left")
output_folder = tk.Entry(folder_row, width=24)
output_folder.pack(side="left", padx=4)
tk.Button(folder_row, text="Browse...", command=browse_for_a_path).pack(side="left")
folder_row.pack(fill="x", padx=8, pady=4)
tk.Checkbutton(root, text="Overwrite existing files", variable=overwrite).pack(
anchor="w", padx=8
)
tk.Button(root, text="Create", command=create_the_task).pack(anchor="w", padx=8, pady=4)
tk.Label(root, textvariable=status).pack(anchor="w", padx=8, pady=(0, 8))
root.mainloop()It looks right and it works. To a screen reader it is almost nothing: both captions and the status line are announced as pictures, both entries arrive as anonymous panes with no name and no readable contents, and the three buttons and the checkbox are all unnamed buttons. The words are on the screen and none of them are in the tree. See the claim, stated precisely.
Three lines, and two of them are the same call:
import tk_uia
# ... build the window exactly as above ...
tk_uia.enable(root)
tk_uia.label_for(file_caption, task_file)
tk_uia.label_for(folder_caption, output_folder)
root.mainloop()What each line buys:
enable(root)names every widget that carries its own words and gives every widget the right control type. The captions becomeTextControls that read themselves, the buttons become namedButtonControls, and the checkbox becomes aCheckBoxControlwhoseToggleStateis correct and followsoverwritewith nothing else said. The status line is named fromstatusand stays in step with it: the label told Tk which variable it shows, soenable()reads that off the widget and follows it. Since 0.6.0 that costs no call at all. Set the variable, and what a client reads changes with what is on screen.label_for(caption, entry)says the one thing nothing can read back. An entry has no words of its own, and in Tk the label that names it is a sibling. No part of the toolkit records which widget a caption speaks for, so no amount of reading the entry will find it. Said once, the entry answers to'Task file'. The colon comes off, because every caption in a form has one and none of them is part of a control's name.
That is the whole of it for this window. There is no third call to remember. Anything else you might want to say is in the API table, and most forms need none of it.
One label_for per entry is fine for a form you are writing now. For a dialog
that already exists, infer_names_from_layout(root) applies the same convention
to every row at once and tells you what it did:
# ... enable(root) and the two label_for calls, exactly as above ...
for named in tk_uia.infer_names_from_layout(root):
print(named.path, "->", named.name)Which on this window prints:
.!frame.!button -> Browse... for Task file
.!frame2.!button -> Browse... for Output folder
Two lines, not four, and the two that are missing are the lesson. The entries
are absent precisely because your word already won: the two label_for calls
above named them, and a name the application chose is never replaced. What comes
back is what this call really named, so a row somebody has already thought about
is passed over in silence, and the two routes mix freely. What it did do is the
reason to prefer it here: it qualified both Browse... buttons with the row
they act on. Two controls announced Browse... in one window are two controls
nobody can choose between, and that is a fault the per-row route leaves behind.
On the same window with those two label_for lines deleted, the same call
prints four, because then nobody had said anything about the entries either:
.!frame.!entry -> Task file
.!frame.!button -> Browse... for Task file
.!frame2.!entry -> Output folder
.!frame2.!button -> Browse... for Output folder
| A form you are writing now, ten rows or fewer | label_for per row. It is explicit, it is in your source next to the widgets it talks about, and a reader of your code can see what the entry will be called. |
| A dialog that already exists, or one with dozens of rows | infer_names_from_layout(root). One call, whether the rows are frames or grid rows inside one frame, and it returns every name it chose so you can read the whole of the guess before shipping it. |
| A row the convention gets wrong | set_acc_name(widget, ...). A name you chose is never replaced, whether you said it before the call or after it. |
The two mix freely, and neither is part of enable() on purpose. A layout is
not a statement. Two widgets are beside each other because somebody packed them
that way, so applying the convention is a guess, and this library never guesses
on its own. Asked for by name it is something else: a convention you have
recognised in your own window. The longer
version.
describe(root) reports what your application has told Windows, and names every
widget it did not. It reads no COM and no UI Automation, so you can leave the
call in.
root.update() # let Tk map the window: <Map> is what annotates
print(tk_uia.describe(root))With enable() and the two label_for calls, that prints:
tk-uia 0.8.0 -- what this application has told Windows it is showing
enable() reported PROVIDED. 12 widgets under .: 11 written to, 1 not.
6 of them answer UIA themselves with working patterns; the rest are typed and named through the proxy.
WIDGET CLASS ROLE NAME VALUE ID
---------------- ----------- ----------------- -------------------------- ----- --
. Tk - - - -
.!frame Frame GROUPING (20) - - -
.!frame.!label Label STATIC_TEXT (41) 'Task file:' - -
.!frame.!entry Entry TEXT (42) 'Task file' - -
answers UIA itself, with working: Value
.!frame.!button Button PUSH_BUTTON (43) 'Browse...' - -
answers UIA itself, with working: Invoke
.!frame2 Frame GROUPING (20) - - -
.!frame2.!label Label STATIC_TEXT (41) 'Output folder:' - -
.!frame2.!entry Entry TEXT (42) 'Output folder' - -
answers UIA itself, with working: Value
.!frame2.!button Button PUSH_BUTTON (43) 'Browse...' - -
answers UIA itself, with working: Invoke
.!checkbutton-1 Checkbutton CHECK_BUTTON (44) 'Overwrite existing files' - -
answers UIA itself, with working: Toggle
.!button Button PUSH_BUTTON (43) 'Create' - -
answers UIA itself, with working: Invoke
.!label Label STATIC_TEXT (41) 'Ready.' - -
kept in step with a variable: name
WHAT A CLIENT WILL NOT GET, AND WHY
NAME_NOT_UNIQUE (2)
shares its role and its accessible name with another widget in the
same window, so a client asking for it reaches one of them at random
and a screen reader announces both of them the same way. Qualify the
caption -- 'Browse... for Export Folder' -- with set_acc_name, or let
infer_names_from_layout(root) qualify the generic ones for a whole
window at once.
.!frame.!button (Button)
.!frame2.!button (Button)
LEFT ALONE ON PURPOSE
NAMED_BY_ITS_TITLE (1)
a window, and `wm title` already gives it a correct accessible name.
Overriding it would break resolving the window by its title, which is
where every other query starts.
. (Tk)
Everything above is what tk-uia believes it wrote. It is not evidence that
a client can read it: IAccPropServices accepts a write to a window handle
nobody owns, answers S_OK, and changes nothing. Reading the same window
back from another process is the only thing that proves the bridge carried
it.
Read the headline first. enable() reported PROVIDED is the line that says
the widgets both carry annotations and answer UI Automation themselves. On a
Tk 9.1, or off Windows, it reads NATIVE or UNSUPPORTED and every row below
is blank; under annotate_only() it reads ANNOTATED. That is why the
strategy comes before the table rather than after it.
The table is what a client will read: a role, a name and a value per widget,
and under each widget that acts, the patterns that genuinely work. The
Browse... rows carry a working Invoke: a client presses them through the
tree, no clicking. .!label carries kept in step with a variable: name,
which is the status line following status. Nothing goes stale there, ever.
Below the table are the gaps: one here, plus a heading that is not a fault at
all. NAMED_BY_ITS_TITLE is the report saying it left the window alone
because root.title("New Task") already named it correctly.
NAME_NOT_UNIQUEis the one to fix. BothBrowse...buttons are correctly typed and correctly named, and they are named the same thing. A screen reader user hears the same announcement for two controls that do different things, and a locator asking for "the Browse... button" gets whichever one the tree hands back first. Nothing about either button on its own is wrong, which is why it takes a whole-window check to see it. Two fixes, and both remove the gap from this report: qualify the captions by hand withset_acc_name(button, "Browse... for Task file"), or runinfer_names_from_layout(root), which does that for every row at once. Take the retrofit route above and this heading is gone, with the rest of the report unchanged.
Two gaps this window used to show are gone because the widgets answer for
themselves now. The entries' values are read live out of the widgets when a
client asks, so there is no confident empty answer to warn about (a
textvariable is still worth one word at construction: it is what keeps the
MSAA view current and what raises change events). And the buttons genuinely
press, so CANNOT_BE_PRESSED appears only for a widget left to the proxy or
a role assigned by hand.
describe() reports what tk-uia believes it wrote, and says so in its own last
paragraph. It is not a client, and it is not proof. Reading the same window back
from another process is proof. The recipe is in the
guide.
<Map> is the event that annotates a widget, and Tk fires it when the widget
goes on screen. Call describe(root) between building the window and running
mainloop() and you get a report of a window that has not happened yet.
On this same form, nine of its twelve widgets come back NEVER_MAPPED
and the two entries label_for reached come back UNMAPPED_SINCE_ANNOTATED.
The root.update() above is what makes the report describe the window you are
looking at. In a running application, root.after(0, ...) or a debug key
binding does the same job.
Name the list; the rows come with it:
results = tk.Listbox(root)
tk_uia.enable(root)
tk_uia.set_acc_name(results, "Search results")A client walking into Search results finds every row as a named list item,
in the application's order, selects one through its SelectionItem pattern
(the application hears the same <<ListboxSelect>> a user's choice fires),
and brings an offscreen row into view through ScrollItem. Rows are read from
the widget at the moment a client asks, so inserting, deleting or renaming
needs no further call. A ttk.Treeview works the same way, with each
branch's items beneath it and ExpandCollapse to open a branch.
Where selectmode takes more than one, rows also join and leave the
selection through AddToSelection and RemoveFromSelection, and a selection
change is raised as a UIA event whenever the widget's own select event
fires. One honest edge: a row scrolled out of view answers an empty
rectangle and IsOffscreen until something scrolls it in.
Tabbing through the annotated form, a screen reader has something to work with
at every stop: an edit control named Task file, a button named Browse... for Task file, a check box named Overwrite existing files whose checked state is
correct and stays correct, and a status line whose name changes when status
does. Before enable(), every one of those stops is an anonymous pane or an
unnamed button, with no name at all.
Things worth knowing at this point:
- Activation works through the tree. A client can press the button through its
InvokePattern, type into the entry throughValuePattern, and never touch the mouse; only a widget the applicationleave_to_the_proxy()s keeps the old advertise-and-do-nothing proxy behaviour. How, and the rules behind it. - What is inside a list is in the tree too. A
Listbox's rows, aTreeview's items and attk.Notebook's tabs are all reachable elements; aMenu's entries are Windows' own. Caveats worth knowing. - No screen reader has been in the room. Everything this project claims is read back through UI Automation, which is the API a screen reader consumes. "NVDA can read this tree" is evidenced. "NVDA says the right thing at the right moment" is not. The paragraph above is what the tree supports, and it is not a transcript. The checklist that would close it.
The honest claim is the narrow one: the accessibility tree tells the truth. That is worth a great deal more than an untrue one.
- Is this for you?, including the two cases where the answer is "use something else".
- Caveats worth
knowing:
a name goes stale after
config(text=...), disabled state is not conveyed, and the rest. - COVERAGE.md: every
widget class in both toolkits, measured bare, after
enable(), and after the naming a well-behaved application adds. - The whole API: seventeen calls, and most forms need three.