Skip to content

Commit a5aa4e7

Browse files
authored
Add UI accessibility properties, keyboard shortcuts, and macOS native menu; standardize emulator display name (#178)
1 parent 77176c3 commit a5aa4e7

34 files changed

Lines changed: 983 additions & 145 deletions

.github/workflows/release-desktop-apps.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ jobs:
109109
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
110110
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
111111
112-
- name: Download checksum files from release
112+
- name: Download checksum files and macOS zip from release
113113
env:
114114
GH_TOKEN: ${{ github.token }}
115115
run: |
@@ -118,6 +118,9 @@ jobs:
118118
--repo ${{ github.repository }} \
119119
--pattern "checksums-${runtime}.sha256"
120120
done
121+
gh release download "${{ github.event.release.tag_name }}" \
122+
--repo ${{ github.repository }} \
123+
--pattern "DotNet6502-Avalonia-osx-arm64.zip"
121124
122125
- name: Extract SHA256 hashes
123126
id: hashes
@@ -136,10 +139,17 @@ jobs:
136139
echo "headless_win_x64=$(extract_hash Headless win-x64)" >> "$GITHUB_OUTPUT"
137140
echo "headless_win_arm64=$(extract_hash Headless win-arm64)" >> "$GITHUB_OUTPUT"
138141
142+
- name: Extract macOS app bundle name from zip
143+
id: app_bundle
144+
run: |
145+
APP_NAME=$(zipinfo -1 DotNet6502-Avalonia-osx-arm64.zip | grep -E '^[^/]+\.app/$' | sed 's|/$||' | head -1)
146+
echo "app_name=$APP_NAME" >> "$GITHUB_OUTPUT"
147+
139148
- name: Update Homebrew tap
140149
env:
141150
GH_TOKEN: ${{ secrets.PACKAGE_MANAGER_TOKEN }}
142151
VERSION: ${{ steps.version.outputs.version }}
152+
APP_NAME: ${{ steps.app_bundle.outputs.app_name }}
143153
OSX_ARM64_HASH: ${{ steps.hashes.outputs.osx_arm64 }}
144154
LINUX_X64_HASH: ${{ steps.hashes.outputs.linux_x64 }}
145155
LINUX_ARM64_HASH: ${{ steps.hashes.outputs.linux_arm64 }}
@@ -153,6 +163,9 @@ jobs:
153163
# Update Cask (macOS desktop app)
154164
sed -i "s/version \".*\"/version \"${VERSION}\"/" Casks/dotnet-6502.rb
155165
sed -i "s/sha256 \".*\"/sha256 \"${OSX_ARM64_HASH}\"/" Casks/dotnet-6502.rb
166+
# Update .app bundle name (read from the actual release zip, not source)
167+
sed -i "s|app \".*\.app\"|app \"${APP_NAME}\"|" Casks/dotnet-6502.rb
168+
sed -i "s|binary \".*\.app/Contents/MacOS/|binary \"${APP_NAME}/Contents/MacOS/|" Casks/dotnet-6502.rb
156169
157170
# Update Formulas - use Python for multi-hash replacement
158171
python3 <<'PYEOF'

doc/APPS_AVALONIA_AUTOMATION.md

Lines changed: 275 additions & 0 deletions
Large diffs are not rendered by default.

doc/APPS_AVALONIA_TROUBLESHOOT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ The Avalonia desktop application supports optional console logging for debugging
107107

108108
On Windows, the application opens a **separate console window** for log output. This is because Windows GUI applications (WinExe) don't have a console attached by default.
109109

110-
- A new console window titled "DotNet6502 Emulator - Log Output" will appear
110+
- A new console window titled "DotNet 6502 Emulator - Log Output" will appear
111111
- Log messages are displayed in this dedicated window
112112
- The console window closes automatically when the application exits
113113

doc/INSTALL_DESKTOP_APPS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ dotnet-6502
4949

5050
On macOS, the app is also installed to `/Applications` and can be launched from Launchpad, Spotlight, or Finder like any other Mac app.
5151

52-
On Windows (Scoop), a Start Menu shortcut **DotNet6502 Emulator** is also created.
52+
On Windows (Scoop), a Start Menu shortcut **DotNet 6502 Emulator** is also created.
5353

5454
### Updating
5555

src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Browser/wwwroot/safari-notice.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
</style>
5151
</head>
5252
<body>
53-
<h1>DotNet-6502 Emulator</h1>
53+
<h1>DotNet 6502 Emulator</h1>
5454

5555
<div class="notice error">
5656
<h3>⚠️ Safari Desktop Compatibility Issue</h3>

src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/App.axaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
33
xmlns:local="using:Highbyte.DotNet6502.App.Avalonia.Core"
44
x:Class="Highbyte.DotNet6502.App.Avalonia.Core.App"
5+
Name="DotNet 6502 Emulator"
56
RequestedThemeVariant="Dark">
67
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
78

src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/App.axaml.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,16 @@ public override void Initialize()
164164

165165
AvaloniaXamlLoader.Load(this);
166166

167+
// Pre-register an empty NativeMenu on macOS so Avalonia's backend subscribes to
168+
// Items.CollectionChanged before any window is shown. ApplyMenuContributor then
169+
// mutates the items of this same object at runtime instead of replacing the menu,
170+
// which is required for the macOS menu bar and its keyboard shortcuts to work.
171+
if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(
172+
System.Runtime.InteropServices.OSPlatform.OSX))
173+
{
174+
NativeMenu.SetMenu(this, new NativeMenu());
175+
}
176+
167177
WriteBootstrapLog("App Initialize end");
168178
}
169179

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System.Collections.Generic;
2+
using Avalonia.Controls;
3+
using Avalonia.Input;
4+
5+
namespace Highbyte.DotNet6502.App.Avalonia.Core.SystemSetup;
6+
7+
/// <summary>
8+
/// Supplies native-menu items and key bindings for the currently selected emulator system.
9+
/// Implementations live on the system's menu ViewModel (e.g. C64MenuViewModel) so that the
10+
/// shortcuts dispatch directly to the ViewModel commands without code-behind glue.
11+
///
12+
/// Platform split:
13+
/// - macOS: GetNativeMenuItems() is installed as a NativeMenu on the Application.
14+
/// On macOS, NativeMenu items appear in the OS-level system menu bar (not
15+
/// inside the app window), which is the desired UX. The menu bar is also
16+
/// exposed via the macOS Accessibility API (AXMenuItem), making shortcuts
17+
/// self-describing and discoverable by AI agents at runtime.
18+
/// - Windows/Linux: NativeMenu would render as in-window chrome on these platforms, which
19+
/// is not desired. GetKeyBindings() is used instead — shortcuts are registered
20+
/// directly on the main Window and fire regardless of which child has focus,
21+
/// but they are invisible to accessibility tools and require prior knowledge.
22+
/// - WASM: Neither applies; this interface is a no-op in the browser target.
23+
/// </summary>
24+
public interface ISystemMenuContributor
25+
{
26+
string MenuLabel { get; }
27+
28+
IReadOnlyList<NativeMenuItemBase> GetNativeMenuItems();
29+
30+
IReadOnlyList<KeyBinding> GetKeyBindings();
31+
}

src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/ViewModels/C64MenuViewModel.cs

Lines changed: 224 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Reactive.Linq;
88
using System.Reflection;
99
using System.Threading.Tasks;
10+
using Avalonia.Controls;
1011
using Avalonia.Input;
1112
using Highbyte.DotNet6502.App.Avalonia.Core.SystemSetup;
1213
using Highbyte.DotNet6502.Impl.Avalonia.Commodore64.Input;
@@ -20,7 +21,7 @@
2021

2122
namespace Highbyte.DotNet6502.App.Avalonia.Core.ViewModels;
2223

23-
public class C64MenuViewModel : ViewModelBase
24+
public class C64MenuViewModel : ViewModelBase, ISystemMenuContributor
2425
{
2526
private readonly AvaloniaHostApp _avaloniaHostApp;
2627
private readonly ILoggerFactory _loggerFactory;
@@ -57,6 +58,14 @@ public class C64MenuViewModel : ViewModelBase
5758
public ReactiveCommand<byte[], Unit> LoadBasicFileCommand { get; }
5859
public ReactiveCommand<Unit, byte[]> SaveBasicFileCommand { get; }
5960
public ReactiveCommand<byte[], Unit> LoadBinaryFileCommand { get; }
61+
62+
// Section toggle / joystick commands used by both the UI click handlers and the menu/shortcut bridge.
63+
public ReactiveCommand<Unit, Unit> ToggleDiskSectionCommand { get; }
64+
public ReactiveCommand<Unit, Unit> ToggleLoadSaveSectionCommand { get; }
65+
public ReactiveCommand<Unit, Unit> ToggleConfigSectionCommand { get; }
66+
public ReactiveCommand<int, Unit> SetActiveJoystickCommand { get; }
67+
public ReactiveCommand<Unit, Unit> ToggleJoystickKeyboardCommand { get; }
68+
public ReactiveCommand<int, Unit> SetKeyboardJoystickCommand { get; }
6069
// --- End ReactiveUI Commands ---
6170

6271
public C64MenuViewModel(
@@ -133,6 +142,40 @@ public C64MenuViewModel(
133142
async (fileBuffer) => await LoadBinaryFile(fileBuffer),
134143
this.WhenAnyValue(x => x.IsFileOperationEnabled),
135144
RxSchedulers.MainThreadScheduler);
145+
146+
ToggleDiskSectionCommand = ReactiveCommandHelper.CreateSafeCommand(
147+
() => ToggleSection(C64MenuSection.Disk),
148+
null,
149+
RxSchedulers.MainThreadScheduler);
150+
151+
ToggleLoadSaveSectionCommand = ReactiveCommandHelper.CreateSafeCommand(
152+
() => ToggleSection(C64MenuSection.LoadSave),
153+
null,
154+
RxSchedulers.MainThreadScheduler);
155+
156+
ToggleConfigSectionCommand = ReactiveCommandHelper.CreateSafeCommand(
157+
() => ToggleSection(C64MenuSection.Config),
158+
null,
159+
RxSchedulers.MainThreadScheduler);
160+
161+
SetActiveJoystickCommand = ReactiveCommandHelper.CreateSafeCommand<int>(
162+
port => CurrentJoystick = port,
163+
null,
164+
RxSchedulers.MainThreadScheduler);
165+
166+
ToggleJoystickKeyboardCommand = ReactiveCommandHelper.CreateSafeCommand(
167+
() => JoystickKeyboardEnabled = !JoystickKeyboardEnabled,
168+
null,
169+
RxSchedulers.MainThreadScheduler);
170+
171+
SetKeyboardJoystickCommand = ReactiveCommandHelper.CreateSafeCommand<int>(
172+
port =>
173+
{
174+
if (IsKeyboardJoystickSelectionEnabled)
175+
KeyboardJoystick = port;
176+
},
177+
null,
178+
RxSchedulers.MainThreadScheduler);
136179
}
137180

138181
private EmulatorState EmulatorState => _avaloniaHostApp.EmulatorState;
@@ -332,6 +375,186 @@ public bool HasConfigValidationErrors
332375
}
333376
}
334377

378+
// Section expansion state — bound from XAML so both UI clicks and keyboard shortcuts
379+
// go through the same ViewModel state.
380+
private bool _isDiskSectionExpanded = true;
381+
public bool IsDiskSectionExpanded
382+
{
383+
get => _isDiskSectionExpanded;
384+
private set
385+
{
386+
if (_isDiskSectionExpanded == value)
387+
return;
388+
_isDiskSectionExpanded = value;
389+
this.RaisePropertyChanged(nameof(IsDiskSectionExpanded));
390+
this.RaisePropertyChanged(nameof(DiskSectionHeaderText));
391+
}
392+
}
393+
394+
private bool _isLoadSaveSectionExpanded = false;
395+
public bool IsLoadSaveSectionExpanded
396+
{
397+
get => _isLoadSaveSectionExpanded;
398+
private set
399+
{
400+
if (_isLoadSaveSectionExpanded == value)
401+
return;
402+
_isLoadSaveSectionExpanded = value;
403+
this.RaisePropertyChanged(nameof(IsLoadSaveSectionExpanded));
404+
this.RaisePropertyChanged(nameof(LoadSaveSectionHeaderText));
405+
}
406+
}
407+
408+
private bool _isConfigSectionExpanded = false;
409+
public bool IsConfigSectionExpanded
410+
{
411+
get => _isConfigSectionExpanded;
412+
private set
413+
{
414+
if (_isConfigSectionExpanded == value)
415+
return;
416+
_isConfigSectionExpanded = value;
417+
this.RaisePropertyChanged(nameof(IsConfigSectionExpanded));
418+
this.RaisePropertyChanged(nameof(ConfigSectionHeaderText));
419+
}
420+
}
421+
422+
public string DiskSectionHeaderText => (IsDiskSectionExpanded ? "▼ " : "▶ ") + "Disk Drive & .D64 images";
423+
public string LoadSaveSectionHeaderText => (IsLoadSaveSectionExpanded ? "▼ " : "▶ ") + "Load/Save";
424+
public string ConfigSectionHeaderText => (IsConfigSectionExpanded ? "▼ " : "▶ ") + "Configuration";
425+
426+
private enum C64MenuSection { Disk, LoadSave, Config }
427+
428+
private void ToggleSection(C64MenuSection section)
429+
{
430+
bool newState = section switch
431+
{
432+
C64MenuSection.Disk => !IsDiskSectionExpanded,
433+
C64MenuSection.LoadSave => !IsLoadSaveSectionExpanded,
434+
C64MenuSection.Config => !IsConfigSectionExpanded,
435+
_ => false,
436+
};
437+
438+
SetSectionExpanded(section, newState, collapseOthers: newState);
439+
}
440+
441+
private void SetSectionExpanded(C64MenuSection section, bool expanded, bool collapseOthers)
442+
{
443+
switch (section)
444+
{
445+
case C64MenuSection.Disk:
446+
IsDiskSectionExpanded = expanded;
447+
if (collapseOthers && expanded)
448+
{
449+
IsLoadSaveSectionExpanded = false;
450+
IsConfigSectionExpanded = false;
451+
}
452+
break;
453+
case C64MenuSection.LoadSave:
454+
IsLoadSaveSectionExpanded = expanded;
455+
if (collapseOthers && expanded)
456+
{
457+
IsDiskSectionExpanded = false;
458+
IsConfigSectionExpanded = false;
459+
}
460+
break;
461+
case C64MenuSection.Config:
462+
IsConfigSectionExpanded = expanded;
463+
if (collapseOthers && expanded)
464+
{
465+
IsDiskSectionExpanded = false;
466+
IsLoadSaveSectionExpanded = false;
467+
}
468+
break;
469+
}
470+
}
471+
472+
/// <summary>
473+
/// Called by the View when validation errors are present: collapse Disk/LoadSave and expand Config.
474+
/// </summary>
475+
public void ExpandConfigSectionOnValidationError()
476+
{
477+
IsDiskSectionExpanded = false;
478+
IsLoadSaveSectionExpanded = false;
479+
IsConfigSectionExpanded = true;
480+
}
481+
482+
// --- ISystemMenuContributor ---
483+
public string MenuLabel => "C64";
484+
485+
public IReadOnlyList<NativeMenuItemBase> GetNativeMenuItems()
486+
{
487+
// On macOS, NativeMenu items appear in the OS-level system menu bar (not the app window),
488+
// which is the desired UX. The menu bar is also exposed via the macOS Accessibility API,
489+
// making shortcuts self-describing and discoverable by AI agents at runtime.
490+
// Use Meta+Alt (⌘⌥) as the primary modifier so hints show as "⌘⌥L" etc.
491+
const KeyModifiers macBase = KeyModifiers.Meta | KeyModifiers.Alt;
492+
//const KeyModifiers macBase = KeyModifiers.Alt;
493+
const KeyModifiers macShift = KeyModifiers.Meta | KeyModifiers.Alt | KeyModifiers.Shift;
494+
495+
return new NativeMenuItemBase[]
496+
{
497+
BuildMenuItem("Toggle Disk Drive section", new KeyGesture(Key.D, macShift), ToggleDiskSectionCommand),
498+
BuildMenuItem("Toggle Load/Save section", new KeyGesture(Key.L, macBase), ToggleLoadSaveSectionCommand),
499+
BuildMenuItem("Toggle Configuration section", new KeyGesture(Key.C, macBase), ToggleConfigSectionCommand),
500+
new NativeMenuItemSeparator(),
501+
BuildMenuItem("Active joystick: Port 1", new KeyGesture(Key.D1, macBase), SetActiveJoystickCommand, 1),
502+
BuildMenuItem("Active joystick: Port 2", new KeyGesture(Key.D2, macBase), SetActiveJoystickCommand, 2),
503+
new NativeMenuItemSeparator(),
504+
BuildMenuItem("Toggle Joystick KB", new KeyGesture(Key.K, macBase), ToggleJoystickKeyboardCommand),
505+
BuildMenuItem("Keyboard joystick: Port 1", new KeyGesture(Key.D1, macShift), SetKeyboardJoystickCommand, 1),
506+
BuildMenuItem("Keyboard joystick: Port 2", new KeyGesture(Key.D2, macShift), SetKeyboardJoystickCommand, 2),
507+
};
508+
}
509+
510+
public IReadOnlyList<KeyBinding> GetKeyBindings()
511+
{
512+
// On Windows/Linux, NativeMenu would render as in-window chrome, which is not the desired
513+
// UX (on macOS it goes to the OS system menu bar, which is fine there). KeyBindings are
514+
// used instead: registered on the main Window, they fire regardless of which child has focus.
515+
// Ctrl+Alt combos are safe alongside the C64 emulator's own Ctrl+key color combinations
516+
// (those trigger on plain Ctrl, without Alt).
517+
const KeyModifiers nonMacBase = KeyModifiers.Control | KeyModifiers.Alt;
518+
const KeyModifiers nonMacShift = KeyModifiers.Control | KeyModifiers.Alt | KeyModifiers.Shift;
519+
520+
return new[]
521+
{
522+
BuildKeyBinding(new KeyGesture(Key.D, nonMacShift), ToggleDiskSectionCommand),
523+
BuildKeyBinding(new KeyGesture(Key.L, nonMacBase), ToggleLoadSaveSectionCommand),
524+
BuildKeyBinding(new KeyGesture(Key.C, nonMacBase), ToggleConfigSectionCommand),
525+
BuildKeyBinding(new KeyGesture(Key.D1, nonMacBase), SetActiveJoystickCommand, 1),
526+
BuildKeyBinding(new KeyGesture(Key.D2, nonMacBase), SetActiveJoystickCommand, 2),
527+
BuildKeyBinding(new KeyGesture(Key.K, nonMacBase), ToggleJoystickKeyboardCommand),
528+
BuildKeyBinding(new KeyGesture(Key.D1, nonMacShift), SetKeyboardJoystickCommand, 1),
529+
BuildKeyBinding(new KeyGesture(Key.D2, nonMacShift), SetKeyboardJoystickCommand, 2),
530+
};
531+
}
532+
533+
private static NativeMenuItem BuildMenuItem(string header, KeyGesture gesture, System.Windows.Input.ICommand command, object? parameter = null)
534+
{
535+
var item = new NativeMenuItem
536+
{
537+
Header = header,
538+
Gesture = gesture,
539+
Command = command,
540+
};
541+
if (parameter != null)
542+
item.CommandParameter = parameter;
543+
return item;
544+
}
545+
546+
private static KeyBinding BuildKeyBinding(KeyGesture gesture, System.Windows.Input.ICommand command, object? parameter = null)
547+
{
548+
var binding = new KeyBinding
549+
{
550+
Gesture = gesture,
551+
Command = command,
552+
};
553+
if (parameter != null)
554+
binding.CommandParameter = parameter;
555+
return binding;
556+
}
557+
335558
private void InitializeC64Data()
336559
{
337560
// Initialize joystick options

0 commit comments

Comments
 (0)