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>
224 lines
8.0 KiB
C#
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);
|
|
}
|
|
}
|