Skip to content

Latest commit

 

History

History
348 lines (278 loc) · 16.2 KB

File metadata and controls

348 lines (278 loc) · 16.2 KB

Your first accessible form

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.

The form, as you already write it

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.

Making it accessible

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 become TextControls that read themselves, the buttons become named ButtonControls, and the checkbox becomes a CheckBoxControl whose ToggleState is correct and follows overwrite with nothing else said. The status line is named from status and stays in step with it: the label told Tk which variable it shows, so enable() 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.

The retrofit, for a window that already has fifty rows

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

Which route fits

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.

Check your work

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.

How to read that

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_UNIQUE is the one to fix. Both Browse... 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 with set_acc_name(button, "Browse... for Task file"), or run infer_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.

Call it once the window is on screen

<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.

A list of results

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.

What a screen reader user gets

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 through ValuePattern, and never touch the mouse; only a widget the application leave_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, a Treeview's items and a ttk.Notebook's tabs are all reachable elements; a Menu'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.

Where to go next

  • 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.