The surface an action uses to drive another application: finding windows,
searching element trees, waiting for the screen to change, filling fields.
Reached through keymap.ui (or self.ui inside a ThreadedAction) and the
methods on the nodes it hands back — the three names below are the only ones a
config imports.
Generated from the docstrings. For how to write an action, the authoring
skill in keyhac/skills/keyhac-action-authoring/ is the procedural half, and
examples/actions/ holds working ones.
A
UINodeis a snapshot. It records what an element was when it was read; the screen then moves on and the node does not notice.find,find_alland the waits read the live screen each call regardless of the node’s age;walk,dump,childrenand the text properties show only what was captured.reread()refreshes one deliberately, andStaleElementis raised when a node you are still holding refers to something that no longer exists — which is the signal to re-find it, as distinct fromFillFailed, which means the selector was wrong. Address elements byidentifierwhere there is one, then by role plus name or text.
Cross-platform by shape, not by data. Every method here exists and behaves
the same on Windows and macOS. What differs is the tree it reads: roles are
AXTable / Table, macOS keeps a control’s state in one value where Windows
splits it across Value, ToggleState and IsSelected, and neither platform’s
attribute names mean anything to the other. An action is written against a
screen that was inspected first, so it is not portable — the framework is.
UI.enable_content_access() is the one deliberately one-sided call, exposed so
an action can make it unconditionally.
Contents: UI · UINode · WaitTimeout · FillFailed · ActionCancelled · StaleElement
UIThe action-facing view of the desktop. Reached as keymap.ui.
UI.at_pointat_point(x: 'float', y: 'float') → UINode | None
The element under a screen point, in whichever application owns it.
The cheap way into the text layer: the pointer is usually already over the line the user means (design document §6).
UI.enable_content_accessenable_content_access(
target: 'UINode | None' = None,
enable: 'bool' = True
) → bool
Ask a Chromium or Electron application to expose its content.
macOS only, and safe to call anywhere. Chrome, Edge, VS Code and Slack build no accessibility tree until an assistive client asks: a loaded page measured 59 nodes of browser chrome with no document in it, and 119 with every field addressable once asked. Windows needs nothing equivalent - Chromium enables its renderer tree when a UIA client attaches - so this returns False there, and an action calls it either way rather than branching.
Args:
target: A node in the application, or None for the focused one. Any node will do; the request goes to its application.enable: False to give it back, which is polite and measurably works - Chrome returned to 59 nodes.Returns: True when the platform did something.
UI.focusedfocused() → UINode | None
The element with keyboard focus right now, as a node.
The cheapest root there is: a key binding already told you which application and which field the user meant (design document §3.2).
Asked each time, not remembered. This used to hand back keymap.focus, which is a snapshot taken while a key was being dispatched - so an action that closed a window and waited for focus to land somewhere else never saw it move, and kept being handed the destroyed element, or the application that no longer had a window. Polling did not help, because polling produces no keystrokes and only a keystroke refreshed it (issue #44).
keymap.focus stays what it was, on purpose. Deciding which key table applies to a keystroke needs the focus that keystroke was aimed at, and re-reading it there would race the key it is dispatching. The two are different questions; this is the one an action is asking.
Returns: The focused element, or None when nothing has focus or the platform could not say. None is an answer - a stale element that fails every attribute read is not.
UI.nodenode(element) → UINode | None
Wrap a platform element as a node, reading nothing below it.
The escape hatch for an element obtained some other way - through keymap.focus.element, or a platform call this API does not cover.
UI.on_main_threadon_main_thread(func: 'Callable[[], Any]') → Any
Run func on the event-loop thread and return its result.
Every method here already does this, so an action needs it only to make several reads atomic with respect to a UI that is moving underneath - or to call a platform element method this API does not wrap.
UI.preserve_clipboardpreserve_clipboard()
Put the clipboard back the way it was afterwards.
node.set_text() already does this around its own paste; this is for an action that uses the clipboard for something else.
UI.waitwait(
condition: 'Callable[[], Any]',
timeout: 'float' = 10.0,
message: 'str | None' = None,
interval: 'float | None' = None
) → Any
Block until condition() is truthy, and return what it returned.
For a wait that is not “an element appeared” or “an element went away”
node.wait_for() and node.wait_until_gone(). Never sleep: a fixed delay passes on the machine it was written on, and on a faster one it fails silently, acting on a screen that has not arrived.Raises:
WaitTimeout: The condition never became true.UI.windowwindow(
app: 'str' = None,
title: 'str' = None,
class_name: 'str' = None
) → UINode | None
A top-level window, as a node to search inside.
Matches exactly like keymap.find_window and define_keytable: case-insensitive fnmatch, “ |
” alternation, “.exe” optional on Windows. |
Args:
app: Application name pattern.title: Window title pattern.class_name: Win32 class name pattern (Windows only).Returns: The window’s element as a node, or None when nothing matched.
UI.windowswindows(app: 'str' = None, title: 'str' = None) → list[UINode]
Every matching top-level window, as nodes.
For the cases where “the window” is ambiguous - a browser with several windows open, or an application whose settings live in a second one.
UINodeOne element, projected onto the facts both platforms agree on.
Every member is one of two kinds. find, find_all, reread, the waits and the text layer read the live UI each time they are called, dispatching to the event-loop thread themselves; text, all_text, children, walk and dump are free reads of this snapshot, showing the screen as it was when the node was read.
Attributes:
role: Control role - “AXTextField” (macOS) or “Edit” (Windows). The OS’s own name; match it with role= patterns, which accept the macOS names with or without their “AX” prefix.name: The element’s label, not its content (“Query” for a field labelled Query).value: The element’s content (what is typed into the field, “0”/”1” for a checkbox).name_source: Which attribute name came from - “label”, “description”, “help”, or None when the element has no name. An icon-only button typically has no label and answers one of the other two; nothing at all means it can be addressed only by role and position.identifier: A stable identifier where the platform has one - the DOM id in web content, AXIdentifier in native macOS UI, AutomationId on Windows. The best thing to address an element by when present, since it survives relabelling and localisation.rect: (x, y, w, h) in screen coordinates, or None.depth: Distance below the root the walk started from.element: The platform UIElement, for anything outside this projection.children: Child nodes, in the platform’s own order.truncated: True when this node’s children were cut off by max_depth or max_nodes - so a caller can tell “leaf” from “gave up here”.The text of this element and everything under it.
What a table cell needs: web content puts the visible string in a child AXStaticText, so the cell’s own text is empty and reading a results table off .text silently yields blank columns.
Two kinds of repetition are dropped, both of them WebKit’s doing: a child that merely restates its parent (a label’s AXStaticText carrying the label again, a heading’s child carrying the heading again), and an immediate repeat of the piece just emitted. Repeats that are not adjacent survive on purpose - two cells of a row legitimately holding “37” are data, not noise.
This element’s own label and content, as one string.
Note is not None, not truthiness: an unchecked checkbox’s value is 0 and an empty field’s is “”, and both are facts an action needs. This is the same trap “read before toggling” exists to avoid.
UINode.dumpdump(max_value: 'int' = 60) → str
This subtree as indented text - to read, and to hand to an AI agent.
Prints the snapshot as held: a node from ui.window() or ui.node() has read nothing below itself yet, so reread() first.
UINode.findfind(
max_depth: 'int' = 14,
max_nodes: 'int' = 1000,
**criteria
) → 'UINode | None'
The first element below this one matching criteria, or None.
Reads the live UI at call time - this node’s captured children play no part, so an old window node finds what is on screen now. None rather than an exception, because only the caller knows whether a missing element is a failed precondition or an expected absence - wait_for is the one that insists.
Args:
max_depth: Depth bound for the underlying walk. Web content can nest controls deeper than the default; raise this before concluding an element is not there.max_nodes: Node budget for the underlying walk.**criteria: role, name, value, identifier, text and predicate; patterns are case-insensitive fnmatch with “ |
” alternation. |
UINode.find_allfind_all(
max_depth: 'int' = 14,
max_nodes: 'int' = 1000,
**criteria
) → list['UINode']
Every element below this one matching criteria, in tree order.
The same live read as find - the snapshot is not consulted.
Args:
max_depth: Depth bound for the underlying walk.max_nodes: Node budget for the underlying walk.**criteria: As find.UINode.focusfocus() → bool
Give this element keyboard focus; True when it actually landed.
UINode.line_at_caretline_at_caret() → str | None
The line the caret is on - no selection, no pointer.
UINode.presspress() → None
Press this element, by whichever action name the platform uses.
UINode.read_textread_text() → str | None
The whole text content, descending into child text nodes.
Distinct from the text / all_text properties, which are free reads of the snapshot: this asks the application, and is what a terminal buffer or a document body needs.
UINode.rereadreread(
max_depth: 'int' = 14,
max_nodes: 'int' = 1000,
roles: 'str | None' = None,
prune=None
) → 'UINode'
Read this subtree again, returning a fresh node.
A UINode is a snapshot: the screen moves on, and nothing here notices.
UINode.selectionselection() → str | None
The selected text (“” is a real answer, meaning a bare caret).
UINode.set_checkedset_checked(checked: 'bool') → bool
Set a checkbox, reading it first. True when it pressed.
UINode.set_textset_text(text: 'str', **options) → str
Write text into this field and prove it arrived.
Returns the mechanism that worked; raises FillFailed when none did. Takes the same options as keyhac.core.fill.set_text.
UINode.wait_forwait_for(
timeout: 'float' = 10.0,
message: 'str | None' = None,
max_depth: 'int' = 14,
max_nodes: 'int' = 1000,
**criteria
) → 'UINode'
Wait until an element matching criteria exists below this one.
Args:
timeout: Seconds before giving up.message: What was being waited for, for the timeout error.max_depth: Depth bound for the walk. Every poll walks the tree again, so this is a cost bound as much as a reach bound.max_nodes: Node budget for the walk.**criteria: As find.UINode.wait_until_gonewait_until_gone(
timeout: 'float' = 10.0,
message: 'str | None' = None,
max_depth: 'int' = 14,
max_nodes: 'int' = 1000,
**criteria
) → None
Wait until nothing below this one matches criteria.
A bound makes “gone” mean “not found within the bounds”: an element deeper than max_depth counts as gone.
Args:
timeout: Seconds before giving up.message: What was being waited for, for the timeout error.max_depth: Depth bound for the walk.max_nodes: Node budget for the walk.**criteria: As find.UINode.wait_until_stablewait_until_stable(
quiet: 'float' = 0.3,
timeout: 'float' = 10.0,
**bounds
) → None
Wait until this subtree stops changing.
UINode.walkwalk() → Iterator['UINode']
This node and every descendant in the snapshot, depth first.
A walk over what was captured, not what is on screen: it yields the nodes already held, asking the OS nothing. On a node read with max_depth=0 - which is what ui.window() and ui.node() return - that is this node alone. find_all() is the one that searches the live tree; reread().walk() traverses a fresh capture.
WaitTimeoutA wait gave up.
Deliberately its own type, and deliberately an error rather than a False return: an action whose precondition never arrived must stop, not carry on against a screen that is not there (design document §3.7).
FillFailedA write did not take.
Carries what was attempted, because “the field is still empty” and “the field has the wrong text” want different responses from the caller.
ActionCancelledRaised inside a running action when the user cancels it with Esc.
Derived from BaseException rather than Exception, and that is the point. An action of the kind this framework exists for catches Exception around each item, because partial failure is the thing it is built to survive:
for system in self.systems:
try:
self._read_system(system, rows)
except Exception as error: # <- would swallow a cancellation
self.failed.append((system["name"], str(error)))
Were this an ordinary Exception, pressing Esc there would be recorded as “SystemA failed” and the run would carry on to SystemB - the one thing cancelling must not do. As a BaseException it passes through every such handler while still unwinding the action’s finally blocks, so progress already written stays written.
Cancellation is KeyboardInterrupt’s cousin, not an error. An action never needs to know this class exists: wait_for raises it, and long actions spend most of their time waiting.
StaleElementThe element this node was read from no longer exists.
A UINode is a snapshot. It records what an element was when the tree was walked; the screen moves on and the node does not notice. That is the contract on purpose - a node that quietly re-read itself would hide exactly the change an action’s preconditions exist to catch.
So the node has to say when it has gone stale, and say it in a way an action can act on. The distinction this exists for is the one §3.7 turns on:
StaleElement - the screen moved. Re-find the element and carry on, or stop and hand back to a human. The action is not wrong.FillFailed / an empty search - the selector is wrong. The action was written against a screen that is not this one, and running it again will fail the same way.Before this existed both arrived as “element supports no press action”, because a dead element reports no actions - true, and the least useful true thing to say.
Generated from the docstrings by make api-reference. Edit the
docstrings, not this file.