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>
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
using System.Runtime.Versioning;
|
||||
using RioJoy.Core.Editing;
|
||||
using RioJoy.Core.Hid;
|
||||
using RioJoy.Core.Mapping;
|
||||
using RioJoy.Core.Profiles;
|
||||
|
||||
namespace RioJoy.Tray.Editor;
|
||||
|
||||
/// <summary>
|
||||
/// Profile editor: shows the cockpit authoring sheet (its logical layout, from
|
||||
/// <see cref="SheetLayout"/>) as a scrollable, clickable grid and edits the
|
||||
/// selected address cell's label and action/modifiers/lamp. Writes back to the
|
||||
/// <see cref="RioProfile"/>'s <see cref="RioProfile.OverlayLabels"/> (keyed by the
|
||||
/// wallpaper region <c>b-XX</c>) and <see cref="RioProfile.Buttons"/> (the iRIO
|
||||
/// word); <c>Save</c> raises <see cref="Saved"/>.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ProfileEditorForm : Form
|
||||
{
|
||||
private readonly RioProfile _profile;
|
||||
private readonly IReadOnlyList<SheetCell> _cells;
|
||||
private readonly SheetCanvas _canvas = new();
|
||||
|
||||
private readonly Label _info = new() { AutoSize = true, Location = new Point(12, 12), MaximumSize = new Size(310, 0) };
|
||||
private readonly TextBox _labelBox = new() { Location = new Point(70, 44), Width = 230 };
|
||||
private readonly ComboBox _kindBox = new() { Location = new Point(70, 78), Width = 150, DropDownStyle = ComboBoxStyle.DropDownList };
|
||||
private readonly Label _valueLabel = new() { Text = "Key:", Location = new Point(12, 115), AutoSize = true };
|
||||
private readonly ComboBox _valueCombo = new() { Location = new Point(70, 112), Width = 230, DropDownStyle = ComboBoxStyle.DropDownList };
|
||||
private readonly CheckBox _shift = new() { Text = "Shift", Location = new Point(70, 144), AutoSize = true };
|
||||
private readonly CheckBox _ctrl = new() { Text = "Ctrl", Location = new Point(140, 144), AutoSize = true };
|
||||
private readonly CheckBox _alt = new() { Text = "Alt", Location = new Point(200, 144), AutoSize = true };
|
||||
private readonly CheckBox _ext = new() { Text = "Ext", Location = new Point(250, 144), AutoSize = true };
|
||||
private readonly CheckBox _lit = new() { Text = "Lit", Location = new Point(70, 172), AutoSize = true };
|
||||
private readonly Button _apply = new() { Text = "Apply to cell", Location = new Point(70, 206), Width = 110 };
|
||||
private readonly Button _save = new() { Text = "Save profile", Location = new Point(70, 240), Width = 110 };
|
||||
private readonly Button _close = new() { Text = "Close", Location = new Point(190, 240), Width = 80 };
|
||||
|
||||
private int? _address;
|
||||
private bool _loading;
|
||||
|
||||
/// <summary>A pick-list entry: what the user sees and the byte it maps to.</summary>
|
||||
private sealed record ValueItem(string Display, byte Value)
|
||||
{
|
||||
public override string ToString() => Display;
|
||||
}
|
||||
|
||||
/// <summary>Raised when the user saves; the argument is the edited profile.</summary>
|
||||
public event Action<RioProfile>? Saved;
|
||||
|
||||
public ProfileEditorForm(IReadOnlyList<SheetCell> cells, RioProfile profile)
|
||||
{
|
||||
_cells = cells ?? throw new ArgumentNullException(nameof(cells));
|
||||
_profile = profile ?? throw new ArgumentNullException(nameof(profile));
|
||||
|
||||
Text = $"RIOJoy — Edit profile: {profile.Name}";
|
||||
ClientSize = new Size(1500, 820);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
MinimumSize = new Size(900, 500);
|
||||
|
||||
_kindBox.Items.AddRange(Enum.GetNames<RioRouteKind>());
|
||||
_kindBox.SelectedIndexChanged += OnKindChanged;
|
||||
_canvas.SetCells(cells);
|
||||
_canvas.AddressSelected += OnAddressSelected;
|
||||
|
||||
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
|
||||
scroller.Controls.Add(_canvas);
|
||||
|
||||
Panel editPanel = BuildEditPanel();
|
||||
|
||||
Controls.Add(scroller);
|
||||
Controls.Add(editPanel);
|
||||
|
||||
_apply.Click += (_, _) => ApplyToCell();
|
||||
_save.Click += (_, _) => { ApplyToCell(); Saved?.Invoke(_profile); };
|
||||
_close.Click += (_, _) => Close();
|
||||
|
||||
SetEditingEnabled(false);
|
||||
_info.Text = "Click a coloured address cell to edit it.";
|
||||
}
|
||||
|
||||
/// <summary>The selected RIO address (for tests / offline rendering).</summary>
|
||||
public int? SelectedAddress => _address;
|
||||
|
||||
private Panel BuildEditPanel()
|
||||
{
|
||||
var panel = new Panel { Dock = DockStyle.Right, Width = 330, Padding = new Padding(8), BorderStyle = BorderStyle.FixedSingle };
|
||||
|
||||
panel.Controls.Add(_info);
|
||||
panel.Controls.Add(new Label { Text = "Label:", Location = new Point(12, 47), AutoSize = true });
|
||||
panel.Controls.Add(_labelBox);
|
||||
panel.Controls.Add(new Label { Text = "Action:", Location = new Point(12, 81), AutoSize = true });
|
||||
panel.Controls.Add(_kindBox);
|
||||
panel.Controls.Add(_valueLabel);
|
||||
panel.Controls.Add(_valueCombo);
|
||||
panel.Controls.AddRange(new Control[] { _shift, _ctrl, _alt, _ext, _lit, _apply, _save, _close });
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
private void OnAddressSelected(SheetCell cell)
|
||||
{
|
||||
if (cell.Address is not { } addr) { SetEditingEnabled(false); return; }
|
||||
|
||||
_address = addr;
|
||||
string region = RegionFor(addr);
|
||||
_info.Text = $"Cell {cell.Text} Address 0x{addr:X2} {cell.Section} (wallpaper {region})";
|
||||
|
||||
_labelBox.Text = _profile.OverlayLabels.TryGetValue(region, out string? label) ? label : string.Empty;
|
||||
|
||||
ButtonBinding b = ButtonBinding.FromWord(
|
||||
_profile.Buttons.TryGetValue(addr, out int w) ? (ushort)w : (ushort)0);
|
||||
|
||||
_loading = true;
|
||||
_kindBox.SelectedItem = b.Kind.ToString();
|
||||
PopulateValues(b.Kind);
|
||||
SelectValue(b.Value);
|
||||
_shift.Checked = b.Shift;
|
||||
_ctrl.Checked = b.Ctrl;
|
||||
_alt.Checked = b.Alt;
|
||||
_ext.Checked = b.Extended;
|
||||
_lit.Checked = b.IsLit;
|
||||
_loading = false;
|
||||
|
||||
SetEditingEnabled(true);
|
||||
}
|
||||
|
||||
private void OnKindChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_loading) return;
|
||||
PopulateValues(SelectedKind());
|
||||
if (_valueCombo.Items.Count > 0) _valueCombo.SelectedIndex = 0;
|
||||
UpdateModifierAvailability();
|
||||
}
|
||||
|
||||
// Fill the value picker with friendly choices for the chosen routing kind.
|
||||
private void PopulateValues(RioRouteKind kind)
|
||||
{
|
||||
_valueLabel.Text = kind switch
|
||||
{
|
||||
RioRouteKind.Keyboard => "Key:",
|
||||
RioRouteKind.Joystick => "Button:",
|
||||
RioRouteKind.Hat => "Direction:",
|
||||
RioRouteKind.Mouse => "Action:",
|
||||
RioRouteKind.RioCommand => "Command:",
|
||||
_ => "Value:",
|
||||
};
|
||||
|
||||
_valueCombo.BeginUpdate();
|
||||
_valueCombo.Items.Clear();
|
||||
|
||||
switch (kind)
|
||||
{
|
||||
case RioRouteKind.Keyboard:
|
||||
foreach (KeyName k in KeyCatalog.Entries)
|
||||
_valueCombo.Items.Add(new ValueItem(k.Name, k.Value));
|
||||
break;
|
||||
case RioRouteKind.Joystick:
|
||||
for (int btn = 1; btn <= RioHidReport.ButtonCount; btn++)
|
||||
_valueCombo.Items.Add(new ValueItem($"Button {btn}", (byte)btn));
|
||||
break;
|
||||
case RioRouteKind.Hat:
|
||||
foreach (RioHat h in new[] { RioHat.Up, RioHat.Right, RioHat.Down, RioHat.Left })
|
||||
_valueCombo.Items.Add(new ValueItem(h.ToString(), (byte)(int)h));
|
||||
break;
|
||||
case RioRouteKind.Mouse:
|
||||
foreach (MouseAction m in Enum.GetValues<MouseAction>())
|
||||
_valueCombo.Items.Add(new ValueItem(m.ToString(), (byte)m));
|
||||
break;
|
||||
case RioRouteKind.RioCommand:
|
||||
foreach (RioCommandCode c in Enum.GetValues<RioCommandCode>())
|
||||
_valueCombo.Items.Add(new ValueItem(c.ToString(), (byte)c));
|
||||
break;
|
||||
}
|
||||
|
||||
_valueCombo.EndUpdate();
|
||||
UpdateModifierAvailability();
|
||||
}
|
||||
|
||||
private void SelectValue(byte value)
|
||||
{
|
||||
for (int i = 0; i < _valueCombo.Items.Count; i++)
|
||||
{
|
||||
if (_valueCombo.Items[i] is ValueItem v && v.Value == value)
|
||||
{
|
||||
_valueCombo.SelectedIndex = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Not in the list (e.g. an uncatalogued VK): add a raw fallback entry.
|
||||
int idx = _valueCombo.Items.Add(new ValueItem($"0x{value:X2}", value));
|
||||
_valueCombo.SelectedIndex = idx;
|
||||
}
|
||||
|
||||
// Modifiers/extended only make sense for keyboard actions.
|
||||
private void UpdateModifierAvailability()
|
||||
{
|
||||
bool keyboard = SelectedKind() == RioRouteKind.Keyboard;
|
||||
foreach (Control c in new Control[] { _shift, _ctrl, _alt, _ext })
|
||||
c.Enabled = keyboard;
|
||||
}
|
||||
|
||||
private RioRouteKind SelectedKind() =>
|
||||
Enum.TryParse(_kindBox.SelectedItem?.ToString(), out RioRouteKind k) ? k : RioRouteKind.Keyboard;
|
||||
|
||||
private void ApplyToCell()
|
||||
{
|
||||
if (_address is not { } addr) return;
|
||||
|
||||
string region = RegionFor(addr);
|
||||
string label = _labelBox.Text.Trim();
|
||||
if (string.IsNullOrEmpty(label))
|
||||
_profile.OverlayLabels.Remove(region);
|
||||
else
|
||||
_profile.OverlayLabels[region] = label;
|
||||
|
||||
RioRouteKind kind = SelectedKind();
|
||||
bool keyboard = kind == RioRouteKind.Keyboard;
|
||||
var binding = new ButtonBinding
|
||||
{
|
||||
Kind = kind,
|
||||
Value = _valueCombo.SelectedItem is ValueItem vi ? vi.Value : (byte)0,
|
||||
Shift = keyboard && _shift.Checked,
|
||||
Ctrl = keyboard && _ctrl.Checked,
|
||||
Alt = keyboard && _alt.Checked,
|
||||
Extended = keyboard && _ext.Checked,
|
||||
IsLit = _lit.Checked,
|
||||
};
|
||||
|
||||
ushort word = binding.ToWord();
|
||||
if (word == 0)
|
||||
_profile.Buttons.Remove(addr);
|
||||
else
|
||||
_profile.Buttons[addr] = word;
|
||||
}
|
||||
|
||||
private void SetEditingEnabled(bool enabled)
|
||||
{
|
||||
foreach (Control c in new Control[] { _labelBox, _kindBox, _valueCombo, _shift, _ctrl, _alt, _ext, _lit, _apply })
|
||||
c.Enabled = enabled;
|
||||
if (enabled) UpdateModifierAvailability();
|
||||
}
|
||||
|
||||
private static string RegionFor(int address) => $"b-{address:X2}";
|
||||
|
||||
/// <summary>Select an address programmatically (for tests / offline preview).</summary>
|
||||
public void SelectAddress(int address)
|
||||
{
|
||||
SheetCell? cell = _cells.FirstOrDefault(c => c.Address == address);
|
||||
if (cell is not null)
|
||||
{
|
||||
_canvas.Select(address);
|
||||
OnAddressSelected(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Runtime.Versioning;
|
||||
using RioJoy.Core.Editing;
|
||||
|
||||
namespace RioJoy.Tray.Editor;
|
||||
|
||||
/// <summary>
|
||||
/// Scrollable cockpit-sheet grid: draws the cells (via <see cref="SheetView"/>) at a
|
||||
/// fixed cell size and raises <see cref="AddressSelected"/> when an address cell is
|
||||
/// clicked. Sized to the full grid so the host panel scrolls it.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal sealed class SheetCanvas : Control
|
||||
{
|
||||
private const int CellW = 62;
|
||||
private const int CellH = 22;
|
||||
|
||||
private IReadOnlyList<SheetCell> _cells = Array.Empty<SheetCell>();
|
||||
|
||||
public SheetCanvas()
|
||||
{
|
||||
DoubleBuffered = true;
|
||||
BackColor = Color.FromArgb(28, 28, 28);
|
||||
}
|
||||
|
||||
/// <summary>The RIO address currently highlighted, or null.</summary>
|
||||
public int? SelectedAddress { get; private set; }
|
||||
|
||||
public event Action<SheetCell>? AddressSelected;
|
||||
|
||||
public void SetCells(IReadOnlyList<SheetCell> cells)
|
||||
{
|
||||
_cells = cells;
|
||||
Size = SheetView.GridSize(cells, CellW, CellH);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void Select(int? address)
|
||||
{
|
||||
SelectedAddress = address;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e) =>
|
||||
SheetView.Paint(e.Graphics, _cells, CellW, CellH, SelectedAddress);
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseDown(e);
|
||||
SheetCell? hit = SheetView.HitTest(_cells, e.Location, CellW, CellH);
|
||||
if (hit?.Address is { } addr)
|
||||
{
|
||||
SelectedAddress = addr;
|
||||
AddressSelected?.Invoke(hit);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Runtime.Versioning;
|
||||
using RioJoy.Core.Editing;
|
||||
|
||||
namespace RioJoy.Tray.Editor;
|
||||
|
||||
/// <summary>
|
||||
/// Draws the cockpit authoring sheet as a grid of cells at their spreadsheet
|
||||
/// (row, col) positions — the cockpit's logical layout. Address cells are filled by
|
||||
/// section and clickable; scaffolding/label cells are drawn as context text. Shared
|
||||
/// by <see cref="SheetCanvas"/> and offline bitmap rendering.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public static class SheetView
|
||||
{
|
||||
public static Color SectionColor(SheetSection section) => section switch
|
||||
{
|
||||
SheetSection.Contacts or SheetSection.Letters => Color.FromArgb(232, 150, 150), // red
|
||||
SheetSection.Secondary or SheetSection.Screen => Color.FromArgb(245, 224, 150), // yellow
|
||||
SheetSection.Throttle or SheetSection.Joystick => Color.FromArgb(150, 182, 226), // blue
|
||||
SheetSection.Keypad or SheetSection.ExtKeypad => Color.FromArgb(165, 212, 165), // green
|
||||
_ => Color.Gainsboro,
|
||||
};
|
||||
|
||||
public static Size GridSize(IReadOnlyList<SheetCell> cells, int cellW, int cellH)
|
||||
{
|
||||
int maxCol = 0, maxRow = 0;
|
||||
foreach (SheetCell c in cells)
|
||||
{
|
||||
if (c.Col > maxCol) maxCol = c.Col;
|
||||
if (c.Row > maxRow) maxRow = c.Row;
|
||||
}
|
||||
return new Size((maxCol + 1) * cellW, (maxRow + 1) * cellH);
|
||||
}
|
||||
|
||||
public static void Paint(
|
||||
Graphics g,
|
||||
IReadOnlyList<SheetCell> cells,
|
||||
int cellW,
|
||||
int cellH,
|
||||
int? selectedAddress)
|
||||
{
|
||||
g.Clear(Color.FromArgb(28, 28, 28));
|
||||
|
||||
using var addrFont = new Font("Segoe UI", 7.5f, FontStyle.Bold);
|
||||
using var textFont = new Font("Segoe UI", 7f);
|
||||
using var border = new Pen(Color.FromArgb(70, 70, 70));
|
||||
|
||||
foreach (SheetCell cell in cells)
|
||||
{
|
||||
var r = new Rectangle(cell.Col * cellW, cell.Row * cellH, cellW, cellH);
|
||||
|
||||
if (cell.Address is { } addr)
|
||||
{
|
||||
using var fill = new SolidBrush(SectionColor(cell.Section));
|
||||
g.FillRectangle(fill, r);
|
||||
g.DrawRectangle(border, r);
|
||||
TextRenderer.DrawText(g, cell.Text, addrFont, r, Color.Black,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPrefix);
|
||||
|
||||
if (addr == selectedAddress)
|
||||
{
|
||||
using var hi = new Pen(Color.Yellow, 2f);
|
||||
g.DrawRectangle(hi, r.X + 1, r.Y + 1, r.Width - 2, r.Height - 2);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Scaffolding / KEY / LABEL / section headers — context only.
|
||||
TextRenderer.DrawText(g, cell.Text, textFont, r, Color.Silver,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
|
||||
TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The address cell at a pixel point, or null.</summary>
|
||||
public static SheetCell? HitTest(IReadOnlyList<SheetCell> cells, Point p, int cellW, int cellH)
|
||||
{
|
||||
int col = p.X / cellW;
|
||||
int row = p.Y / cellH;
|
||||
return cells.FirstOrDefault(c => c.Row == row && c.Col == col && c.Address is not null);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@ using RioJoy.Core;
|
||||
using RioJoy.Core.Calibration;
|
||||
using RioJoy.Core.Mapping;
|
||||
using RioJoy.Core.Output;
|
||||
using RioJoy.Core.Overlay;
|
||||
using RioJoy.Core.Profiles;
|
||||
using RioJoy.Core.Serial;
|
||||
using RioJoy.Overlay;
|
||||
|
||||
namespace RioJoy.Tray;
|
||||
|
||||
@@ -141,7 +143,48 @@ public sealed class RioCoordinator : IDisposable
|
||||
{
|
||||
Teardown();
|
||||
SetStatus($"Error opening {port}: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyWallpaper(profile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate the profile's cockpit wallpaper from the configured overlay template
|
||||
/// and apply it. Best-effort and opt-in (only when <see cref="AppConfig.OverlayTemplatePath"/>
|
||||
/// is set), so a missing template or render failure never breaks activation.
|
||||
/// </summary>
|
||||
private void ApplyWallpaper(RioProfile profile)
|
||||
{
|
||||
string? templatePath = _config().OverlayTemplatePath;
|
||||
if (string.IsNullOrWhiteSpace(templatePath) || !File.Exists(templatePath))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
OverlayTemplate template = OverlayTemplateStore.Load(templatePath);
|
||||
string templateDir = Path.GetDirectoryName(Path.GetFullPath(templatePath)) ?? ".";
|
||||
|
||||
string outDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"RIOJoy", "wallpapers");
|
||||
string outPath = Path.Combine(outDir, SafeFileName(profile.Name) + ".png");
|
||||
|
||||
new ProfileWallpaperGenerator().Generate(template, templateDir, profile.OverlayLabels, outPath);
|
||||
profile.WallpaperPath = outPath;
|
||||
WallpaperApplier.Apply(outPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus($"{Status} [wallpaper: {ex.Message}]");
|
||||
}
|
||||
}
|
||||
|
||||
private static string SafeFileName(string name)
|
||||
{
|
||||
foreach (char c in Path.GetInvalidFileNameChars())
|
||||
name = name.Replace(c, '_');
|
||||
return name;
|
||||
}
|
||||
|
||||
private void GoDormant(string status)
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RioJoy.Core\RioJoy.Core.csproj" />
|
||||
<ProjectReference Include="..\RioJoy.Overlay\RioJoy.Overlay.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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;
|
||||
@@ -67,6 +69,8 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
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);
|
||||
@@ -129,6 +133,48 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace RioJoy.Tray;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the Windows desktop wallpaper to a generated cockpit overlay
|
||||
/// (Phase 7 — the modern replacement for manually setting the GIMP-exported
|
||||
/// background). Wraps <c>SystemParametersInfo(SPI_SETDESKWALLPAPER)</c>.
|
||||
///
|
||||
/// <para>Windows requires a <strong>BMP</strong> file path for reliable results on
|
||||
/// all versions; PNGs are accepted on modern Windows but not guaranteed, so callers
|
||||
/// should hand this a path the overlay renderer wrote. Applying a wallpaper changes
|
||||
/// a user-visible system setting, so it is only invoked on an explicit profile
|
||||
/// activation, never speculatively.</para>
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public static class WallpaperApplier
|
||||
{
|
||||
private const int SPI_SETDESKWALLPAPER = 0x0014;
|
||||
private const int SPIF_UPDATEINIFILE = 0x01; // persist across logon
|
||||
private const int SPIF_SENDCHANGE = 0x02; // broadcast WM_SETTINGCHANGE
|
||||
|
||||
/// <summary>
|
||||
/// Apply <paramref name="imagePath"/> as the desktop wallpaper. Returns true on
|
||||
/// success. Throws if the file does not exist.
|
||||
/// </summary>
|
||||
public static bool Apply(string imagePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
|
||||
string full = Path.GetFullPath(imagePath);
|
||||
if (!File.Exists(full))
|
||||
throw new FileNotFoundException("Wallpaper image not found.", full);
|
||||
|
||||
return SystemParametersInfo(
|
||||
SPI_SETDESKWALLPAPER, 0, full, SPIF_UPDATEINIFILE | SPIF_SENDCHANGE);
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SystemParametersInfo(int uAction, int uParam, string lpvParam, int fuWinIni);
|
||||
}
|
||||
Reference in New Issue
Block a user