Skip to content

Avalonia Desktop app automation

UI automation and accessibility for the Avalonia desktop and browser apps.

Overview

The Avalonia desktop app exposes accessibility metadata so that screen readers and UI-automation agents (including AI agents) can identify, describe, and operate every interactive control.

This is implemented by setting attached properties from the Avalonia.Automation namespace on controls in .axaml:

  • AutomationProperties.AutomationId — a stable, programmatic selector (flat PascalCase, e.g. StartButton, ScaleSlider). Maps to the platform-specific identifier (AX identifier on macOS, AutomationId on Windows UIA).
  • AutomationProperties.Name — a short human-readable label (e.g. "Start", "Scale"). Maps to AX title on macOS / Name on UIA. Read aloud by screen readers.

The AutomationProperties attached properties live in the default https://github.com/avaloniaui XML namespace, so no extra xmlns is required.

Source: src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/Views/.

What Avalonia auto-generates (no attributes needed)

Even without any AutomationProperties attributes, Avalonia already surfaces some data via its automation peers:

Source in XAML Auto-mapped to
Button.Content="Start" AX label / UIA Name = "Start"
TabItem.Header="Log" AX label / UIA Name = "Log"
Name="MyControl" / x:Name AX identifier / UIA AutomationId
ToolTip.Tip="…" AX help / UIA HelpText
Window.Title="…" AX window title

Because of this, controls with Name="…" already get a stable identifier for free. The codebase keeps existing Name= attributes (they're also used by code-behind FindControl lookups) and adds explicit AutomationProperties.AutomationId alongside — decoupling the automation identity from the code-behind field name.

Conventions used in this codebase

  1. Flat PascalCase ids matching existing Name= style: StartButton, SystemSelectionComboBox, ScaleSlider, LogTab, OkButton.
  2. Per-row dynamic ids for ItemsControl templates — bound to an identifying field. Pattern:
    <Button AutomationProperties.AutomationId="{Binding FileName, StringFormat='ScriptRow.Reload.{0}'}"
            AutomationProperties.Name="Reload script"
            Content="↻" />
    
    Use . as separator (e.g. ScriptRow.Reload.snake.lua, RomFileTextBox.Kernal). Escape leading {0} in a StringFormat with {} to prevent the XAML parser from interpreting it as a markup extension:
    StringFormat='{}{0} ROM file name'
    
  3. Icon-only buttons (↻, +, i, pencil, trash) need both AutomationId and a descriptive Name, because the Content is a glyph that doesn't describe the action.
  4. Text-only buttons (Content="Start") — Content already becomes the AX label, so only AutomationId is strictly needed. Name is added when Content is bound or ambiguous. State-toggling buttons whose Content is bound (e.g. DiskToggleButton, whose caption flips between "Insert .dsk image" and "Eject disk") should bind Name to that same property. A static Name overrides the caption in the accessibility tree, so agents and screen readers would no longer be able to tell which state the control is in.
  5. Decorative elements (TextBlock labels, Border, Image, layout Grid) do not get AutomationProperties unless an agent needs to read their text (e.g. status strings).
  6. Container-level AutomationId on root UserControl/Window elements (StatisticsView, C64InfoView, EmulatorPlaceholderView, dialog windows) so agents can locate and scope to a view.

What is surfaced

A non-exhaustive list of the most useful AutomationIds, grouped by view. All of these are present in the compiled app and queryable via the platform's accessibility API.

MainView (always visible)

  • System selection: SystemSelectionComboBox, SystemVariantSelectionComboBox
  • Emulator control: StartButton, PauseButton, ResetButton, StopButton, MonitorButton, StatsButton
  • Native keyboard: NativeKeyboardButton (browser touch-first primary pointer only; hidden by default and in the native desktop app; caption/name toggles Keyboard / Hide keyboard; enabled only while a supported machine is running), NativeKeyboardPanel, NativeKeyboardTextBox, NativeKeyboardStatus (input rejection status). The field is hidden until the button is clicked and clears after each committed text chunk. Pause, Stop, monitor opening, and focus loss cancel pending input.
  • Display/audio/settings: ScaleSlider, AudioCheckBox, AudioVolumeSlider, settings button OptionsButton
  • Snapshot section (collapsible, common): header SnapshotSectionHeader, content SnapshotSectionContent; buttons SaveSnapshotButton, LoadSnapshotButton, directory link SnapshotFolderLink (visible only when the section is expanded — collapsed by default; toggle it with the Emulator menu / ⌘⌥⇧S shortcut)
  • Status: EmulatorStateText
  • Bottom tab control: InformationTabControl with tabs InformationTab, ConfigStatusTab, LogTab, ScriptsTab, GeneralInfoTab, DebugAndRemotingTab
  • Log tab: ClearLogButton
  • Scripts tab: ScriptsBannerRefreshButton, script directory link ScriptFolderLink, AddScriptButton, LoadExamplesButton, ScriptsRefreshButton; sort headers SortByFileNameButton, SortByStatusButton, SortByYieldButton, SortByHooksButton
  • Script rows (dynamic): ScriptRow.ToggleEnabled.<FileName>, ScriptRow.Reload.<FileName>, ScriptRow.Edit.<FileName>, ScriptRow.Delete.<FileName>
  • Debug tab: ExternalDebugToggleButton, ExternalDebugPortInput, DebugSoundButton, DebugGamepadButton

C64MenuView (sidebar)

  • Basic clipboard: CopyBasicButton, PasteTextButton, AiBasicCheckBox, AiBasicInfoButton
  • Collapsible section headers: DiskSectionHeader, LoadSaveSectionHeader, ConfigSectionHeader
  • Disk section: DiskToggleButton, DiskInfoButton, PreloadedDiskComboBox, PreloadedDiskInfoButton, DownloadAndRunDiskButton
  • Load/Save section: LoadBasicButton, SaveBasicButton, LoadBinaryButton, AssemblyExampleComboBox, LoadAssemblyExampleButton, BasicExampleComboBox, LoadBasicExampleButton
  • Config section: ActiveJoystickComboBox, JoystickKeyboardCheckBox, KeyboardJoystickComboBox, C64ConfigButton

C64ConfigDialog / C64ConfigUserControl

  • Window: C64ConfigDialog
  • ROMs (dynamic per ROM): RomFileTextBox.Kernal, RomFileTextBox.Basic, RomFileTextBox.Chargen
  • ROM actions: RomDirectoryTextBox, ClearRomsButton, LoadRomsButton, DownloadRomsButton, DownloadRomFilesButton
  • Video: RenderProviderComboBox, RenderTargetComboBox, PerLineSpritesCheckBox, LegacyPixelGeneratorCheckBox
  • Audio: SidAudioCheckBox
  • Input: HostJoystickComboBox, KeyboardJoystickEnableCheckBox, KeyboardJoystickPortComboBox, KeyboardMappingsExpander
  • Network: CorsProxyTextBox, CorsProxyResetButton
  • AI assistant: AiHelpButton, AiBackendComboBox, OpenAiApiKeyTextBox, OllamaEndpointTextBox, OllamaModelNameTextBox, OllamaApiKeyTextBox, CustomEndpointApiKeyTextBox, TestAiBackendButton
  • Footer: CancelButton, OkButton

C64 acknowledgment and ROM download overlays

  • Acknowledgment root: C64AcknowledgmentDialog
  • Consent controls: C64AcknowledgmentLicenseCheckBox, C64AcknowledgmentConfirmButton, C64AcknowledgmentCancelButton, C64AcknowledgmentRomUrlButton
  • Download root: C64RomDownloadDialog
  • Download actions: C64RomDownloadCancelButton, C64RomDownloadOpenConfigButton

Apple2MenuView (sidebar)

  • Root: Apple2MenuView
  • Basic clipboard: CopyBasicButton, PasteTextButton
  • Collapsible section headers: DownloadSectionHeader, DownloadSectionContent, DiskSectionHeader, DiskSectionContent, LoadSaveSectionHeader, LoadSaveSectionContent, ConfigSectionHeader, ConfigSectionContent
  • Download & Run section: PreloadedDiskComboBox, DownloadAndRunDiskButton
  • Disk drive section (Disk II): DiskToggleButton (insert/eject, label bound to state like the C64's DiskToggleButton), BootDiskButton (only visible with a disk inserted), DriveStatusText
  • Configuration section: OpenApple2ConfigButton (only enabled while the emulator is stopped — see the note below), KeyboardJoystickCheckBox (usable while running)
  • Load/Save section: LoadBasicButton, SaveBasicButton, LoadBinaryButton, LoadFromDskButton, DskFileComboBox, RunDskFileButton, AssemblyExampleComboBox, LoadAssemblyExampleButton, BasicExampleComboBox, LoadBasicExampleButton
  • Config section: OpenApple2ConfigButton

Apple2ConfigDialog / Apple2ConfigDialogView

  • Window / root: Apple2ConfigDialog, Apple2ConfigDialogView
  • ROMs (dynamic per ROM): RomFileTextBox.apple2, RomFileTextBox.chargen, RomFileTextBox.disk2 (optional Disk II boot ROM)
  • ROM actions: RomStatusSummaryText, RomDirectoryTextBox, LoadRomsButton, DownloadRomsToFilesButton, DownloadRomsToMemoryButton, ClearRomsButton
  • Memory: LanguageCardEnableCheckBox — fits the 16 KB language card (64 KB total). On by default; unchecking it gives a stock 48 KB machine, which cannot run ProDOS software. Read at build time, so it takes effect on the next start rather than live.
  • Display: MonitorColorComboBox, RenderProviderComboBox, RenderTargetComboBox
  • Audio: AudioEnableCheckBox, AudioProviderComboBox, AudioTargetComboBox. Audio needs both a provider and a target selected; a provider alone leaves the host building no audio coordinator, and the machine is then silent with nothing logged to say why.
  • Input & Joystick: KeyboardLayoutComboBox — host keyboard layout (Auto plus each explicit layout; Auto means auto-detect). Same id as the C64 dialog's equivalent, so a check that walks both dialogs uses one name.
  • Input & Joystick: KeyboardJoystickEnableCheckBox — the persisted setting. Its live counterpart is KeyboardJoystickCheckBox in the sidebar, needed because this dialog only opens while the emulator is stopped. Both show the same setting and stay in step; the difference is that only the dialog's OK writes it to disk. The sidebar checkbox is always enabled.
    Caveat: CheckBox controls do not appear in the macOS accessibility tree in this app at all — verified against this dialog, where every button and combobox enumerates but no checkbox does. Their AutomationIds are set correctly and are right for other hosts, but on macOS a checkbox has to be verified from a screenshot rather than from inspect-ui.
  • CPU: CpuModelComboBox (NMOS 6502 or NCR 65C02 — the models an Apple II can have), CpuCompatibilityProfileComboBox. The profile list is dependent on the selected model: it only offers the profiles that model supports, and auto-corrects the selection when the model changes (picking the 65C02 collapses it to "Official only").
  • Messages: ConfigStatusMessageText (non-error status), ConfigErrorStatusMessageText (error status), ConfigValidationMessageText (bulleted validation-error list)
  • Footer: ResetToDefaultsButton, CancelButton, OkButton

OricConfigDialogView

  • Root: OricConfigDialogView
  • ROM actions: OricRomStatusText, OricRomDirectoryTextBox, OricLoadRomButton, OricDownloadRomButton, OricClearRomButton
  • Audio: OricAudioEnabledCheckBox
  • Hardware compatibility: OricVSyncHackEnabledCheckBox — enables the optional RGB-sync-to-cassette-input CB1 compatibility cable; disabled by default for a stock Atmos.
  • Input & Joystick: OricKeyboardLayoutComboBox — host keyboard layout (Auto, US, or Swedish; Auto means auto-detect), OricJoystickInterfaceComboBox, OricHostJoystickComboBox, OricKeyboardJoystickEnableCheckBox, OricKeyboardJoystickPortComboBox
  • Messages: OricConfigStatusText
  • Footer: OricConfigCancelButton, OricConfigSaveButton

The download button opens an ownership/licence acknowledgement overlay before fetching the copyrighted firmware. Its buttons are OricRomDownloadConfirmButton and OricRomDownloadCancelButton.

OricMenuView

  • Root: OricMenuView
  • Basic clipboard: CopyBasicButton, PasteTextButton (enabled while the emulator is running)
  • Collapsible section headers: DownloadSectionHeader, DownloadSectionContent, TapeSectionHeader, TapeSectionContent, LoadSaveSectionHeader, LoadSaveSectionContent, ConfigSectionHeader, ConfigSectionContent
  • Download & Run: OricDownloadProgramComboBox, OricDownloadAndRunButton, OricDownloadStatusText, OricDownloadErrorText
  • Tape transport: TapeAttachButton, TapeRewindButton, TapePreviousRecordButton, TapeNextRecordButton, TapeEjectButton, TapeStatusText. Attach/replace validates and inserts a TAP image without loading it; previous/next move between parsed file boundaries.
  • Load/Save: LoadBasicButton, AssemblyExampleComboBox, LoadAssemblyExampleButton, BasicExampleComboBox, LoadBasicExampleButton (enabled after the emulator has been initialized). The Raster Bars assembly example automatically enables the CB1 VSync compatibility cable, while No-CB1 Bars disables it; both restart the machine before loading their TAP image.
  • Joystick: JoystickInterfaceComboBox, ActiveJoystickComboBox, JoystickKeyboardCheckBox, KeyboardJoystickComboBox. The interface and port selectors work while the machine is running; keyboard joystick uses W/A/S/D and Space.
  • Configuration: OpenOricConfigButton (enabled only while the emulator is stopped)

OricInfoView

  • Root: OricInfoView
  • Keyboard reference: OricKeyboardMappingTable (read-only table with separate Atmos, PC, and Mac key columns)

Vic20MenuView (sidebar)

  • Root: Vic20MenuView
  • Basic clipboard: CopyBasicButton, PasteTextButton (same ids as the other menus — only one system's menu is visible at a time)
  • Collapsible section headers: LoadSaveSectionHeader, LoadSaveSectionContent, ConfigSectionHeader, ConfigSectionContent. Unlike the C64 and Apple II menus these are plain toggles, not an accordion — expanding one section does not collapse the other.
  • Load/Save section: LoadBasicButton, SaveBasicButton, LoadBinaryButton, AssemblyExampleComboBox, LoadAssemblyExampleButton, BasicExampleComboBox, LoadBasicExampleButton
  • Configuration section: Vic20ConfigButton (only enabled while the emulator is stopped; no attention pulse — the VIC-20 menu does not use ButtonFlashController), ActiveJoystickComboBox, JoystickKeyboardCheckBox, KeyboardJoystickComboBox

Vic20ConfigDialog / Vic20ConfigDialogView

  • Window / root: Vic20ConfigDialog, Vic20ConfigDialogView
  • ROMs (dynamic per ROM): Vic20RomFileTextBox.basic, Vic20RomFileTextBox.kernal, Vic20RomFileTextBox.chargen
  • ROM actions: Vic20RomDirectoryTextBox, Vic20ClearRomsButton, Vic20LoadRomsButton, Vic20DownloadRomsButton, Vic20DownloadRomFilesButton
  • CPU: Vic20CpuCompatibilityProfileComboBox — profile only; the VIC-20's CPU model is fixed (NMOS 6502, the machine's identity), so there is no model picker here.
  • Video: Vic20RenderProviderComboBox, Vic20RenderTargetComboBox
  • Footer: Vic20ConfigResetToDefaultsButton, Vic20ConfigCancelButton, Vic20ConfigOkButton

GenericComputerMenuView (sidebar)

  • Root: GenericComputerMenuView
  • Collapsible section: GenericConfigSectionHeader, GenericConfigSectionContent (expanded by default; the Configuration section is the menu's only content)
  • Configuration section: OpenGenericConfigButton (only enabled while the emulator is stopped)

GenericConfigDialog / GenericConfigDialogView

  • Window / root: GenericConfigDialog, GenericConfigDialogView
  • CPU: GenericCpuModelComboBox (all CPU models — the Generic machine has no fixed identity), GenericCpuCompatibilityProfileComboBox (dependent on the selected model, same auto-correcting behavior as the Apple II dialog)
  • Footer: GenericConfigCancelButton, GenericConfigOkButton

EmulatorConfigUserControl (general settings)

DefaultEmulatorComboBox, DefaultScaleSlider, ShowErrorDialogCheckBox, ShowDebugToolsCheckBox, AudioProfileComboBox, StopOnBrkCheckBox, StopOnUnknownInstructionCheckBox, SnapshotDirectoryTextBox, LuaScriptDirectoryTextBox, LuaStorePrefixTextBox, CancelButton, OkButton.

MonitorDialog / MonitorUserControl

  • Window: MonitorDialog
  • OutputScrollViewer, CommandTextBox, SendCommandButton, CloseMonitorButton, MonitorStatusScroll

ScriptEditorDialog

  • Root: ScriptEditorDialog
  • FileNameBox, ContentBox, CancelButton, SaveButton

Debug views

  • DebugSoundUserControl: InitAudioButton, PlayAudioButton, PauseAudioButton, StopAudioButton, PlaySynthButton, StartSynthReleaseButton, StopSynthButton, SoundTestComboBox, PlaySoundButton, StopSoundButton, CloseButton
  • DebugGamepadUserControl: CloseButton (remainder is read-only visual status)

ErrorUserControl

ErrorMessageTextBox, ExceptionDetailsTextBox, ShowDetailsButton, ContinueButton, ExitButton.

Other containers

  • EmulatorView (root) with EmulatorRenderSurface on the render ContentPresenter
  • EmulatorPlaceholderView (root, pre-start logo)
  • StatisticsView (root)
  • C64InfoView (root, keyboard mapping reference)
  • Apple2InfoView (root, keyboard mapping reference)

Keyboard shortcuts

The app exposes these layers of shortcuts:

  1. General tab-navigation shortcuts — always active, independent of which emulator system is running.
  2. Common (non-system-specific) shortcuts — always active, for cross-system functions such as toggling the Snapshot sidebar section.
  3. System-specific shortcuts — active only when a particular system is selected (e.g. C64). The active system's menu ViewModel implements ISystemMenuContributor (Core/SystemSetup/ISystemMenuContributor.cs).

On macOS all layers appear in the OS-level system menu bar (outside the app window) — general (tab) shortcuts under a View top-level menu, common shortcuts under an Emulator top-level menu, and system-specific shortcuts under the system name (e.g. C64). The macOS Accessibility API exposes these with their Gesture string, so an AI agent can discover them at runtime without prior documentation:

peekaboo menu list --app "DotNet 6502 Emulator"

On Windows / Linux, NativeMenu would render as in-window chrome (not desired), so KeyBindings registered on the main window are used instead. They fire regardless of which child control has focus, but are invisible to accessibility tools — an agent needs to know them from this document.

Tab navigation shortcuts (always active)

These shortcuts jump directly to a named tab regardless of tab order — reordering tabs in code does not break automation scripts.

Tab macOS Windows / Linux
Information ⌘⌥I Ctrl+Alt+I
Config status ⌘⌥C Ctrl+Alt+C
Log ⌘⌥L Ctrl+Alt+L
Scripts ⌘⌥S Ctrl+Alt+S
General info ⌘⌥G Ctrl+Alt+G
Debug ⌘⌥D Ctrl+Alt+D

On macOS, click via the menu bar instead of counting arrow-key presses:

peekaboo menu click --app "DotNet 6502 Emulator" --path "DotNet 6502 Emulator > View > Log"

Common shortcuts (always active — Emulator menu)

Cross-system functions, available regardless of which system is selected.

Action macOS Windows / Linux
Toggle Snapshot section ⌘⌥⇧S Ctrl+Alt+Shift+S

On macOS, click via the menu bar:

peekaboo menu click --app "DotNet 6502 Emulator" --path "DotNet 6502 Emulator > Emulator > Toggle Snapshot section"

C64 shortcuts (active when the C64 system is selected)

Action macOS Windows / Linux
Toggle Disk Drive section ⌘⌥⇧D Ctrl+Alt+Shift+D
Toggle Load/Save section ⌘⌥⇧L Ctrl+Alt+Shift+L
Toggle Configuration section ⌘⌥⇧C Ctrl+Alt+Shift+C
Active joystick → Port 1 ⌘⌥1 Ctrl+Alt+1
Active joystick → Port 2 ⌘⌥2 Ctrl+Alt+2
Toggle Joystick KB ⌘⌥K Ctrl+Alt+K
Keyboard joystick → Port 1 ⌘⌥⇧1 Ctrl+Alt+Shift+1
Keyboard joystick → Port 2 ⌘⌥⇧2 Ctrl+Alt+Shift+2

On macOS, click via the menu bar:

peekaboo menu click --app "DotNet 6502 Emulator" --path "DotNet 6502 Emulator > C64 > Toggle Configuration section"

Apple II shortcuts (active when the Apple2 system is selected)

Action macOS Windows / Linux
Toggle Download & Run section ⌘⌥⇧D Ctrl+Alt+Shift+D
Toggle Load/Save section ⌘⌥⇧L Ctrl+Alt+Shift+L
Toggle Configuration section ⌘⌥⇧C Ctrl+Alt+Shift+C
Toggle Joystick KB ⌘⌥K Ctrl+Alt+K

Toggle Joystick KB uses the same key as the C64's and VIC-20's, so the shortcut means the same thing whichever machine is loaded.

Oric shortcuts (active when the Oric system is selected)

Action macOS Windows / Linux
Toggle Download & Run section ⌘⌥⇧D Ctrl+Alt+Shift+D
Toggle Tape section ⌘⌥⇧T Ctrl+Alt+Shift+T
Toggle Load/Save section ⌘⌥⇧L Ctrl+Alt+Shift+L
Toggle Configuration section ⌘⌥⇧C Ctrl+Alt+Shift+C
Active joystick → Port 1 ⌘⌥1 Ctrl+Alt+1
Active joystick → Port 2 ⌘⌥2 Ctrl+Alt+2
Toggle Joystick KB ⌘⌥K Ctrl+Alt+K
Keyboard joystick → Port 1 ⌘⌥⇧1 Ctrl+Alt+Shift+1
Keyboard joystick → Port 2 ⌘⌥⇧2 Ctrl+Alt+Shift+2

On macOS these commands appear under the Oric system menu.

VIC-20 shortcuts (active when the VIC-20 system is selected — VIC-20 menu)

Action macOS Windows / Linux
Toggle Load/Save section ⌘⌥⇧L Ctrl+Alt+Shift+L
Toggle Configuration section ⌘⌥⇧C Ctrl+Alt+Shift+C
Active joystick → Port 1 ⌘⌥1 Ctrl+Alt+1
Active joystick → Port 2 ⌘⌥2 Ctrl+Alt+2
Toggle Joystick KB ⌘⌥K Ctrl+Alt+K
Keyboard joystick → Port 1 ⌘⌥⇧1 Ctrl+Alt+Shift+1
Keyboard joystick → Port 2 ⌘⌥⇧2 Ctrl+Alt+Shift+2

Testing the keyboard joystick from automation: apple2.type will not do it. That command goes through the text-paste service, which writes ASCII straight to the keyboard latch and never becomes a HostKey, so the joystick mapping cannot see it. Use keyboard.press --key w and read back PDL(1) — 0 means the key steered, 128 means it did not.

Sidebar sections behave as an accordion in the C64, Apple II, and Oric menus (shared AccordionSections helper): expanding one section collapses the others. Automation scripts must therefore expand a section (header button or shortcut) before its inner controls exist in the accessibility tree.

One exception worth knowing when a script asserts on section state: if the selected system's configuration is invalid — normally a fresh install whose ROM files are missing — the menu expands its Configuration section by itself and pulses the config button (C64Config / OpenApple2ConfigButton / OpenOricConfigButton) orange until the configuration becomes valid or the button is clicked. So a Configuration section found already expanded is a signal about the config, not a leftover from an earlier step. The pulse is a Background change driven from the view (shared ButtonFlashController) and is not exposed through the accessibility tree; a screenshot is the only way to observe it.

The Apple II menu contributes the Download & Run section (curated downloadable programs), the Load/Save section (Applesoft Basic and DOS 3.3 binary files, loading files from .dsk images, plus example programs) and the Configuration section — which is the route to the ROM settings dialog. It also contributes the Disk drive section, which inserts a .dsk image in the emulated Disk II and boots from it — distinct from the Load/Save section, where .dsk images are only a file source for injecting a program into RAM.

In addition to the menu shortcut above, the running Apple II emulator itself handles Ctrl+F12 as CTRL-RESET (warm reset through the reset vector). It is emulator keyboard input, not a menu item, so it only works while the emulator screen has focus and the system is running.

Emulator keyboard state and focus

The emulator view clears all tracked keys-down whenever it stops receiving keyboard input — when focus moves to another control, or when the window deactivates (app switch, the macOS screenshot overlay). This prevents permanently "stuck" keys whose key-up was delivered elsewhere (e.g. the Shift of a Cmd+Shift+4 screenshot chord). Automation consequence: a key held across a click on another control (or an app switch) is released, not held — press keys only while the emulator view keeps focus for the whole down/up pair.

What is NOT surfaced (known gaps)

  1. Individual TabItem controls on macOS — verified with peekaboo see after running the app. The InformationTabControl surfaces, but its TabItem children (InformationTab, LogTab, etc.) do not appear as distinct clickable elements in the AX tree, despite having explicit AutomationProperties.AutomationId + Name. The AX tree on macOS reports roles limited to button, group, menu, other, slider — no AXTabGroup / AXTab.

This is most likely an Avalonia TabItemAutomationPeer / macOS NSAccessibility bridge limitation, not a bug in this codebase. Worth filing an issue upstream in avaloniaui/Avalonia.

Workaround: use keyboard navigation — this is the reliable approach. Find the InformationTabControl element via peekaboo see, click it to give it focus, then press the right-arrow key once per tab step:

# Capture the AX tree and find InformationTabControl's elem_NN
peekaboo see --app "DotNet 6502 Emulator" --json | jq '.. | objects | select(.identifier == "InformationTabControl") | .id'
# → e.g. "elem_49"

# Focus the tab control
peekaboo click --on elem_49 --app "DotNet 6502 Emulator" --window-index 0

# Navigate right to reach the target tab (count depends on which tab is currently active)
# Tab order: Information → ConfigStatus → Log → Scripts → GeneralInfo → Debug
peekaboo press right   # repeat as needed

The number of right-arrow presses depends on the currently active tab, not a fixed offset. If "Information" is active, pressing right twice reaches "Log". If another tab is already active, adjust accordingly.

Avoid clicking by screen coordinates for tabs: coordinates are window-size-dependent and scale across display densities. Avoid peekaboo click "Log": text-query matching is global and can hit an element with the same label in another app or inside the tab's content area (e.g. Ghostty's "Log Out" menu).

  1. Collapsed/conditional content only appears in the AX tree when its container is rendered. Examples:
    • MainView common SnapshotSectionContent (SaveSnapshotButton, LoadSnapshotButton) — collapsed by default; expand via the Emulator menu / ⌘⌥⇧S shortcut or by clicking SnapshotSectionHeader.
    • C64MenuView section contents (DiskSectionContent, LoadSaveSectionContent, ConfigSectionContent) — only visible when the section header is expanded.
    • Some tools in Debug & Remoting tab contents — hidden until ShowDebugTools is enabled in EmulatorConfig.
    • Dialog controls (C64ConfigDialog, MonitorDialog, ScriptEditorDialog) — only exist while the dialog is open.

To automate these, expand/open the container first, then re-query the AX tree.

  1. Dynamic row ids change with data. ScriptRow.Reload.<FileName> depends on the loaded script set. An agent must first query the Scripts tab to discover current rows.

  2. Browser/WebAssembly target. Automation there goes through the DOM, not Avalonia's AX bridge. Accessibility attributes in .axaml propagate only where the Avalonia runtime has a peer — the browser story is not covered in this document. The HTML zoom controls have stable DOM ids: browser-zoom-out, browser-zoom-level, browser-zoom-in, browser-zoom-fit, browser-zoom-reset, browser-zoom-toggle, and browser-orientation-toggle. Orientation feedback uses browser-orientation-status (role="status"). Rotate appears on touch devices when the zoom toolbar is needed. With orientation lock/unlock and fullscreen support, it requests the opposite orientation on click. Without those APIs it uses aria-disabled="true" and disabled styling, while remaining focusable/tappable to explain manual device rotation. Hover, focus, and tap show the guidance in browser-orientation-status; no fullscreen or rotation request is made. Tap-opened help stays open for reading; tapping Rotate again, tapping outside, pressing Escape, or Reset dismisses it. Tapping inside the explanation keeps it open. The first tap stays open even if hover/focus events precede the click; hover/focus help alone expires after ten seconds. Its accessible label describes the target and fullscreen requirement. If locking reports unsupported, Rotate stays visible but disabled, its label explains the limitation, and a persistent status message directs device-emulation testers to the browser device toolbar. Reset dismisses the message. Reset also unlocks orientation; exiting fullscreen unlocks it too. No orientation change is requested at startup. The toggle exposes aria-expanded and an accessible Hide/Show zoom controls label; it appears as a text-free bottom-left handle with a 44-pixel-wide touch target when collapsed. When expanded, the target narrows to avoid overlapping the nearby zoom buttons. The same button and icon stay at identical viewport coordinates in both states; only the chevron reverses (left when expanded, right when collapsed). It collapses/reopens browser-zoom-options without changing zoom or scroll position. Fit scales both dimensions and scrolls to the top-left; Reset restores 100% and the top-left. The controls stay hidden until the first Avalonia content measurement, then appear when the unscaled app exceeds the browser window or an orientation lock is active. Wheel/trackpad scrolling and native touch panning over the canvas are enabled when the scaled app overflows the viewport-sized browser-app-scroll panel. The toolbar is outside that panel so app zoom does not resize it or the document; rapid toolbar taps suppress native double-tap zoom while pinch zoom remains available. Browser overlays use the visible canvas rectangle supplied by JavaScript (after zoom and scroll conversion), including nested dialogs; desktop overlays retain their window/container layout.

Automating on macOS via peekaboo

peekaboo is a CLI tool for macOS UI automation (screenshots + AX tree traversal + input). These notes are from hands-on verification with the desktop app.

One-time setup

Grant peekaboo these permissions (System Settings → Privacy & Security):

  • Screen Recording (required for see / capture)
  • Accessibility (required for click, type, focus management)

Verify with:

peekaboo permissions

Discovering the AX tree

# Launch the app first (from the repo root)
dotnet run --project src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop

# In another terminal, capture the current AX state
peekaboo see --app "DotNet 6502 Emulator"

# With an annotated screenshot showing clickable element IDs (elem_NN)
peekaboo see --app "DotNet 6502 Emulator" --annotate /tmp/ax.png

# Full JSON dump for scripting
peekaboo see --app "DotNet 6502 Emulator" --json > /tmp/ax.json

The app's process name under macOS is DotNet 6502 Emulator (set via Application.Name in App.axaml, which Avalonia applies to NSApplication at startup). Confirm with:

peekaboo list

Clicking controls

peekaboo's click command has several selection modes:

# 1. By the short "elem_NN" id from the latest `see` snapshot:
peekaboo click --on elem_24 --app "DotNet 6502 Emulator" --window-index 0

# 2. By a specific snapshot id (useful if you captured earlier):
peekaboo click --on elem_24 --snapshot <UUID-from-see> --app "DotNet 6502 Emulator" --window-index 0

# 3. By text query (matches Name/title/label):
peekaboo click "Start" --app "DotNet 6502 Emulator" --window-index 0

# 4. By absolute screen coordinates:
peekaboo click --coords "440,595" --app "DotNet 6502 Emulator" --window-index 0

Gotchas (learned the hard way)

  • Always pass --window-index 0 when targeting this app. The Avalonia runtime creates a secondary hidden window, and without a window scope the focus step in click times out with Error: Timeout while waiting for condition.

  • Don't use --no-auto-focus from a terminal. The terminal emulator (e.g. Ghostty) reclaims focus between commands, so a --no-auto-focus click lands on the terminal window at the same screen coordinates — click still reports "✅ Click successful" but against the wrong app. Let peekaboo's auto-focus bring the Avalonia window forward.

  • Text-query clicks are unreliable for tabs — and can hit other apps. peekaboo click "Log" searches globally across all visible AX elements. It can match a label inside the tab content, a menu item in another app (e.g. Ghostty's "Log Out" item), or any other element named "Log" that happens to be on screen. For tab navigation, always use the keyboard approach described in "What is NOT surfaced" item 1 above.

  • Screenshot coordinates vs. screen coordinates. The annotated screenshot from peekaboo see --annotate is scaled to roughly 0.75× the window-point size. To convert a pixel position in the screenshot to a click coordinate, scale by ~1.33× and add the window's screen offset (peekaboo list shows the window Position).

  • AXIdentifier-based clicking isn't directly supported. peekaboo's --id / --on flags take the elem_NN token from a see snapshot, not the AutomationProperties.AutomationId string. To select by AutomationId, parse the JSON from peekaboo see --json and look for identifier == "StartButton" to find the corresponding elem_NN, then pass that to click --on.

  • Sidebar buttons (C64MenuView) are not reachable via peekaboo click. Controls in the left-hand sidebar — DownloadAndRunDiskButton, PreloadedDiskComboBox, LoadBasicButton, etc. — do not appear in the AX tree that peekaboo enumerates, so neither text-query nor elem_NN clicks work. peekaboo click --coords also fails because it still attempts AX focus resolution internally when --app is given. The reliable fallback is AppleScript coordinate-click, which does a raw hit-test outside the AX tree:

tell application "DotNet 6502 Emulator" to activate
delay 0.5
tell application "System Events"
    tell process "DotNet 6502 Emulator"
        set winPos to position of window 1
        -- Replace (dx, dy) with the button's offset from the window's top-left corner
        click at {(item 1 of winPos) + dx, (item 2 of winPos) + dy}
    end tell
end tell

From a shell script, wrap this in osascript -e '...' or osascript <<'EOF' ... EOF. Verified working for DownloadAndRunDiskButton (offset approx. x+90, y+585 at default window size). For sidebar actions that have a C64 menu shortcut (toggle sections, set joystick port), prefer peekaboo menu click over coordinate hacks — those are more robust to window size changes.

Worked example: start the emulator, then open the Log tab

# Snapshot and find Start button
SNAP=$(peekaboo see --app "DotNet 6502 Emulator" --json | jq -r '.snapshot_id')
START=$(peekaboo see --app "DotNet 6502 Emulator" --json \
        | jq -r '.. | objects | select(.identifier == "StartButton") | .id')

# 1. Click Start — emulator boots into C64 BASIC
peekaboo click --on "$START" --snapshot "$SNAP" \
               --app "DotNet 6502 Emulator" --window-index 0

# 2. Navigate to the Log tab via its named menu shortcut (order-independent)
peekaboo menu click --app "DotNet 6502 Emulator" --path "DotNet 6502 Emulator > View > Log"

# Verify
peekaboo see --app "DotNet 6502 Emulator" --annotate /tmp/after.png

The JSON path shapes above are illustrative — adjust with your peekaboo version's output schema. peekaboo list + peekaboo see --json are the two fundamental queries to script anything more involved.

See also