Phase 7: live encoder gauges in the profile editor
RioRuntime raises AxesUpdated with the calibrated AxisOutputs after every analog reply (independent of the joystick sink, so the gauges work while editor input routing is suppressed). The editor strip now renders the values live: Z and the L/R pedals (Rx/Ry) fill bottom-up, Rz deflects from a center tick, and the X/Y box tracks the stick as a dot over a crosshair. Fraction/deflection math is pure in Core.Editing.AxisGauges (clamped, unit-tested); the canvas repaints only the strip and only on change. Verified by offline form screenshots against injected values. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+7
-2
@@ -280,8 +280,13 @@ Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline
|
|||||||
`ButtonBinding` ↔ `RioMapEntry.Create` — no hex. The value field is a
|
`ButtonBinding` ↔ `RioMapEntry.Create` — no hex. The value field is a
|
||||||
context-sensitive picker (keyboard key by name via `KeyCatalog`, joystick Button N,
|
context-sensitive picker (keyboard key by name via `KeyCatalog`, joystick Button N,
|
||||||
hat direction, mouse/RIO-command enum); modifiers enable only for keyboard.
|
hat direction, mouse/RIO-command enum); modifiers enable only for keyboard.
|
||||||
Opened from the tray ("Edit profile…"); Save persists the profile.
|
Opened from the tray ("Edit profile…"); Save persists the profile. The encoder
|
||||||
⏳ Still to refine: live encoder gauges, showing each button's assigned key as a
|
gauges are **live**: `RioRuntime.AxesUpdated` streams the calibrated axis values
|
||||||
|
(post invert/deadzone/mix — exactly what the virtual joystick receives) into the
|
||||||
|
strip at the analog poll rate — Z and the L/R pedals fill bottom-up, Rz deflects
|
||||||
|
from its center tick, and the X/Y box tracks the stick as a dot (pure fraction
|
||||||
|
math in `RioJoy.Core.Editing.AxisGauges`, unit-tested).
|
||||||
|
⏳ Still to refine: showing each button's assigned key as a
|
||||||
caption, grouping a button's two bank addresses, and clone-from-existing.
|
caption, grouping a button's two bank addresses, and clone-from-existing.
|
||||||
- The unified profile JSON supersedes both `RIO.ini` and the Google Sheet, with
|
- The unified profile JSON supersedes both `RIO.ini` and the Google Sheet, with
|
||||||
importers for each.
|
importers for each.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using RioJoy.Core.Calibration;
|
||||||
|
|
||||||
|
namespace RioJoy.Core.Editing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure math for the editor's live encoder gauges: converts a calibrated axis
|
||||||
|
/// value (<c>0..32766</c>, center 16383) into a normalized fill fraction or a
|
||||||
|
/// signed center deflection, clamped so out-of-range values can't overdraw a
|
||||||
|
/// gauge. UI-free so the conversions are unit-tested; the panel renderer only
|
||||||
|
/// scales these to pixels.
|
||||||
|
/// </summary>
|
||||||
|
public static class AxisGauges
|
||||||
|
{
|
||||||
|
/// <summary>Fill fraction <c>0..1</c> of an axis value (0 = empty, Max = full).</summary>
|
||||||
|
public static double Fraction(int value) =>
|
||||||
|
Math.Min(1.0, Math.Max(0.0, value / (double)AxisOutputs.Max));
|
||||||
|
|
||||||
|
/// <summary>Signed deflection <c>-1..+1</c> from the axis center (0 = centered).</summary>
|
||||||
|
public static double Deflection(int value) =>
|
||||||
|
Math.Min(1.0, Math.Max(-1.0, (value - AxisOutputs.Center) / (double)AxisOutputs.Center));
|
||||||
|
}
|
||||||
@@ -56,6 +56,13 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<int, bool>? ButtonActivity;
|
public event Action<int, bool>? ButtonActivity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised with the six calibrated axis values after every analog reply —
|
||||||
|
/// independent of the joystick sink, so the editor can show live gauges even
|
||||||
|
/// while input routing is suppressed.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<AxisOutputs>? AxesUpdated;
|
||||||
|
|
||||||
/// <summary>Raised with the firmware version when a <see cref="RioCommand.VersionReply"/> arrives.</summary>
|
/// <summary>Raised with the firmware version when a <see cref="RioCommand.VersionReply"/> arrives.</summary>
|
||||||
public event Action<VersionInfo>? VersionReceived;
|
public event Action<VersionInfo>? VersionReceived;
|
||||||
|
|
||||||
@@ -129,6 +136,8 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
|
|||||||
{
|
{
|
||||||
// Output is best-effort: a faulty joystick sink must not kill the link.
|
// Output is best-effort: a faulty joystick sink must not kill the link.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AxesUpdated?.Invoke(axes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPacket(RioPacket packet)
|
private void OnPacket(RioPacket packet)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using RioJoy.Core.Calibration;
|
||||||
using RioJoy.Core.Editing;
|
using RioJoy.Core.Editing;
|
||||||
|
|
||||||
namespace RioJoy.Tray.Editor;
|
namespace RioJoy.Tray.Editor;
|
||||||
@@ -31,6 +32,7 @@ internal sealed class PanelCanvas : Control
|
|||||||
public Func<JoyStickSetting, bool>? CalibrationProvider { get; set; }
|
public Func<JoyStickSetting, bool>? CalibrationProvider { get; set; }
|
||||||
|
|
||||||
private readonly HashSet<int> _livePressed = new();
|
private readonly HashSet<int> _livePressed = new();
|
||||||
|
private AxisOutputs? _axes;
|
||||||
|
|
||||||
public int? SelectedAddress { get; private set; }
|
public int? SelectedAddress { get; private set; }
|
||||||
|
|
||||||
@@ -43,6 +45,20 @@ internal sealed class PanelCanvas : Control
|
|||||||
Invalidate();
|
Invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Show the latest calibrated axis values on the encoder-gauge strip. Arrives at
|
||||||
|
/// the analog poll rate (~18 Hz), so only the strip is repainted, and only when a
|
||||||
|
/// value actually changed.
|
||||||
|
/// </summary>
|
||||||
|
public void SetAxes(AxisOutputs axes)
|
||||||
|
{
|
||||||
|
if (_axes is { } p && p.X == axes.X && p.Y == axes.Y && p.Z == axes.Z &&
|
||||||
|
p.Rx == axes.Rx && p.Ry == axes.Ry && p.Rz == axes.Rz)
|
||||||
|
return;
|
||||||
|
_axes = axes;
|
||||||
|
Invalidate(new Rectangle(0, 0, Width, PanelView.TopStrip));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Raised when a <c>[JoyStick]</c> toggle cell is clicked.</summary>
|
/// <summary>Raised when a <c>[JoyStick]</c> toggle cell is clicked.</summary>
|
||||||
public event Action<JoyStickSetting>? CalibrationToggled;
|
public event Action<JoyStickSetting>? CalibrationToggled;
|
||||||
|
|
||||||
@@ -66,6 +82,7 @@ internal sealed class PanelCanvas : Control
|
|||||||
a => FunctionProvider?.Invoke(a),
|
a => FunctionProvider?.Invoke(a),
|
||||||
a => LitProvider?.Invoke(a) ?? false,
|
a => LitProvider?.Invoke(a) ?? false,
|
||||||
_livePressed.Contains,
|
_livePressed.Contains,
|
||||||
|
_axes,
|
||||||
SelectedAddress);
|
SelectedAddress);
|
||||||
PanelView.PaintCalibration(e.Graphics, s => CalibrationProvider?.Invoke(s) ?? false);
|
PanelView.PaintCalibration(e.Graphics, s => CalibrationProvider?.Invoke(s) ?? false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using RioJoy.Core.Calibration;
|
||||||
using RioJoy.Core.Editing;
|
using RioJoy.Core.Editing;
|
||||||
|
|
||||||
namespace RioJoy.Tray.Editor;
|
namespace RioJoy.Tray.Editor;
|
||||||
@@ -74,6 +75,7 @@ public static class PanelView
|
|||||||
Func<int, string?> function,
|
Func<int, string?> function,
|
||||||
Func<int, bool> lit,
|
Func<int, bool> lit,
|
||||||
Func<int, bool> live,
|
Func<int, bool> live,
|
||||||
|
AxisOutputs? axes,
|
||||||
int? selected)
|
int? selected)
|
||||||
{
|
{
|
||||||
g.Clear(Color.FromArgb(28, 28, 28));
|
g.Clear(Color.FromArgb(28, 28, 28));
|
||||||
@@ -82,7 +84,7 @@ public static class PanelView
|
|||||||
using var subFont = new Font("Segoe UI", 6.75f);
|
using var subFont = new Font("Segoe UI", 6.75f);
|
||||||
using var groupPen = new Pen(Color.FromArgb(80, 80, 80));
|
using var groupPen = new Pen(Color.FromArgb(80, 80, 80));
|
||||||
|
|
||||||
DrawEncoderStrip(g, titleFont);
|
DrawEncoderStrip(g, titleFont, axes);
|
||||||
|
|
||||||
// Group frames + titles.
|
// Group frames + titles.
|
||||||
foreach (PanelGroup grp in groups)
|
foreach (PanelGroup grp in groups)
|
||||||
@@ -150,10 +152,11 @@ public static class PanelView
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void DrawEncoderStrip(Graphics g, Font font)
|
private static void DrawEncoderStrip(Graphics g, Font font, AxisOutputs? axes)
|
||||||
{
|
{
|
||||||
using var pen = new Pen(Color.FromArgb(120, 160, 120));
|
using var pen = new Pen(Color.FromArgb(120, 160, 120));
|
||||||
using var gauge = new SolidBrush(Color.FromArgb(20, 40, 20));
|
using var gauge = new SolidBrush(Color.FromArgb(20, 40, 20));
|
||||||
|
using var fill = new SolidBrush(Color.FromArgb(52, 128, 70));
|
||||||
|
|
||||||
// Centered over the Upper Middle MFD block and arranged to match the physical
|
// Centered over the Upper Middle MFD block and arranged to match the physical
|
||||||
// cockpit: Z on the left and the X/Y stick graph on the right (both full
|
// cockpit: Z on the left and the X/Y stick graph on the right (both full
|
||||||
@@ -164,21 +167,61 @@ public static class PanelView
|
|||||||
const int bar = 30; // stroke thickness, matching Z's width / Rz bar
|
const int bar = 30; // stroke thickness, matching Z's width / Rz bar
|
||||||
int baseX = 6 * CellW - 100; // cluster is 200 wide; 6*CellW centers the MFD
|
int baseX = 6 * CellW - 100; // cluster is 200 wide; 6*CellW centers the MFD
|
||||||
int uX = baseX + 34, uW = 82;
|
int uX = baseX + 34, uW = 82;
|
||||||
|
var zBox = new Rectangle(baseX, top, bar, fullH);
|
||||||
|
var lBox = new Rectangle(uX, top, bar, fullH); // left arm of the U
|
||||||
|
var rBox = new Rectangle(uX + uW - bar, top, bar, fullH); // right arm of the U
|
||||||
|
var rzBox = new Rectangle(uX, top + fullH, uW, bar); // bottom bar, below L & R (no overlap)
|
||||||
|
var xyBox = new Rectangle(baseX + 120, top, 80, fullH);
|
||||||
(string Label, Rectangle Box)[] boxes =
|
(string Label, Rectangle Box)[] boxes =
|
||||||
{
|
{
|
||||||
("Z", new Rectangle(baseX, top, bar, fullH)),
|
("Z", zBox), ("L", lBox), ("R", rBox), ("Rz", rzBox), ("X / Y", xyBox),
|
||||||
("L", new Rectangle(uX, top, bar, fullH)), // left arm of the U
|
|
||||||
("R", new Rectangle(uX + uW - bar, top, bar, fullH)), // right arm of the U
|
|
||||||
("Rz", new Rectangle(uX, top + fullH, uW, bar)), // bottom bar, below L & R (no overlap)
|
|
||||||
("X / Y", new Rectangle(baseX + 120, top, 80, fullH)),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
foreach (var (_, box) in boxes)
|
||||||
|
g.FillRectangle(gauge, box);
|
||||||
|
|
||||||
|
// Live values from the RIO's analog stream (null when the link is down).
|
||||||
|
// The gauges show the calibrated outputs — post invert/deadzone/mix — i.e.
|
||||||
|
// exactly what the virtual joystick receives.
|
||||||
|
if (axes is { } a)
|
||||||
|
{
|
||||||
|
// Z (throttle) and the L/R pedals (Rx/Ry) fill bottom-up.
|
||||||
|
foreach (var (box, value) in new[] { (zBox, a.Z), (lBox, a.Rx), (rBox, a.Ry) })
|
||||||
|
{
|
||||||
|
int h = (int)Math.Round(box.Height * AxisGauges.Fraction(value));
|
||||||
|
if (h > 0)
|
||||||
|
g.FillRectangle(fill, box.X, box.Bottom - h, box.Width, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rz (mixed rudder) deflects left/right from the bar's center.
|
||||||
|
double d = AxisGauges.Deflection(a.Rz);
|
||||||
|
int cx = rzBox.X + rzBox.Width / 2;
|
||||||
|
int w = (int)Math.Round(rzBox.Width / 2.0 * Math.Abs(d));
|
||||||
|
if (w > 0)
|
||||||
|
g.FillRectangle(fill, d < 0 ? cx - w : cx, rzBox.Y, w, rzBox.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference marks: Rz center tick + X/Y crosshair (drawn even when idle).
|
||||||
|
g.DrawLine(pen, rzBox.X + rzBox.Width / 2, rzBox.Y, rzBox.X + rzBox.Width / 2, rzBox.Bottom);
|
||||||
|
g.DrawLine(pen, xyBox.X, xyBox.Y + xyBox.Height / 2, xyBox.Right, xyBox.Y + xyBox.Height / 2);
|
||||||
|
g.DrawLine(pen, xyBox.X + xyBox.Width / 2, xyBox.Y, xyBox.X + xyBox.Width / 2, xyBox.Bottom);
|
||||||
|
|
||||||
foreach (var (lbl, box) in boxes)
|
foreach (var (lbl, box) in boxes)
|
||||||
{
|
{
|
||||||
g.FillRectangle(gauge, box);
|
|
||||||
g.DrawRectangle(pen, box);
|
g.DrawRectangle(pen, box);
|
||||||
TextRenderer.DrawText(g, lbl, font, box, Color.Gainsboro,
|
TextRenderer.DrawText(g, lbl, font, box, Color.Gainsboro,
|
||||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The X/Y stick dot goes on top of the crosshair and label
|
||||||
|
// (0 = left/top, matching the HID axis convention).
|
||||||
|
if (axes is { } live)
|
||||||
|
{
|
||||||
|
int px = xyBox.X + (int)Math.Round(AxisGauges.Fraction(live.X) * (xyBox.Width - 1));
|
||||||
|
int py = xyBox.Y + (int)Math.Round(AxisGauges.Fraction(live.Y) * (xyBox.Height - 1));
|
||||||
|
using var dot = new SolidBrush(Color.FromArgb(150, 230, 150));
|
||||||
|
g.FillEllipse(dot, px - 3, py - 3, 7, 7);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static PanelButton? HitTest(IReadOnlyList<PanelButton> buttons, Point p)
|
public static PanelButton? HitTest(IReadOnlyList<PanelButton> buttons, Point p)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
using RioJoy.Core.Calibration;
|
||||||
using RioJoy.Core.Editing;
|
using RioJoy.Core.Editing;
|
||||||
using RioJoy.Core.Output;
|
using RioJoy.Core.Output;
|
||||||
using RioJoy.Core.Mapping;
|
using RioJoy.Core.Mapping;
|
||||||
@@ -465,6 +466,10 @@ public sealed class ProfileEditorForm : Form
|
|||||||
public void ShowLiveActivity(int address, bool pressed) =>
|
public void ShowLiveActivity(int address, bool pressed) =>
|
||||||
RunOnUi(() => _canvas.SetLivePressed(address, pressed));
|
RunOnUi(() => _canvas.SetLivePressed(address, pressed));
|
||||||
|
|
||||||
|
/// <summary>Show the live calibrated axis values on the encoder gauges (serial thread).</summary>
|
||||||
|
public void ShowAxes(AxisOutputs axes) =>
|
||||||
|
RunOnUi(() => _canvas.SetAxes(axes));
|
||||||
|
|
||||||
/// <summary>Show the firmware version from a RIO version reply (serial thread).</summary>
|
/// <summary>Show the firmware version from a RIO version reply (serial thread).</summary>
|
||||||
public void ShowVersion(VersionInfo version) =>
|
public void ShowVersion(VersionInfo version) =>
|
||||||
RunOnUi(() => { _version = version; RenderStatus(); });
|
RunOnUi(() => { _version = version; RenderStatus(); });
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
|||||||
{
|
{
|
||||||
activity = editor.ShowLiveActivity;
|
activity = editor.ShowLiveActivity;
|
||||||
runtime.ButtonActivity += activity;
|
runtime.ButtonActivity += activity;
|
||||||
|
runtime.AxesUpdated += editor.ShowAxes;
|
||||||
runtime.VersionReceived += editor.ShowVersion;
|
runtime.VersionReceived += editor.ShowVersion;
|
||||||
runtime.CheckReceived += editor.AddCheckStatus;
|
runtime.CheckReceived += editor.AddCheckStatus;
|
||||||
}
|
}
|
||||||
@@ -248,6 +249,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
|||||||
if (runtime is not null && activity is not null)
|
if (runtime is not null && activity is not null)
|
||||||
{
|
{
|
||||||
runtime.ButtonActivity -= activity;
|
runtime.ButtonActivity -= activity;
|
||||||
|
runtime.AxesUpdated -= editor.ShowAxes;
|
||||||
runtime.VersionReceived -= editor.ShowVersion;
|
runtime.VersionReceived -= editor.ShowVersion;
|
||||||
runtime.CheckReceived -= editor.AddCheckStatus;
|
runtime.CheckReceived -= editor.AddCheckStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using RioJoy.Core.Calibration;
|
||||||
|
using RioJoy.Core.Editing;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace RioJoy.Core.Tests.Editing;
|
||||||
|
|
||||||
|
public class AxisGaugesTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 0.0)]
|
||||||
|
[InlineData(AxisOutputs.Max, 1.0)]
|
||||||
|
[InlineData(-500, 0.0)] // clamps below range
|
||||||
|
[InlineData(AxisOutputs.Max + 500, 1.0)] // clamps above range
|
||||||
|
public void Fraction_MapsAndClamps(int value, double expected) =>
|
||||||
|
Assert.Equal(expected, AxisGauges.Fraction(value), precision: 6);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Fraction_Center_IsHalf() =>
|
||||||
|
Assert.Equal(0.5, AxisGauges.Fraction(AxisOutputs.Center), precision: 3);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(AxisOutputs.Center, 0.0)]
|
||||||
|
[InlineData(0, -1.0)]
|
||||||
|
[InlineData(-500, -1.0)] // clamps below range
|
||||||
|
[InlineData(AxisOutputs.Max + 500, 1.0)] // clamps above range
|
||||||
|
public void Deflection_MapsAndClamps(int value, double expected) =>
|
||||||
|
Assert.Equal(expected, AxisGauges.Deflection(value), precision: 6);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Deflection_Max_IsFullRight() =>
|
||||||
|
Assert.Equal(1.0, AxisGauges.Deflection(AxisOutputs.Max), precision: 3);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using AxisOutputs = RioJoy.Core.Calibration.AxisOutputs;
|
||||||
using RioJoy.Core;
|
using RioJoy.Core;
|
||||||
using RioJoy.Core.Mapping;
|
using RioJoy.Core.Mapping;
|
||||||
using RioJoy.Core.Protocol;
|
using RioJoy.Core.Protocol;
|
||||||
@@ -72,6 +73,32 @@ public class RioRuntimeTests
|
|||||||
await run;
|
await run;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnalogReply_RaisesAxesUpdated()
|
||||||
|
{
|
||||||
|
var fake = new FakeTransport();
|
||||||
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
||||||
|
var recorder = new RecordingSink();
|
||||||
|
|
||||||
|
using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder);
|
||||||
|
AxisOutputs? seen = null;
|
||||||
|
runtime.AxesUpdated += axes => seen = axes;
|
||||||
|
runtime.Start();
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
Task run = link.RunAsync(cts.Token);
|
||||||
|
|
||||||
|
// All raw axes at zero → joystick X/Y centered, same values the sink receives.
|
||||||
|
fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => seen is not null);
|
||||||
|
Assert.Equal(AxisOutputs.Center, seen!.Value.X);
|
||||||
|
Assert.Equal(AxisOutputs.Center, seen.Value.Y);
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
await run;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task LitLamps_Dimmed_OnFirstBoardReply_NotOnStart()
|
public async Task LitLamps_Dimmed_OnFirstBoardReply_NotOnStart()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user