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 (AXidentifieron macOS,AutomationIdon Windows UIA).AutomationProperties.Name— a short human-readable label (e.g. "Start", "Scale"). Maps to AXtitleon macOS /Nameon 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¶
- Flat PascalCase ids matching existing
Name=style:StartButton,SystemSelectionComboBox,ScaleSlider,LogTab,OkButton. - Per-row dynamic ids for
ItemsControltemplates — bound to an identifying field. Pattern:Use<Button AutomationProperties.AutomationId="{Binding FileName, StringFormat='ScriptRow.Reload.{0}'}" AutomationProperties.Name="Reload script" Content="↻" />.as separator (e.g.ScriptRow.Reload.snake.lua,RomFileTextBox.Kernal). Escape leading{0}in aStringFormatwith{}to prevent the XAML parser from interpreting it as a markup extension: - Icon-only buttons (
↻,+,i, pencil, trash) need bothAutomationIdand a descriptiveName, because theContentis a glyph that doesn't describe the action. - Text-only buttons (
Content="Start") —Contentalready becomes the AX label, so onlyAutomationIdis strictly needed.Nameis added whenContentis bound or ambiguous. State-toggling buttons whoseContentis bound (e.g.DiskToggleButton, whose caption flips between "Insert .dsk image" and "Eject disk") should bindNameto that same property. A staticNameoverrides the caption in the accessibility tree, so agents and screen readers would no longer be able to tell which state the control is in. - Decorative elements (
TextBlocklabels,Border,Image, layoutGrid) do not get AutomationProperties unless an agent needs to read their text (e.g. status strings). - Container-level AutomationId on root
UserControl/Windowelements (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 buttonOptionsButton - Snapshot section (collapsible, common): header
SnapshotSectionHeader, contentSnapshotSectionContent; buttonsSaveSnapshotButton,LoadSnapshotButton, directory linkSnapshotFolderLink(visible only when the section is expanded — collapsed by default; toggle it with theEmulatormenu /⌘⌥⇧Sshortcut) - Status:
EmulatorStateText - Bottom tab control:
InformationTabControlwith tabsInformationTab,ConfigStatusTab,LogTab,ScriptsTab,GeneralInfoTab,DebugAndRemotingTab - Log tab:
ClearLogButton - Scripts tab:
ScriptsBannerRefreshButton, script directory linkScriptFolderLink,AddScriptButton,LoadExamplesButton,ScriptsRefreshButton; sort headersSortByFileNameButton,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'sDiskToggleButton),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 (Autoplus each explicit layout;Automeans 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 isKeyboardJoystickCheckBoxin 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:CheckBoxcontrols 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 frominspect-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, orSwedish;Automeans 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 useButtonFlashController),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) withEmulatorRenderSurfaceon the render ContentPresenterEmulatorPlaceholderView(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:
- General tab-navigation shortcuts — always active, independent of which emulator system is running.
- Common (non-system-specific) shortcuts — always active, for cross-system functions such as toggling the Snapshot sidebar section.
- 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:
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:
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)¶
- Individual
TabItemcontrols on macOS — verified withpeekaboo seeafter running the app. TheInformationTabControlsurfaces, but itsTabItemchildren (InformationTab,LogTab, etc.) do not appear as distinct clickable elements in the AX tree, despite having explicitAutomationProperties.AutomationId+Name. The AX tree on macOS reports roles limited tobutton,group,menu,other,slider— noAXTabGroup/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).
- Collapsed/conditional content only appears in the AX tree when its container is rendered. Examples:
MainViewcommonSnapshotSectionContent(SaveSnapshotButton,LoadSnapshotButton) — collapsed by default; expand via theEmulatormenu /⌘⌥⇧Sshortcut or by clickingSnapshotSectionHeader.C64MenuViewsection contents (DiskSectionContent,LoadSaveSectionContent,ConfigSectionContent) — only visible when the section header is expanded.- Some tools in
Debug & Remotingtab contents — hidden untilShowDebugToolsis enabled inEmulatorConfig. - 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.
-
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. -
Browser/WebAssembly target. Automation there goes through the DOM, not Avalonia's AX bridge. Accessibility attributes in
.axamlpropagate 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, andbrowser-orientation-toggle. Orientation feedback usesbrowser-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 usesaria-disabled="true"and disabled styling, while remaining focusable/tappable to explain manual device rotation. Hover, focus, and tap show the guidance inbrowser-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 exposesaria-expandedand 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/reopensbrowser-zoom-optionswithout 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-sizedbrowser-app-scrollpanel. 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:
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:
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 0when targeting this app. The Avalonia runtime creates a secondary hidden window, and without a window scope the focus step inclicktimes out withError: Timeout while waiting for condition. -
Don't use
--no-auto-focusfrom a terminal. The terminal emulator (e.g. Ghostty) reclaims focus between commands, so a--no-auto-focusclick lands on the terminal window at the same screen coordinates —clickstill 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 --annotateis 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 listshows the window Position). -
AXIdentifier-based clicking isn't directly supported. peekaboo's
--id/--onflags take theelem_NNtoken from aseesnapshot, not theAutomationProperties.AutomationIdstring. To select by AutomationId, parse the JSON frompeekaboo see --jsonand look foridentifier == "StartButton"to find the correspondingelem_NN, then pass that toclick --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 norelem_NNclicks work.peekaboo click --coordsalso fails because it still attempts AX focus resolution internally when--appis 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¶
- Avalonia Desktop app — general app overview
- Avalonia Accessibility docs
- NSAccessibility Programming Guide
- peekaboo — macOS UI automation CLI used for the verification in this doc