Files
riojoy/src/RioJoy.Tray/TrayApplicationContext.cs
T
CydandClaude Opus 4.8 8d2d0b71aa Phase 7: cockpit overlay generator, region extraction, and profile editor
Overlay generator (pure, tested) — RioJoy.Core/Overlay:
- FontFitter ports the legacy calc-fontsize auto-fit search (validated against a
  brute-force oracle); OverlayLayoutEngine ports create-data-layer's fit + h/v
  justification and adds per-region 90 deg CCW rotation; OverlayTemplate/Region +
  OverlayTemplateStore hold cell geometry/colour/rotation (regions.json).
- GoobieDataImporter parses the legacy .data label sheet into label rows.
- src/RioJoy.Overlay: SkiaSharp rasterizer (SkiaTextMeasurer shared with the engine
  so measured layout == drawn output; SkiaOverlayRenderer -> PNG;
  ProfileWallpaperGenerator). Verified end-to-end on the real cockpit art
  (regions.json + riojoy.png + TEST.data) by OverlayRenderIntegrationTests.
- RioJoy.Tray/WallpaperApplier applies via SystemParametersInfo; RioCoordinator
  generates+applies on profile activation (opt-in via AppConfig.OverlayTemplatePath).

Region geometry — tools/XcfRegionExtract parses riojoy.xcf (a GIMP-format binary
reader, no GIMP needed) into the 119-cell regions.json: per-layer offsets/size/
font/colour, with 90 deg CCW rotation on a-00 + b-10..b-1F. Base image riojoy.png.
NOTE: the wallpaper positions are a VGA chroma-split display artifact (6 displays),
not the cockpit's logical layout.

Mapping editor (first cut) — RioJoy.Tray/Editor/ProfileEditorForm renders the
config sheet's logical grid (SheetLayout parses the sheet CSV export). The iRIO
word is edited via ButtonBinding <-> RioMapEntry.Create (no hex bit-twiddling), with
a context-sensitive picker: keyboard keys by name (KeyCatalog VK names), joystick
Button N, hat direction, mouse/RIO-command enum names; modifiers only for keyboard.
Opened from the tray ("Edit profile..."). The sheet-grid layout is a starting point
that needs rework from a better-formatted source.

~220 xUnit tests green. Docs (PLAN.md, README) updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:13:48 -05:00

224 lines
8.0 KiB
C#

using System.Runtime.Versioning;
using RioJoy.Core.Editing;
using RioJoy.Core.Mapping;
using RioJoy.Core.Profiles;
using RioJoy.Tray.Editor;
using RioJoy.Tray.Os;
namespace RioJoy.Tray;
/// <summary>
/// Owns the tray icon, menu, and the RIOJoy runtime. The menu mirrors the legacy
/// console menu (axis resets, version/status, diagnostic toggles, quit) and adds
/// profile selection (auto vs. manual) and a "start with Windows" toggle. The
/// auto-switch watcher is polled on a UI timer so menu/status updates stay on the
/// UI thread.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class TrayApplicationContext : ApplicationContext
{
private static readonly string ConfigPath =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "RIOJoy", "config.json");
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1);
private readonly AppConfig _config;
private readonly RioCoordinator _coordinator;
private readonly AutoSwitchWatcher _watcher;
private readonly System.Windows.Forms.Timer _pollTimer;
private readonly NotifyIcon _trayIcon;
private readonly ToolStripMenuItem _statusItem;
public TrayApplicationContext()
{
_config = ConfigStore.Load(ConfigPath);
_coordinator = new RioCoordinator(() => _config);
_coordinator.StatusChanged += _ => RefreshOnUiThread();
_watcher = new AutoSwitchWatcher(new ForegroundProcessProvider(), () => _config);
_watcher.DecisionChanged += d => _coordinator.ApplyDecision(d);
_statusItem = new ToolStripMenuItem("Status: starting…") { Enabled = false };
_trayIcon = new NotifyIcon
{
Icon = SystemIcons.Application,
Text = "RIOJoy",
Visible = true,
ContextMenuStrip = BuildMenu(),
};
// Poll the foreground app on the UI thread.
_pollTimer = new System.Windows.Forms.Timer { Interval = (int)PollInterval.TotalMilliseconds };
_pollTimer.Tick += (_, _) => _watcher.Poll();
_pollTimer.Start();
_watcher.Poll();
}
private ContextMenuStrip BuildMenu()
{
var menu = new ContextMenuStrip();
menu.Items.Add(_statusItem);
menu.Items.Add(new ToolStripSeparator());
// Profile selection: Auto + each profile + manual dormant.
var profileMenu = new ToolStripMenuItem("Profile");
profileMenu.DropDownOpening += (_, _) => RebuildProfileMenu(profileMenu);
RebuildProfileMenu(profileMenu);
menu.Items.Add(profileMenu);
menu.Items.Add("Edit profile…", null, (_, _) => OpenEditor());
// RIO commands mirroring the legacy console menu.
var commands = new ToolStripMenuItem("RIO commands");
AddCommand(commands, "Reset all analog axes", RioCommandCode.ResetAllAxes);
AddCommand(commands, "Reset throttle", RioCommandCode.ResetThrottle);
AddCommand(commands, "Reset left pedal", RioCommandCode.ResetLeftPedal);
AddCommand(commands, "Reset right pedal", RioCommandCode.ResetRightPedal);
AddCommand(commands, "Reset vertical joystick", RioCommandCode.ResetVerticalJoystick);
AddCommand(commands, "Reset horizontal joystick", RioCommandCode.ResetHorizontalJoystick);
commands.DropDownItems.Add(new ToolStripSeparator());
AddCommand(commands, "Reset digital (general)", RioCommandCode.GeneralReset);
AddCommand(commands, "Request version && status", RioCommandCode.RequestVersionAndCheck);
AddCommand(commands, "Toggle raw-axes readout", RioCommandCode.ToggleRawAxesReadout);
AddCommand(commands, "Toggle poll-rate readout", RioCommandCode.TogglePollRateReadout);
menu.Items.Add(commands);
menu.Items.Add(new ToolStripSeparator());
var autoStart = new ToolStripMenuItem("Start with Windows")
{
CheckOnClick = true,
Checked = AutoStartManager.IsEnabled(),
};
autoStart.Click += (_, _) => AutoStartManager.SetEnabled(autoStart.Checked);
menu.Items.Add(autoStart);
menu.Items.Add("Exit", null, (_, _) => Quit());
return menu;
}
private void RebuildProfileMenu(ToolStripMenuItem profileMenu)
{
profileMenu.DropDownItems.Clear();
var auto = new ToolStripMenuItem("Auto (foreground app)") { Checked = _coordinator.IsAuto };
auto.Click += (_, _) => _coordinator.SetAuto();
profileMenu.DropDownItems.Add(auto);
profileMenu.DropDownItems.Add(new ToolStripSeparator());
foreach (RioProfile profile in _config.Profiles)
{
RioProfile captured = profile;
var item = new ToolStripMenuItem(profile.Name)
{
Checked = !_coordinator.IsAuto && _coordinator.ActiveProfileName == profile.Name,
};
item.Click += (_, _) => _coordinator.SetManualProfile(captured);
profileMenu.DropDownItems.Add(item);
}
if (_config.Profiles.Count == 0)
profileMenu.DropDownItems.Add(new ToolStripMenuItem("(no profiles configured)") { Enabled = false });
profileMenu.DropDownItems.Add(new ToolStripSeparator());
var dormant = new ToolStripMenuItem("Dormant (release port)")
{
Checked = !_coordinator.IsAuto && _coordinator.ActiveProfileName is null,
};
dormant.Click += (_, _) => _coordinator.SetManualDormant();
profileMenu.DropDownItems.Add(dormant);
}
private void OpenEditor()
{
// The cockpit sheet (config-sheet.csv) sits beside the overlay template.
string? templatePath = _config.OverlayTemplatePath;
string? sheetPath = string.IsNullOrWhiteSpace(templatePath)
? null
: Path.Combine(Path.GetDirectoryName(Path.GetFullPath(templatePath)) ?? ".", "config-sheet.csv");
if (sheetPath is null || !File.Exists(sheetPath))
{
MessageBox.Show(
"Set 'OverlayTemplatePath' in the config and place 'config-sheet.csv' beside it to use the editor.",
"RIOJoy", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
IReadOnlyList<SheetCell> cells;
try
{
cells = SheetLayout.Load(sheetPath);
}
catch (Exception ex)
{
MessageBox.Show($"Could not load the cockpit sheet:\n{ex.Message}", "RIOJoy",
MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
RioProfile profile = _config.Profiles.FirstOrDefault() ?? AddBlankProfile();
var editor = new ProfileEditorForm(cells, profile);
editor.Saved += _ => ConfigStore.Save(_config, ConfigPath);
editor.Show();
}
private RioProfile AddBlankProfile()
{
var profile = new RioProfile { Name = "New profile" };
_config.Profiles.Add(profile);
return profile;
}
private void AddCommand(ToolStripMenuItem parent, string text, RioCommandCode code)
{
var item = new ToolStripMenuItem(text);
item.Click += (_, _) => _coordinator.Runtime?.Trigger(code);
parent.DropDownItems.Add(item);
}
private void RefreshOnUiThread()
{
if (_trayIcon.ContextMenuStrip?.InvokeRequired == true)
{
_trayIcon.ContextMenuStrip.BeginInvoke(RefreshStatus);
return;
}
RefreshStatus();
}
private void RefreshStatus()
{
_statusItem.Text = $"Status: {_coordinator.Status}";
_trayIcon.Text = $"RIOJoy — {_coordinator.Status}".Length <= 63
? $"RIOJoy — {_coordinator.Status}"
: "RIOJoy";
}
private void Quit()
{
_pollTimer.Stop();
_coordinator.Dispose();
_trayIcon.Visible = false;
ExitThread();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_pollTimer.Dispose();
_coordinator.Dispose();
_trayIcon.Dispose();
}
base.Dispose(disposing);
}
}