← Keyhac crftwr/keyhac on GitHub · craftware

Action API reference

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 UINode is 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_all and the waits read the live screen each call regardless of the node’s age; walk, dump, children and the text properties show only what was captured. reread() refreshes one deliberately, and StaleElement is 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 from FillFailed, which means the selector was wrong. Address elements by identifier where 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

class UI

The action-facing view of the desktop. Reached as keymap.ui.


method UI.at_point

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


method UI.enable_content_access

enable_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:

Returns: True when the platform did something.


method UI.focused

focused()  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.


method UI.node

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


method UI.on_main_thread

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


method UI.preserve_clipboard

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


method UI.wait

wait(
    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”

Raises:


method UI.window

window(
    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:

Returns: The window’s element as a node, or None when nothing matched.


method UI.windows

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


class UINode

One 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:


property UINode.all_text

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.


property UINode.text

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.


method UINode.dump

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


method UINode.find

find(
    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:


method UINode.find_all

find_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:


method UINode.focus

focus()  bool

Give this element keyboard focus; True when it actually landed.


method UINode.line_at_caret

line_at_caret()  str | None

The line the caret is on - no selection, no pointer.


method UINode.press

press()  None

Press this element, by whichever action name the platform uses.


method UINode.read_text

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


method UINode.reread

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


method UINode.selection

selection()  str | None

The selected text (“” is a real answer, meaning a bare caret).


method UINode.set_checked

set_checked(checked: 'bool')  bool

Set a checkbox, reading it first. True when it pressed.


method UINode.set_text

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


method UINode.wait_for

wait_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:


method UINode.wait_until_gone

wait_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:


method UINode.wait_until_stable

wait_until_stable(
    quiet: 'float' = 0.3,
    timeout: 'float' = 10.0,
    **bounds
)  None

Wait until this subtree stops changing.


method UINode.walk

walk()  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.


class WaitTimeout

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


class FillFailed

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


class ActionCancelled

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


class StaleElement

The 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:

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.