diff --git a/Helios/Controls/DualPotentiometer.cs b/Helios/Controls/DualPotentiometer.cs
new file mode 100644
index 000000000..a5fa46997
--- /dev/null
+++ b/Helios/Controls/DualPotentiometer.cs
@@ -0,0 +1,706 @@
+// Copyright 2014 Craig Courtney
+// Copyright 2026 Helios Contributors
+//
+// Helios is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Helios is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+using System.ComponentModel;
+using System.Globalization;
+using System.Windows;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Xml;
+using GadrocsWorkshop.Helios.ComponentModel;
+using GadrocsWorkshop.Helios.Controls.Capabilities;
+using GadrocsWorkshop.Helios.Windows.Controls;
+
+namespace GadrocsWorkshop.Helios.Controls
+{
+ ///
+ /// which potentiometer of a DualPotentiometer, if any, is carried around by the other one
+ ///
+ /// WARNING: these value names must never be changed, as they are serialized to Profile XML
+ ///
+ public enum DualPotentiometerFollowMode
+ {
+ [Description("Pot 2 follows Pot 1")]
+ Potentiometer2FollowsPotentiometer1,
+
+ [Description("Pot 1 follows Pot 2")]
+ Potentiometer1FollowsPotentiometer2,
+
+ [Description("Independent")]
+ None
+ }
+
+ ///
+ /// Two concentric rotating images, each driven by its own input value in the same way
+ /// a potentiometer is (min value, max value, initial rotation, rotation travel).
+ ///
+ /// Potentiometer 1 is shown to the user as "Pot 1 (Lower)" and Potentiometer 2 as "Pot 2 (Upper)".
+ /// Either one can be carried around by the other while its own value turns it in either direction
+ /// on top of that (for example the F-16 airspeed pointer and the Mach dial that rides with it).
+ ///
+ /// The mouse wheel turns Potentiometer 2 while the pointer is over its image, and Potentiometer 1
+ /// anywhere else on the control.
+ ///
+ [HeliosControl("Helios.Base.DualPotentiometer", "Dual Potentiometer", "Potentiometers", typeof(DualPotentiometerRenderer))]
+ public class DualPotentiometer : HeliosVisual, IConfigurableImageLocation, IRefreshableImage
+ {
+ private const string DefaultPotentiometer1Name = "pot 1";
+ private const string DefaultPotentiometer2Name = "pot 2";
+
+ private bool _mouseWheelAction = true;
+
+ private string _potentiometer1Name = DefaultPotentiometer1Name;
+ private string _potentiometer1Image = "{Helios}/Images/Knobs/knob1.png";
+ private double _potentiometer1InitialValue;
+ private double _potentiometer1StepValue = 0.1d;
+ private double _potentiometer1MinValue;
+ private double _potentiometer1MaxValue = 1d;
+ private double _potentiometer1InitialRotation;
+ private double _potentiometer1RotationTravel = 360d;
+ private bool _potentiometer1CounterClockwise;
+ private double _potentiometer1Scale = 1d;
+
+ private string _potentiometer2Name = DefaultPotentiometer2Name;
+ private string _potentiometer2Image = "{Helios}/Images/Knobs/knob1.png";
+ private double _potentiometer2InitialValue;
+ private double _potentiometer2StepValue = 0.1d;
+ private double _potentiometer2MinValue;
+ private double _potentiometer2MaxValue = 1d;
+ private double _potentiometer2InitialRotation;
+ private double _potentiometer2RotationTravel = 360d;
+ private bool _potentiometer2CounterClockwise = true;
+ private double _potentiometer2Scale = 0.5d;
+
+ private DualPotentiometerFollowMode _followMode = DualPotentiometerFollowMode.Potentiometer2FollowsPotentiometer1;
+ private bool _potentiometer2OnTop = true;
+
+ private double _potentiometer1Rotation;
+ private double _potentiometer2Rotation;
+
+ private readonly HeliosValue _potentiometer1Value;
+ private readonly HeliosValue _potentiometer2Value;
+
+ public DualPotentiometer()
+ : base("Dual Potentiometer", new Size(200, 200))
+ {
+ _potentiometer1Value = new HeliosValue(this, new BindingValue(0d), "", "potentiometer 1 value", "potentiometer 1 value",
+ "Current value of pot 1 (the lower pot).", "", BindingValueUnits.Numeric);
+ _potentiometer1Value.Execute += Potentiometer1Value_Execute;
+ Values.Add(_potentiometer1Value);
+ Actions.Add(_potentiometer1Value);
+ Triggers.Add(_potentiometer1Value);
+
+ _potentiometer2Value = new HeliosValue(this, new BindingValue(0d), "", "potentiometer 2 value", "potentiometer 2 value",
+ "Current value of pot 2 (the upper pot).", "", BindingValueUnits.Numeric);
+ _potentiometer2Value.Execute += Potentiometer2Value_Execute;
+ Values.Add(_potentiometer2Value);
+ Actions.Add(_potentiometer2Value);
+ Triggers.Add(_potentiometer2Value);
+
+ UpdateValueNames();
+
+ UpdateRotations();
+ }
+
+ #region Potentiometer 1 (lower) properties
+
+ ///
+ /// Name of potentiometer 1 as it is shown in the bindings lists. Changing it does not break existing bindings.
+ ///
+ public string Potentiometer1Name
+ {
+ get => _potentiometer1Name;
+ set
+ {
+ string newValue = string.IsNullOrWhiteSpace(value) ? DefaultPotentiometer1Name : value.Trim();
+ if (_potentiometer1Name.Equals(newValue))
+ {
+ return;
+ }
+ string oldValue = _potentiometer1Name;
+ _potentiometer1Name = newValue;
+ UpdateValueNames();
+ OnPropertyChanged(nameof(Potentiometer1Name), oldValue, newValue, true);
+ }
+ }
+
+ public string Potentiometer1Image
+ {
+ get => _potentiometer1Image;
+ set => SetImage(ref _potentiometer1Image, value, nameof(Potentiometer1Image));
+ }
+
+ public double Potentiometer1InitialValue
+ {
+ get => _potentiometer1InitialValue;
+ set => SetConfig(ref _potentiometer1InitialValue, value, nameof(Potentiometer1InitialValue));
+ }
+
+ ///
+ /// Amount the value of this pot changes for each click of the mouse wheel
+ ///
+ public double Potentiometer1StepValue
+ {
+ get => _potentiometer1StepValue;
+ set => SetConfig(ref _potentiometer1StepValue, value, nameof(Potentiometer1StepValue));
+ }
+
+ public double Potentiometer1MinValue
+ {
+ get => _potentiometer1MinValue;
+ set => SetConfig(ref _potentiometer1MinValue, value, nameof(Potentiometer1MinValue));
+ }
+
+ public double Potentiometer1MaxValue
+ {
+ get => _potentiometer1MaxValue;
+ set => SetConfig(ref _potentiometer1MaxValue, value, nameof(Potentiometer1MaxValue));
+ }
+
+ ///
+ /// Rotation in degrees of potentiometer 1 when both pots are at their minimum value
+ ///
+ public double Potentiometer1InitialRotation
+ {
+ get => _potentiometer1InitialRotation;
+ set => SetConfig(ref _potentiometer1InitialRotation, value, nameof(Potentiometer1InitialRotation));
+ }
+
+ ///
+ /// Degrees potentiometer 1 turns between its minimum and maximum value
+ ///
+ public double Potentiometer1RotationTravel
+ {
+ get => _potentiometer1RotationTravel;
+ set => SetConfig(ref _potentiometer1RotationTravel, value, nameof(Potentiometer1RotationTravel));
+ }
+
+ public bool Potentiometer1CounterClockwise
+ {
+ get => _potentiometer1CounterClockwise;
+ set => SetConfig(ref _potentiometer1CounterClockwise, value, nameof(Potentiometer1CounterClockwise));
+ }
+
+ ///
+ /// Size of the potentiometer 1 image relative to the control (1 = fills the control)
+ ///
+ public double Potentiometer1Scale
+ {
+ get => _potentiometer1Scale;
+ set
+ {
+ if (SetConfig(ref _potentiometer1Scale, value, nameof(Potentiometer1Scale)))
+ {
+ Refresh();
+ }
+ }
+ }
+
+ ///
+ /// UI access to the current value of potentiometer 1; writes do not create Undo events
+ ///
+ public double Potentiometer1Value
+ {
+ get => _potentiometer1Value.Value.DoubleValue;
+ set
+ {
+ double oldValue = _potentiometer1Value.Value.DoubleValue;
+ if (oldValue.Equals(value))
+ {
+ return;
+ }
+ _potentiometer1Value.SetValue(new BindingValue(value), BypassTriggers);
+ OnPropertyChanged(nameof(Potentiometer1Value), oldValue, value, false);
+ UpdateRotations();
+ }
+ }
+
+ #endregion
+
+ #region Potentiometer 2 (upper) properties
+
+ ///
+ /// Name of potentiometer 2 as it is shown in the bindings lists. Changing it does not break existing bindings.
+ ///
+ public string Potentiometer2Name
+ {
+ get => _potentiometer2Name;
+ set
+ {
+ string newValue = string.IsNullOrWhiteSpace(value) ? DefaultPotentiometer2Name : value.Trim();
+ if (_potentiometer2Name.Equals(newValue))
+ {
+ return;
+ }
+ string oldValue = _potentiometer2Name;
+ _potentiometer2Name = newValue;
+ UpdateValueNames();
+ OnPropertyChanged(nameof(Potentiometer2Name), oldValue, newValue, true);
+ }
+ }
+
+ public string Potentiometer2Image
+ {
+ get => _potentiometer2Image;
+ set => SetImage(ref _potentiometer2Image, value, nameof(Potentiometer2Image));
+ }
+
+ public double Potentiometer2InitialValue
+ {
+ get => _potentiometer2InitialValue;
+ set => SetConfig(ref _potentiometer2InitialValue, value, nameof(Potentiometer2InitialValue));
+ }
+
+ ///
+ /// Amount the value of this pot changes for each click of the mouse wheel
+ ///
+ public double Potentiometer2StepValue
+ {
+ get => _potentiometer2StepValue;
+ set => SetConfig(ref _potentiometer2StepValue, value, nameof(Potentiometer2StepValue));
+ }
+
+ public double Potentiometer2MinValue
+ {
+ get => _potentiometer2MinValue;
+ set => SetConfig(ref _potentiometer2MinValue, value, nameof(Potentiometer2MinValue));
+ }
+
+ public double Potentiometer2MaxValue
+ {
+ get => _potentiometer2MaxValue;
+ set => SetConfig(ref _potentiometer2MaxValue, value, nameof(Potentiometer2MaxValue));
+ }
+
+ ///
+ /// Rotation in degrees of potentiometer 2 when both pots are at their minimum value
+ ///
+ public double Potentiometer2InitialRotation
+ {
+ get => _potentiometer2InitialRotation;
+ set => SetConfig(ref _potentiometer2InitialRotation, value, nameof(Potentiometer2InitialRotation));
+ }
+
+ ///
+ /// Degrees potentiometer 2 turns, relative to whatever is carrying it, between its minimum and maximum value
+ ///
+ public double Potentiometer2RotationTravel
+ {
+ get => _potentiometer2RotationTravel;
+ set => SetConfig(ref _potentiometer2RotationTravel, value, nameof(Potentiometer2RotationTravel));
+ }
+
+ public bool Potentiometer2CounterClockwise
+ {
+ get => _potentiometer2CounterClockwise;
+ set => SetConfig(ref _potentiometer2CounterClockwise, value, nameof(Potentiometer2CounterClockwise));
+ }
+
+ ///
+ /// Size of the potentiometer 2 image relative to the control (1 = fills the control)
+ ///
+ public double Potentiometer2Scale
+ {
+ get => _potentiometer2Scale;
+ set
+ {
+ if (SetConfig(ref _potentiometer2Scale, value, nameof(Potentiometer2Scale)))
+ {
+ Refresh();
+ }
+ }
+ }
+
+ ///
+ /// Selects which potentiometer is carried around by the other one. The following potentiometer's own value
+ /// turns it relative to the potentiometer it follows.
+ ///
+ public DualPotentiometerFollowMode FollowMode
+ {
+ get => _followMode;
+ set => SetConfig(ref _followMode, value, nameof(FollowMode));
+ }
+
+ ///
+ /// If true, potentiometer 2 is drawn over potentiometer 1, otherwise under it
+ ///
+ public bool Potentiometer2OnTop
+ {
+ get => _potentiometer2OnTop;
+ set => SetConfig(ref _potentiometer2OnTop, value, nameof(Potentiometer2OnTop));
+ }
+
+ ///
+ /// UI access to the current value of potentiometer 2; writes do not create Undo events
+ ///
+ public double Potentiometer2Value
+ {
+ get => _potentiometer2Value.Value.DoubleValue;
+ set
+ {
+ double oldValue = _potentiometer2Value.Value.DoubleValue;
+ if (oldValue.Equals(value))
+ {
+ return;
+ }
+ _potentiometer2Value.SetValue(new BindingValue(value), BypassTriggers);
+ OnPropertyChanged(nameof(Potentiometer2Value), oldValue, value, false);
+ UpdateRotations();
+ }
+ }
+
+ #endregion
+
+ #region Rendered state
+
+ ///
+ /// Current rotation in degrees at which potentiometer 1 is drawn, including any rotation it inherits from potentiometer 2
+ ///
+ public double Potentiometer1Rotation => _potentiometer1Rotation;
+
+ ///
+ /// Current rotation in degrees at which potentiometer 2 is drawn, including any rotation it inherits from potentiometer 1
+ ///
+ public double Potentiometer2Rotation => _potentiometer2Rotation;
+
+ #endregion
+
+ #region Actions
+
+ private void Potentiometer1Value_Execute(object action, HeliosActionEventArgs e)
+ {
+ _potentiometer1Value.SetValue(e.Value, e.BypassCascadingTriggers);
+ UpdateRotations();
+ }
+
+ private void Potentiometer2Value_Execute(object action, HeliosActionEventArgs e)
+ {
+ _potentiometer2Value.SetValue(e.Value, e.BypassCascadingTriggers);
+ UpdateRotations();
+ }
+
+ #endregion
+
+ ///
+ /// Degrees a potentiometer has turned away from its initial rotation for the given value
+ ///
+ private static double Deflection(double value, double min, double max, double travel, bool counterClockwise)
+ {
+ double span = max - min;
+ if (span.Equals(0d) || double.IsNaN(value))
+ {
+ return 0d;
+ }
+ double fraction = Math.Max(0d, Math.Min(1d, (value - min) / span));
+ return (counterClockwise ? -1d : 1d) * fraction * travel;
+ }
+
+ private void UpdateRotations()
+ {
+ double deflection1 = Deflection(_potentiometer1Value.Value.DoubleValue, _potentiometer1MinValue, _potentiometer1MaxValue,
+ _potentiometer1RotationTravel, _potentiometer1CounterClockwise);
+ double deflection2 = Deflection(_potentiometer2Value.Value.DoubleValue, _potentiometer2MinValue, _potentiometer2MaxValue,
+ _potentiometer2RotationTravel, _potentiometer2CounterClockwise);
+
+ double rotation1 = _potentiometer1InitialRotation + deflection1 +
+ (_followMode == DualPotentiometerFollowMode.Potentiometer1FollowsPotentiometer2 ? deflection2 : 0d);
+ double rotation2 = _potentiometer2InitialRotation + deflection2 +
+ (_followMode == DualPotentiometerFollowMode.Potentiometer2FollowsPotentiometer1 ? deflection1 : 0d);
+
+ if (!rotation1.Equals(_potentiometer1Rotation) || !rotation2.Equals(_potentiometer2Rotation))
+ {
+ _potentiometer1Rotation = rotation1;
+ _potentiometer2Rotation = rotation2;
+ OnDisplayUpdate();
+ }
+ }
+
+ ///
+ /// Applies the potentiometer names to the names shown for the bindable values. The identifiers used
+ /// to save bindings in the profile are fixed, so bindings survive a rename.
+ ///
+ private void UpdateValueNames()
+ {
+ _potentiometer1Value.Name = _potentiometer1Name + " value";
+ _potentiometer1Value.RecalculateName();
+ _potentiometer2Value.Name = _potentiometer2Name + " value";
+ _potentiometer2Value.RecalculateName();
+ }
+
+ private bool SetConfig(ref T field, T value, string propertyName)
+ {
+ if (Equals(field, value))
+ {
+ return false;
+ }
+ T oldValue = field;
+ field = value;
+ OnPropertyChanged(propertyName, oldValue, value, true);
+ UpdateRotations();
+ OnDisplayUpdate();
+ return true;
+ }
+
+ private void SetImage(ref string field, string value, string propertyName)
+ {
+ if (string.Equals(field, value))
+ {
+ return;
+ }
+ string oldValue = field;
+ field = value;
+ OnPropertyChanged(propertyName, oldValue, value, true);
+ Refresh();
+ }
+
+ #region IConfigurableImageLocation
+
+ public void ReplaceImageNames(string oldName, string newName)
+ {
+ Potentiometer1Image = ReplaceImageName(Potentiometer1Image, oldName, newName);
+ Potentiometer2Image = ReplaceImageName(Potentiometer2Image, oldName, newName);
+ }
+
+ private static string ReplaceImageName(string image, string oldName, string newName)
+ {
+ return string.IsNullOrEmpty(image) ? image : string.IsNullOrEmpty(oldName) ? newName + image : image.Replace(oldName, newName);
+ }
+
+ #endregion
+
+ #region IRefreshableImage
+
+ public override bool ConditionalImageRefresh(string imageName)
+ {
+ if ((Potentiometer1Image ?? "").ToLower().Replace("/", @"\") == imageName ||
+ (Potentiometer2Image ?? "").ToLower().Replace("/", @"\") == imageName)
+ {
+ ImageRefresh = true;
+ Refresh();
+ }
+ return ImageRefresh;
+ }
+
+ #endregion
+
+ public override void Reset()
+ {
+ base.Reset();
+
+ BeginTriggerBypass(true);
+ Potentiometer1Value = Potentiometer1InitialValue;
+ Potentiometer2Value = Potentiometer2InitialValue;
+ EndTriggerBypass(true);
+ UpdateRotations();
+ }
+
+ #region Mouse wheel
+
+ ///
+ /// If true, the mouse wheel turns Pot 2 while the pointer is over the Pot 2 image and Pot 1 anywhere else
+ ///
+ public bool MouseWheelAction
+ {
+ get => _mouseWheelAction;
+ set => SetConfig(ref _mouseWheelAction, value, nameof(MouseWheelAction));
+ }
+
+ public override bool CanConsumeMouseWheel => _mouseWheelAction;
+
+ public override void MouseWheel(int delta)
+ {
+ if (!_mouseWheelAction || delta == 0)
+ {
+ return;
+ }
+
+ // NOTE: ignoring mouse wheel speed because unit is unclear, same as the other rotary controls
+ int pulses = delta < 1 ? -1 : 1;
+ if (IsPointerOverPotentiometer2())
+ {
+ Potentiometer2Value = Stepped(Potentiometer2Value, pulses * _potentiometer2StepValue, _potentiometer2MinValue, _potentiometer2MaxValue);
+ }
+ else
+ {
+ Potentiometer1Value = Stepped(Potentiometer1Value, pulses * _potentiometer1StepValue, _potentiometer1MinValue, _potentiometer1MaxValue);
+ }
+ }
+
+ private static double Stepped(double value, double change, double min, double max)
+ {
+ double low = Math.Min(min, max);
+ double high = Math.Max(min, max);
+ return Math.Round(Math.Max(low, Math.Min(high, value + change)), 5);
+ }
+
+ ///
+ /// The mouse wheel notification does not include a location, so we ask WPF where the pointer is
+ /// relative to the view that is displaying this control, and then test that against the
+ /// area covered by the Pot 2 image.
+ ///
+ private bool IsPointerOverPotentiometer2()
+ {
+ DependencyObject element = Mouse.DirectlyOver as DependencyObject;
+ while (element != null)
+ {
+ if (element is HeliosVisualView view && ReferenceEquals(view.Visual, this))
+ {
+ return IsOverPotentiometer2(Mouse.GetPosition(view));
+ }
+ element = element is Visual || element is System.Windows.Media.Media3D.Visual3D
+ ? VisualTreeHelper.GetParent(element)
+ : null;
+ }
+
+ // pointer location is not available, so treat it as being outside of Pot 2
+ return false;
+ }
+
+ /// location relative to the upper left corner of this control
+ /// true if the location is inside the ellipse covered by the Pot 2 image
+ private bool IsOverPotentiometer2(Point location)
+ {
+ double radiusX = Width * _potentiometer2Scale / 2d;
+ double radiusY = Height * _potentiometer2Scale / 2d;
+ if (radiusX <= 0d || radiusY <= 0d)
+ {
+ return false;
+ }
+ double x = (location.X - Width / 2d) / radiusX;
+ double y = (location.Y - Height / 2d) / radiusY;
+ return x * x + y * y <= 1d;
+ }
+
+ #endregion
+
+ // this control does not react to touch or mouse buttons
+
+ public override void MouseDown(Point location)
+ {
+ // no code
+ }
+
+ public override void MouseDrag(Point location)
+ {
+ // no code
+ }
+
+ public override void MouseUp(Point location)
+ {
+ // no code
+ }
+
+ #region Persistence
+
+ public override void WriteXml(XmlWriter writer)
+ {
+ base.WriteXml(writer);
+ writer.WriteStartElement("Potentiometer1");
+ WritePotentiometer(writer, Potentiometer1Name, Potentiometer1Image, Potentiometer1InitialValue, Potentiometer1MinValue, Potentiometer1MaxValue,
+ Potentiometer1InitialRotation, Potentiometer1RotationTravel, Potentiometer1CounterClockwise, Potentiometer1Scale, Potentiometer1StepValue);
+ writer.WriteEndElement();
+ writer.WriteStartElement("Potentiometer2");
+ WritePotentiometer(writer, Potentiometer2Name, Potentiometer2Image, Potentiometer2InitialValue, Potentiometer2MinValue, Potentiometer2MaxValue,
+ Potentiometer2InitialRotation, Potentiometer2RotationTravel, Potentiometer2CounterClockwise, Potentiometer2Scale, Potentiometer2StepValue);
+ writer.WriteElementString("OnTop", Potentiometer2OnTop.ToString(CultureInfo.InvariantCulture));
+ writer.WriteEndElement();
+ writer.WriteElementString("FollowMode", FollowMode.ToString());
+ writer.WriteElementString("MouseWheel", MouseWheelAction.ToString(CultureInfo.InvariantCulture));
+ }
+
+ private static void WritePotentiometer(XmlWriter writer, string name, string image, double initialValue, double minValue,
+ double maxValue, double initialRotation, double rotationTravel, bool counterClockwise, double scale, double stepValue)
+ {
+ writer.WriteElementString("Name", name);
+ writer.WriteElementString("Image", image ?? "");
+ writer.WriteElementString("InitialValue", initialValue.ToString(CultureInfo.InvariantCulture));
+ writer.WriteElementString("MinValue", minValue.ToString(CultureInfo.InvariantCulture));
+ writer.WriteElementString("MaxValue", maxValue.ToString(CultureInfo.InvariantCulture));
+ writer.WriteElementString("InitialRotation", initialRotation.ToString(CultureInfo.InvariantCulture));
+ writer.WriteElementString("RotationTravel", rotationTravel.ToString(CultureInfo.InvariantCulture));
+ writer.WriteElementString("CounterClockwise", counterClockwise.ToString(CultureInfo.InvariantCulture));
+ writer.WriteElementString("Scale", scale.ToString(CultureInfo.InvariantCulture));
+ writer.WriteElementString("StepValue", stepValue.ToString(CultureInfo.InvariantCulture));
+ }
+
+ public override void ReadXml(XmlReader reader)
+ {
+ base.ReadXml(reader);
+ TypeConverter bc = TypeDescriptor.GetConverter(typeof(bool));
+
+ reader.ReadStartElement("Potentiometer1");
+ if (reader.Name.Equals("Name"))
+ {
+ Potentiometer1Name = reader.ReadElementString("Name");
+ }
+ Potentiometer1Image = reader.ReadElementString("Image");
+ Potentiometer1InitialValue = ReadDouble(reader, "InitialValue");
+ Potentiometer1MinValue = ReadDouble(reader, "MinValue");
+ Potentiometer1MaxValue = ReadDouble(reader, "MaxValue");
+ Potentiometer1InitialRotation = ReadDouble(reader, "InitialRotation");
+ Potentiometer1RotationTravel = ReadDouble(reader, "RotationTravel");
+ Potentiometer1CounterClockwise = (bool)bc.ConvertFromInvariantString(reader.ReadElementString("CounterClockwise"));
+ Potentiometer1Scale = ReadDouble(reader, "Scale");
+ if (reader.Name.Equals("StepValue"))
+ {
+ Potentiometer1StepValue = ReadDouble(reader, "StepValue");
+ }
+ reader.ReadEndElement();
+
+ reader.ReadStartElement("Potentiometer2");
+ if (reader.Name.Equals("Name"))
+ {
+ Potentiometer2Name = reader.ReadElementString("Name");
+ }
+ Potentiometer2Image = reader.ReadElementString("Image");
+ Potentiometer2InitialValue = ReadDouble(reader, "InitialValue");
+ Potentiometer2MinValue = ReadDouble(reader, "MinValue");
+ Potentiometer2MaxValue = ReadDouble(reader, "MaxValue");
+ Potentiometer2InitialRotation = ReadDouble(reader, "InitialRotation");
+ Potentiometer2RotationTravel = ReadDouble(reader, "RotationTravel");
+ Potentiometer2CounterClockwise = (bool)bc.ConvertFromInvariantString(reader.ReadElementString("CounterClockwise"));
+ Potentiometer2Scale = ReadDouble(reader, "Scale");
+ if (reader.Name.Equals("StepValue"))
+ {
+ Potentiometer2StepValue = ReadDouble(reader, "StepValue");
+ }
+ Potentiometer2OnTop = (bool)bc.ConvertFromInvariantString(reader.ReadElementString("OnTop"));
+ reader.ReadEndElement();
+ if (reader.Name.Equals("FollowMode"))
+ {
+ FollowMode = (DualPotentiometerFollowMode)Enum.Parse(typeof(DualPotentiometerFollowMode), reader.ReadElementString("FollowMode"));
+ }
+ if (reader.Name.Equals("MouseWheel"))
+ {
+ MouseWheelAction = (bool)bc.ConvertFromInvariantString(reader.ReadElementString("MouseWheel"));
+ }
+
+ BeginTriggerBypass(true);
+ Potentiometer1Value = Potentiometer1InitialValue;
+ Potentiometer2Value = Potentiometer2InitialValue;
+ EndTriggerBypass(true);
+ UpdateRotations();
+ }
+
+ private static double ReadDouble(XmlReader reader, string elementName)
+ {
+ return double.Parse(reader.ReadElementString(elementName), CultureInfo.InvariantCulture);
+ }
+
+ #endregion
+ }
+}
diff --git a/Helios/Controls/DualPotentiometerAppearanceEditor.xaml b/Helios/Controls/DualPotentiometerAppearanceEditor.xaml
new file mode 100644
index 000000000..f948452a6
--- /dev/null
+++ b/Helios/Controls/DualPotentiometerAppearanceEditor.xaml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Pot 1 (Lower)
+
+
+
+
+
+
+
+
+
+
+ Pot 2 (Upper)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Helios/Controls/DualPotentiometerAppearanceEditor.xaml.cs b/Helios/Controls/DualPotentiometerAppearanceEditor.xaml.cs
new file mode 100644
index 000000000..551d79a11
--- /dev/null
+++ b/Helios/Controls/DualPotentiometerAppearanceEditor.xaml.cs
@@ -0,0 +1,73 @@
+// Copyright 2014 Craig Courtney
+// Copyright 2026 Helios Contributors
+//
+// Helios is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Helios is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+namespace GadrocsWorkshop.Helios.Controls
+{
+ using GadrocsWorkshop.Helios.ComponentModel;
+ using GadrocsWorkshop.Helios.Windows.Controls;
+ using System.Windows;
+
+ ///
+ /// Interaction logic for DualPotentiometerAppearanceEditor.xaml
+ ///
+ [HeliosPropertyEditor("Helios.Base.DualPotentiometer", "Appearance")]
+ public partial class DualPotentiometerAppearanceEditor : HeliosPropertyEditor
+ {
+ public DualPotentiometerAppearanceEditor()
+ {
+ InitializeComponent();
+ }
+
+ // while a rotation slider is being adjusted, move the pots to the position that slider
+ // describes, so the effect can be seen in the editor
+
+ private void Potentiometer1InitialRotation_GotFocus(object sender, RoutedEventArgs e)
+ {
+ if (Control is DualPotentiometer pots)
+ {
+ pots.Potentiometer1Value = pots.Potentiometer1MinValue;
+ pots.Potentiometer2Value = pots.Potentiometer2MinValue;
+ }
+ }
+
+ private void Potentiometer1RotationTravel_GotFocus(object sender, RoutedEventArgs e)
+ {
+ if (Control is DualPotentiometer pots)
+ {
+ pots.Potentiometer1Value = pots.Potentiometer1MaxValue;
+ pots.Potentiometer2Value = pots.Potentiometer2MinValue;
+ }
+ }
+
+ private void Potentiometer2InitialRotation_GotFocus(object sender, RoutedEventArgs e)
+ {
+ if (Control is DualPotentiometer pots)
+ {
+ pots.Potentiometer1Value = pots.Potentiometer1MinValue;
+ pots.Potentiometer2Value = pots.Potentiometer2MinValue;
+ }
+ }
+
+ private void Potentiometer2RotationTravel_GotFocus(object sender, RoutedEventArgs e)
+ {
+ if (Control is DualPotentiometer pots)
+ {
+ pots.Potentiometer1Value = pots.Potentiometer1MinValue;
+ pots.Potentiometer2Value = pots.Potentiometer2MaxValue;
+ }
+ }
+ }
+}
diff --git a/Helios/Controls/DualPotentiometerBehaviorEditor.xaml b/Helios/Controls/DualPotentiometerBehaviorEditor.xaml
new file mode 100644
index 000000000..31da1aa7e
--- /dev/null
+++ b/Helios/Controls/DualPotentiometerBehaviorEditor.xaml
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Pot 1 (Lower) Value
+
+
+
+
+
+
+
+
+
+
+ Pot 2 (Upper) Value
+
+
+
+
+
+
+
+
+
+
+ Linkage
+
+
+
+
+
+
diff --git a/Helios/Controls/DualPotentiometerBehaviorEditor.xaml.cs b/Helios/Controls/DualPotentiometerBehaviorEditor.xaml.cs
new file mode 100644
index 000000000..0668a44b0
--- /dev/null
+++ b/Helios/Controls/DualPotentiometerBehaviorEditor.xaml.cs
@@ -0,0 +1,33 @@
+// Copyright 2014 Craig Courtney
+// Copyright 2026 Helios Contributors
+//
+// Helios is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Helios is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+namespace GadrocsWorkshop.Helios.Controls
+{
+ using GadrocsWorkshop.Helios.ComponentModel;
+ using GadrocsWorkshop.Helios.Windows.Controls;
+
+ ///
+ /// Interaction logic for DualPotentiometerBehaviorEditor.xaml
+ ///
+ [HeliosPropertyEditor("Helios.Base.DualPotentiometer", "Behavior")]
+ public partial class DualPotentiometerBehaviorEditor : HeliosPropertyEditor
+ {
+ public DualPotentiometerBehaviorEditor()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Helios/Controls/DualPotentiometerRenderer.cs b/Helios/Controls/DualPotentiometerRenderer.cs
new file mode 100644
index 000000000..178570325
--- /dev/null
+++ b/Helios/Controls/DualPotentiometerRenderer.cs
@@ -0,0 +1,97 @@
+// Copyright 2014 Craig Courtney
+// Copyright 2026 Helios Contributors
+//
+// Helios is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Helios is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+namespace GadrocsWorkshop.Helios.Controls
+{
+ using System.Windows;
+ using System.Windows.Media;
+
+ public class DualPotentiometerRenderer : HeliosVisualRenderer
+ {
+ private ImageSource _image1;
+ private ImageSource _image2;
+ private Rect _rect1;
+ private Rect _rect2;
+ private Point _center;
+ private readonly DrawingGroup _group = new DrawingGroup();
+
+ protected override void OnRender(DrawingContext drawingContext)
+ {
+ if (!(Visual is DualPotentiometer pots))
+ {
+ return;
+ }
+
+ DrawingContext ctx = _group.Open();
+
+ // keep the extent of the drawing equal to the control, so that it does not move
+ // or get rescaled as rotated images change the bounding box
+ ctx.DrawRectangle(Brushes.Transparent, null, new Rect(0d, 0d, pots.Width, pots.Height));
+
+ if (pots.Potentiometer2OnTop)
+ {
+ DrawPotentiometer(ctx, _image1, _rect1, pots.Potentiometer1Rotation);
+ DrawPotentiometer(ctx, _image2, _rect2, pots.Potentiometer2Rotation);
+ }
+ else
+ {
+ DrawPotentiometer(ctx, _image2, _rect2, pots.Potentiometer2Rotation);
+ DrawPotentiometer(ctx, _image1, _rect1, pots.Potentiometer1Rotation);
+ }
+ ctx.Close();
+ DrawGroup(drawingContext, _group);
+ }
+
+ private void DrawPotentiometer(DrawingContext ctx, ImageSource image, Rect rect, double rotation)
+ {
+ if (image == null)
+ {
+ return;
+ }
+ ctx.PushTransform(new RotateTransform(rotation, _center.X, _center.Y));
+ ctx.DrawImage(image, rect);
+ ctx.Pop();
+ }
+
+ protected override void OnRefresh()
+ {
+ if (!(Visual is DualPotentiometer pots))
+ {
+ _image1 = null;
+ _image2 = null;
+ return;
+ }
+
+ IImageManager3 imageManager = ConfigManager.ImageManager as IImageManager3;
+ LoadImageOptions loadOptions = pots.ImageRefresh ? LoadImageOptions.ReloadIfChangedExternally : LoadImageOptions.None;
+
+ _center = new Point(pots.Width / 2d, pots.Height / 2d);
+ _rect1 = CenteredRect(pots, pots.Potentiometer1Scale);
+ _rect2 = CenteredRect(pots, pots.Potentiometer2Scale);
+
+ _image1 = string.IsNullOrEmpty(pots.Potentiometer1Image) ? null : imageManager?.LoadImage(pots.Potentiometer1Image, loadOptions);
+ _image2 = string.IsNullOrEmpty(pots.Potentiometer2Image) ? null : imageManager?.LoadImage(pots.Potentiometer2Image, loadOptions);
+ }
+
+ private static Rect CenteredRect(DualPotentiometer pots, double scale)
+ {
+ scale = double.IsNaN(scale) || scale < 0d ? 0d : scale;
+ double width = pots.Width * scale;
+ double height = pots.Height * scale;
+ return new Rect((pots.Width - width) / 2d, (pots.Height - height) / 2d, width, height);
+ }
+ }
+}
diff --git a/Helios/Templates/Base/Text Displays/Multiline Text Panel.htpl b/Helios/Templates/Base/Text Displays/Multiline Text Panel.htpl
new file mode 100644
index 000000000..0820194c8
--- /dev/null
+++ b/Helios/Templates/Base/Text Displays/Multiline Text Panel.htpl
@@ -0,0 +1,32 @@
+
+Multiline Text Panel
+Text Displays
+Helios.Base.TextDisplay
+
+
+
+ Helios Virtual Cockpit LED Dots 5x7 Regular
+ Normal
+ Normal
+ 16
+ Left
+ Center
+
+ 0.015
+ 0
+ 0
+ 0
+
+
+ #FF4DF04D
+ #FF000000
+ UHF 305.00 STPT 1 ¶¶ VHF 127.50 10!23!45 ¶¶ M 34 4321 T 75X
+ ¶=
;!=:
+ True
+ True
+ Height
+ 0,0
+ 317,108
+ False
+
+
diff --git a/InterfaceFiles/Interfaces b/InterfaceFiles/Interfaces
index 32d26e9bf..08dbaf305 160000
--- a/InterfaceFiles/Interfaces
+++ b/InterfaceFiles/Interfaces
@@ -1 +1 @@
-Subproject commit 32d26e9bf846881fa696cf802e789dab0d5f27d7
+Subproject commit 08dbaf305c68684ecb1bc4b55ed3f4f59ab078b8