diff --git a/components/RadialGauge/OpenSolution.bat b/components/RadialGauge/OpenSolution.bat
new file mode 100644
index 00000000..814a56d4
--- /dev/null
+++ b/components/RadialGauge/OpenSolution.bat
@@ -0,0 +1,3 @@
+@ECHO OFF
+
+powershell ..\..\tooling\ProjectHeads\GenerateSingleSampleHeads.ps1 -componentPath %CD% %*
\ No newline at end of file
diff --git a/components/RadialGauge/samples/Dependencies.props b/components/RadialGauge/samples/Dependencies.props
new file mode 100644
index 00000000..e622e1df
--- /dev/null
+++ b/components/RadialGauge/samples/Dependencies.props
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/RadialGauge/samples/RadialGauge.Samples.csproj b/components/RadialGauge/samples/RadialGauge.Samples.csproj
new file mode 100644
index 00000000..7ee8a5b1
--- /dev/null
+++ b/components/RadialGauge/samples/RadialGauge.Samples.csproj
@@ -0,0 +1,8 @@
+
+
+ RadialGauge
+
+
+
+
+
diff --git a/components/RadialGauge/samples/RadialGauge.md b/components/RadialGauge/samples/RadialGauge.md
new file mode 100644
index 00000000..c3babcbe
--- /dev/null
+++ b/components/RadialGauge/samples/RadialGauge.md
@@ -0,0 +1,58 @@
+---
+title: RadialGauge
+author: xamlbrewer
+description: The Radial Gauge Control displays a value in a certain range using a needle on a circular face.
+keywords: RadialGauge, Control, Input
+dev_langs:
+ - csharp
+category: Controls
+subcategory: Input
+discussion-id: 0
+issue-id: 0
+---
+
+# RadialGauge
+
+The [Radial Gauge](/dotnet/api/microsoft.toolkit.uwp.ui.controls.radialgauge) control displays a value in a certain range using a needle on a circular face. This control will make data visualizations and dashboards more engaging with rich style and interactivity.
+The round gauges are powerful, easy to use, and highly configurable to present dashboards capable of displaying clocks, industrial panels, automotive dashboards, and even aircraft cockpits.
+
+The Radial Gauge supports animated transitions between configuration states. The control gradually animates as it redraws changes to the needle, needle position, scale range, color range, and more.
+
+> [!Sample RadialGaugeSample]
+
+## Properties
+
+| Property | Type | Description |
+| -- | -- | -- |
+| Column | double | Gets or sets the column of the scale |
+| IsInteractive | bool | Gets or sets a value indicating whether the control accepts setting its value through interaction |
+| MaxAngle | int | Gets or sets the end angle of the scale, which corresponds with the Maximum value, in degrees |
+| Maximum | double | Gets or sets the maximum value of the scale |
+| MinAngle | int | Gets or sets the start angle of the scale, which corresponds with the Minimum value, in degrees |
+| Minimum | double | Gets or sets the minimum value of the scale |
+| NeedleBrush | SolidColorBrush | Gets or sets the needle background |
+| NeedleBorderBrush | SolidColorBrush | Gets or sets the needle border |
+| NeedleBorderThickness | double | Gets or sets the thickness of the border |
+| NeedleLength | double | Gets or sets the needle length, in percentage of the gauge radius |
+| NeedleWidth | double | Gets or sets the needle width, in percentage of the gauge radius |
+| NormalizedMaxAngle | double | Gets the normalized maximum angle |
+| NormalizedMinAngle | double | Gets the normalized minimum angle |
+| ScaleBrush | Brush | Gets or sets the scale brush |
+| ScalePadding | double | Gets or sets the distance of the scale from the outside of the control, in percentage of the gauge radius |
+| ScaleTickCornerRadius | double | Gets or sets the cornerradius of the scale tick
+| ScaleTickBrush | SolidColorBrush | Gets or sets the scale tick brush |
+| ScaleTickLength | double | Gets or sets the length of the scaleticks, in percentage of the gauge radius |
+| ScaleTickWidth | double | Gets or sets the width of the scale ticks, in percentage of the gauge radius |
+| ScaleWidth | double | Gets or sets the width of the scale, in percentage of the gauge radius |
+| StepSize | double | Gets or sets the rounding interval for the Value |
+| TickBrush | SolidColorBrush | Gets or sets the outer tick brush |
+| TickCornerRadius | double | Gets or sets the cornerradius of the tick |
+| TickLength | double | Gets or sets the length of the ticks, in percentage of the gauge radius |
+| TickPadding | double | Gets or sets the distance of the ticks from the outside of the control, in percentage of the gauge radius |
+| TickSpacing | int | Gets or sets the tick spacing, in units |
+| TickWidth | double | Gets or sets the width of the ticks, in percentage of the gauge radius |
+| TrailBrush | Brush | Gets or sets the trail brush |
+| Unit | string | Gets or sets the displayed unit measure |
+| Value | double | Gets or sets the current value |
+| ValueAngle | double | Gets or sets the current angle of the needle (between MinAngle and MaxAngle). Setting the angle will update the Value |
+| ValueStringFormat | string | Gets or sets the value string format |
diff --git a/components/RadialGauge/samples/RadialGaugeSample.xaml b/components/RadialGauge/samples/RadialGaugeSample.xaml
new file mode 100644
index 00000000..579bbeca
--- /dev/null
+++ b/components/RadialGauge/samples/RadialGaugeSample.xaml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
diff --git a/components/RadialGauge/samples/RadialGaugeSample.xaml.cs b/components/RadialGauge/samples/RadialGaugeSample.xaml.cs
new file mode 100644
index 00000000..d73d2def
--- /dev/null
+++ b/components/RadialGauge/samples/RadialGaugeSample.xaml.cs
@@ -0,0 +1,35 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using CommunityToolkit.WinUI.Controls;
+
+namespace RadialGaugeExperiment.Samples;
+
+///
+/// An example sample page of a custom control inheriting from Panel.
+///
+[ToolkitSampleBoolOption("Enabled", true, Title = "IsEnabled")]
+[ToolkitSampleNumericOption("Value", 120, 0, 240, 1, false, Title = "Value")]
+[ToolkitSampleNumericOption("StepSize", 30, 5, 30, 1, false, Title = "StepSize")]
+[ToolkitSampleBoolOption("IsInteractive", true, Title = "IsInteractive")]
+[ToolkitSampleNumericOption("TickSpacing", 15, 10, 30, 1, false, Title = "TickSpacing")]
+[ToolkitSampleNumericOption("ScaleWidth", 12, 4, 50, 1, false, Title = "ScaleWidth")]
+[ToolkitSampleNumericOption("MinAngle", -150, -150, 360, 1, false, Title = "MinAngle")]
+[ToolkitSampleNumericOption("MaxAngle", 150, 0, 360, 1, false, Title = "MaxAngle")]
+[ToolkitSampleNumericOption("NeedleWidth", 4, 0, 10, 1, false, Title = "NeedleWidth")]
+[ToolkitSampleNumericOption("NeedleLength", 60, 0, 100, 1, false, Title = "NeedleLength")]
+[ToolkitSampleNumericOption("TickLength", 6, 0, 30, 1, false, Title = "TickLength")]
+[ToolkitSampleNumericOption("TickWidth", 2, 0, 30, 1, false, Title = "TickWidth")]
+[ToolkitSampleNumericOption("ScalePadding", 0, 0, 100, 1, false, Title = "ScalePadding")]
+[ToolkitSampleNumericOption("TickPadding", 24, 0, 100, 1, false, Title = "TickPadding")]
+[ToolkitSampleNumericOption("ScaleTickWidth", 0, 0, 20, 1, false, Title = "ScaleTickWidth")]
+
+[ToolkitSample(id: nameof(RadialGaugeSample), "RadialGauge", description: $"A sample for showing how to create and use a {nameof(RadialGauge)} control.")]
+public sealed partial class RadialGaugeSample : Page
+{
+ public RadialGaugeSample()
+ {
+ this.InitializeComponent();
+ }
+}
diff --git a/components/RadialGauge/src/AdditionalAssemblyInfo.cs b/components/RadialGauge/src/AdditionalAssemblyInfo.cs
new file mode 100644
index 00000000..343502a1
--- /dev/null
+++ b/components/RadialGauge/src/AdditionalAssemblyInfo.cs
@@ -0,0 +1,13 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Runtime.CompilerServices;
+
+// These `InternalsVisibleTo` calls are intended to make it easier for
+// for any internal code to be testable in all the different test projects
+// used with the Labs infrastructure.
+[assembly: InternalsVisibleTo("RadialGauge.Tests.Uwp")]
+[assembly: InternalsVisibleTo("RadialGauge.Tests.WinAppSdk")]
+[assembly: InternalsVisibleTo("CommunityToolkit.Tests.Uwp")]
+[assembly: InternalsVisibleTo("CommunityToolkit.Tests.WinAppSdk")]
diff --git a/components/RadialGauge/src/CommunityToolkit.WinUI.Controls.RadialGauge.csproj b/components/RadialGauge/src/CommunityToolkit.WinUI.Controls.RadialGauge.csproj
new file mode 100644
index 00000000..c2745c5c
--- /dev/null
+++ b/components/RadialGauge/src/CommunityToolkit.WinUI.Controls.RadialGauge.csproj
@@ -0,0 +1,22 @@
+
+
+ RadialGauge
+ This package contains RadialGauge.
+ 0.0.1
+
+
+ CommunityToolkit.WinUI.Controls.RadialGaugeRns
+
+
+
+
+
+
+
+
+
+
+
+ $(PackageIdPrefix).$(PackageIdVariant).Controls.$(ToolkitComponentName)
+
+
diff --git a/components/RadialGauge/src/Dependencies.props b/components/RadialGauge/src/Dependencies.props
new file mode 100644
index 00000000..e622e1df
--- /dev/null
+++ b/components/RadialGauge/src/Dependencies.props
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/RadialGauge/src/MultiTarget.props b/components/RadialGauge/src/MultiTarget.props
new file mode 100644
index 00000000..18f6c7c9
--- /dev/null
+++ b/components/RadialGauge/src/MultiTarget.props
@@ -0,0 +1,9 @@
+
+
+
+ uwp;wasdk;
+
+
diff --git a/components/RadialGauge/src/RadialGauge.Input.cs b/components/RadialGauge/src/RadialGauge.Input.cs
new file mode 100644
index 00000000..f7873b47
--- /dev/null
+++ b/components/RadialGauge/src/RadialGauge.Input.cs
@@ -0,0 +1,119 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using VirtualKey = Windows.System.VirtualKey;
+using VirtualKeyModifiers = Windows.System.VirtualKeyModifiers;
+
+namespace CommunityToolkit.WinUI.Controls;
+public partial class RadialGauge : RangeBase
+{
+ private void RadialGauge_ManipulationDelta(object sender, ManipulationDeltaRoutedEventArgs e)
+ {
+ SetGaugeValueFromPoint(e.Position);
+ }
+
+ private void RadialGauge_Tapped(object sender, TappedRoutedEventArgs e)
+ {
+ SetGaugeValueFromPoint(e.GetPosition(this));
+ }
+
+ private void RadialGauge_PointerReleased(object sender, PointerRoutedEventArgs e)
+ {
+ if (IsInteractive)
+ {
+ e.Handled = true;
+ }
+ }
+
+ private void SetKeyboardAccelerators()
+ {
+ // Small step
+ AddKeyboardAccelerator(VirtualKeyModifiers.None, VirtualKey.Left, static (_, kaea) =>
+ {
+ if (kaea.Element is RadialGauge gauge)
+ {
+ gauge.Value = Math.Max(gauge.Minimum, gauge.Value - Math.Max(gauge.StepSize, gauge.SmallChange));
+ kaea.Handled = true;
+ }
+ });
+
+ AddKeyboardAccelerator(VirtualKeyModifiers.None, VirtualKey.Up, static (_, kaea) =>
+ {
+ if (kaea.Element is RadialGauge gauge)
+ {
+ gauge.Value = Math.Min(gauge.Maximum, gauge.Value + Math.Max(gauge.StepSize, gauge.SmallChange));
+ kaea.Handled = true;
+ }
+ });
+
+ AddKeyboardAccelerator(VirtualKeyModifiers.None, VirtualKey.Right, static (_, kaea) =>
+ {
+ if (kaea.Element is RadialGauge gauge)
+ {
+ gauge.Value = Math.Min(gauge.Maximum, gauge.Value + Math.Max(gauge.StepSize, gauge.SmallChange));
+ kaea.Handled = true;
+ }
+ });
+
+ AddKeyboardAccelerator(VirtualKeyModifiers.None, VirtualKey.Down, static (_, kaea) =>
+ {
+ if (kaea.Element is RadialGauge gauge)
+ {
+ gauge.Value = Math.Max(gauge.Minimum, gauge.Value - Math.Max(gauge.StepSize, gauge.SmallChange));
+ kaea.Handled = true;
+ }
+ });
+
+ // Large step
+ AddKeyboardAccelerator(VirtualKeyModifiers.Control, VirtualKey.Left, static (_, kaea) =>
+ {
+ if (kaea.Element is RadialGauge gauge)
+ {
+ gauge.Value = Math.Max(gauge.Minimum, gauge.Value - Math.Max(gauge.StepSize, gauge.LargeChange));
+ kaea.Handled = true;
+ }
+ });
+
+ AddKeyboardAccelerator(VirtualKeyModifiers.Control, VirtualKey.Up, static (_, kaea) =>
+ {
+ if (kaea.Element is RadialGauge gauge)
+ {
+ gauge.Value = Math.Min(gauge.Maximum, gauge.Value + Math.Max(gauge.StepSize, gauge.LargeChange));
+ kaea.Handled = true;
+ }
+ });
+
+ AddKeyboardAccelerator(VirtualKeyModifiers.Control, VirtualKey.Right, static (_, kaea) =>
+ {
+ if (kaea.Element is RadialGauge gauge)
+ {
+ gauge.Value = Math.Min(gauge.Maximum, gauge.Value + Math.Max(gauge.StepSize, gauge.LargeChange));
+ kaea.Handled = true;
+ }
+ });
+
+ AddKeyboardAccelerator(VirtualKeyModifiers.Control, VirtualKey.Down, static (_, kaea) =>
+ {
+ if (kaea.Element is RadialGauge gauge)
+ {
+ gauge.Value = Math.Max(gauge.Minimum, gauge.Value - Math.Max(gauge.StepSize, gauge.LargeChange));
+ kaea.Handled = true;
+ }
+ });
+ }
+
+ private void AddKeyboardAccelerator(
+ VirtualKeyModifiers keyModifiers,
+ VirtualKey key,
+ TypedEventHandler handler)
+ {
+ var accelerator = new KeyboardAccelerator()
+ {
+ Modifiers = keyModifiers,
+ Key = key
+ };
+ accelerator.Invoked += handler;
+ KeyboardAccelerators.Add(accelerator);
+ }
+}
diff --git a/components/RadialGauge/src/RadialGauge.Properties.cs b/components/RadialGauge/src/RadialGauge.Properties.cs
new file mode 100644
index 00000000..f274cfe0
--- /dev/null
+++ b/components/RadialGauge/src/RadialGauge.Properties.cs
@@ -0,0 +1,421 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using CommunityToolkit.WinUI.Helpers;
+
+namespace CommunityToolkit.WinUI.Controls;
+public partial class RadialGauge : RangeBase
+{
+ ///
+ /// Identifies the property.
+ ///
+ public static readonly DependencyProperty IsInteractiveProperty =
+ DependencyProperty.Register(nameof(IsInteractive), typeof(bool), typeof(RadialGauge), new PropertyMetadata(true, OnInteractivityChanged));
+
+ ///
+ /// Identifies the ScaleWidth dependency property.
+ ///
+ public static readonly DependencyProperty ScaleWidthProperty =
+ DependencyProperty.Register(nameof(ScaleWidth), typeof(double), typeof(RadialGauge), new PropertyMetadata(12.0, OnScaleChanged));
+
+ ///
+ /// Identifies the optional StepSize property.
+ ///
+ public static readonly DependencyProperty StepSizeProperty =
+ DependencyProperty.Register(nameof(StepSize), typeof(double), typeof(RadialGauge), new PropertyMetadata(0.0));
+
+ ///
+ /// Identifies the NeedleBrush dependency property.
+ ///
+ public static readonly DependencyProperty NeedleBrushProperty =
+ DependencyProperty.Register(nameof(NeedleBrush), typeof(SolidColorBrush), typeof(RadialGauge), new PropertyMetadata(null, OnFaceChanged));
+
+ ///
+ /// Identifies the NeedleBrush dependency property.
+ ///
+ public static readonly DependencyProperty NeedleBorderBrushProperty =
+ DependencyProperty.Register(nameof(NeedleBorderBrush), typeof(SolidColorBrush), typeof(RadialGauge), new PropertyMetadata(null, OnFaceChanged));
+
+ ///
+ /// Identifies the Unit dependency property.
+ ///
+ public static readonly DependencyProperty UnitProperty =
+ DependencyProperty.Register(nameof(Unit), typeof(string), typeof(RadialGauge), new PropertyMetadata(string.Empty, OnUnitChanged));
+
+ ///
+ /// Identifies the TrailBrush dependency property.
+ ///
+ public static readonly DependencyProperty TrailBrushProperty =
+ DependencyProperty.Register(nameof(TrailBrush), typeof(Brush), typeof(RadialGauge), new PropertyMetadata(null));
+
+ ///
+ /// Identifies the ScaleBrush dependency property.
+ ///
+ public static readonly DependencyProperty ScaleBrushProperty =
+ DependencyProperty.Register(nameof(ScaleBrush), typeof(Brush), typeof(RadialGauge), new PropertyMetadata(null));
+
+ ///
+ /// Identifies the ScaleTickBrush dependency property.
+ ///
+ public static readonly DependencyProperty ScaleTickBrushProperty =
+ DependencyProperty.Register(nameof(ScaleTickBrush), typeof(Brush), typeof(RadialGauge), new PropertyMetadata(null, OnFaceChanged));
+
+ ///
+ /// Identifies the TickBrush dependency property.
+ ///
+ public static readonly DependencyProperty TickBrushProperty =
+ DependencyProperty.Register(nameof(TickBrush), typeof(SolidColorBrush), typeof(RadialGauge), new PropertyMetadata(null, OnFaceChanged));
+
+ ///
+ /// Identifies the ValueStringFormat dependency property.
+ ///
+ public static readonly DependencyProperty ValueStringFormatProperty =
+ DependencyProperty.Register(nameof(ValueStringFormat), typeof(string), typeof(RadialGauge), new PropertyMetadata("N0", (s, e) => OnValueChanged(s)));
+
+ ///
+ /// Identifies the NeedleLength dependency property.
+ ///
+ public static readonly DependencyProperty NeedleLengthProperty =
+ DependencyProperty.Register(nameof(NeedleLength), typeof(double), typeof(RadialGauge), new PropertyMetadata(58d, OnFaceChanged));
+
+ ///
+ /// Identifies the NeedleLength dependency property.
+ ///
+ public static readonly DependencyProperty NeedleBorderThicknessProperty =
+ DependencyProperty.Register(nameof(NeedleBorderThickness), typeof(double), typeof(RadialGauge), new PropertyMetadata(1d, OnFaceChanged));
+
+ ///
+ /// Identifies the NeedleWidth dependency property.
+ ///
+ public static readonly DependencyProperty NeedleWidthProperty =
+ DependencyProperty.Register(nameof(NeedleWidth), typeof(double), typeof(RadialGauge), new PropertyMetadata(5d, OnFaceChanged));
+
+ ///
+ /// Identifies the ScalePadding dependency property.
+ ///
+ public static readonly DependencyProperty ScalePaddingProperty =
+ DependencyProperty.Register(nameof(ScalePadding), typeof(double), typeof(RadialGauge), new PropertyMetadata(0d, OnFaceChanged));
+
+ ///
+ /// Identifies the ScaleTickWidth dependency property.
+ ///
+ public static readonly DependencyProperty ScaleTickWidthProperty =
+ DependencyProperty.Register(nameof(ScaleTickWidth), typeof(double), typeof(RadialGauge), new PropertyMetadata(0d, OnFaceChanged));
+
+ ///
+ /// Identifies the ScaleTickWidth dependency property.
+ ///
+ public static readonly DependencyProperty ScaleTickLengthProperty =
+ DependencyProperty.Register(nameof(ScaleTickLength), typeof(double), typeof(RadialGauge), new PropertyMetadata(12d, OnFaceChanged));
+
+
+ ///
+ /// Identifies the ScaleTickWidth dependency property.
+ ///
+ public static readonly DependencyProperty ScaleTickCornerRadiusProperty =
+ DependencyProperty.Register(nameof(ScaleTickCornerRadius), typeof(double), typeof(RadialGauge), new PropertyMetadata(2d, OnFaceChanged));
+
+ ///
+ /// Identifies the TickSpacing dependency property.
+ ///
+ public static readonly DependencyProperty TickSpacingProperty =
+ DependencyProperty.Register(nameof(TickSpacing), typeof(int), typeof(RadialGauge), new PropertyMetadata(15, OnFaceChanged));
+
+ ///
+ /// Identifies the TickWidth dependency property.
+ ///
+ public static readonly DependencyProperty TickWidthProperty =
+ DependencyProperty.Register(nameof(TickWidth), typeof(double), typeof(RadialGauge), new PropertyMetadata(2d, OnFaceChanged));
+
+ ///
+ /// Identifies the TickLength dependency property.
+ ///
+ public static readonly DependencyProperty TickLengthProperty =
+ DependencyProperty.Register(nameof(TickLength), typeof(double), typeof(RadialGauge), new PropertyMetadata(6d, OnFaceChanged));
+
+ ///
+ /// Identifies the TickPadding dependency property.
+ ///
+ public static readonly DependencyProperty TickPaddingProperty =
+ DependencyProperty.Register(nameof(TickPadding), typeof(double), typeof(RadialGauge), new PropertyMetadata(24d, OnFaceChanged));
+
+ ///
+ /// Identifies the TickCornerRadius dependency property.
+ ///
+ public static readonly DependencyProperty TickCornerRadiusProperty =
+ DependencyProperty.Register(nameof(TickCornerRadius), typeof(double), typeof(RadialGauge), new PropertyMetadata(2d, OnFaceChanged));
+
+ ///
+ /// Identifies the MinAngle dependency property.
+ ///
+ public static readonly DependencyProperty MinAngleProperty =
+ DependencyProperty.Register(nameof(MinAngle), typeof(int), typeof(RadialGauge), new PropertyMetadata(-150, OnScaleChanged));
+
+ ///
+ /// Identifies the MaxAngle dependency property.
+ ///
+ public static readonly DependencyProperty MaxAngleProperty =
+ DependencyProperty.Register(nameof(MaxAngle), typeof(int), typeof(RadialGauge), new PropertyMetadata(150, OnScaleChanged));
+
+ ///
+ /// Identifies the ValueAngle dependency property.
+ ///
+ protected static readonly DependencyProperty ValueAngleProperty =
+ DependencyProperty.Register(nameof(ValueAngle), typeof(double), typeof(RadialGauge), new PropertyMetadata(null));
+
+ ///
+ /// Gets or sets the rounding interval for the Value.
+ ///
+ public double StepSize
+ {
+ get { return (double)GetValue(StepSizeProperty); }
+ set { SetValue(StepSizeProperty, value); }
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the control accepts setting its value through interaction.
+ ///
+ public bool IsInteractive
+ {
+ get { return (bool)GetValue(IsInteractiveProperty); }
+ set { SetValue(IsInteractiveProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the width of the scale, in percentage of the gauge radius.
+ ///
+ public double ScaleWidth
+ {
+ get { return (double)GetValue(ScaleWidthProperty); }
+ set { SetValue(ScaleWidthProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the displayed unit measure.
+ ///
+ public string Unit
+ {
+ get { return (string)GetValue(UnitProperty); }
+ set { SetValue(UnitProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the needle brush.
+ ///
+ public SolidColorBrush NeedleBrush
+ {
+ get { return (SolidColorBrush)GetValue(NeedleBrushProperty); }
+ set { SetValue(NeedleBrushProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the needle border brush.
+ ///
+ public SolidColorBrush NeedleBorderBrush
+ {
+ get { return (SolidColorBrush)GetValue(NeedleBorderBrushProperty); }
+ set { SetValue(NeedleBorderBrushProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the trail brush.
+ ///
+ public Brush TrailBrush
+ {
+ get { return (Brush)GetValue(TrailBrushProperty); }
+ set { SetValue(TrailBrushProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the scale brush.
+ ///
+ public Brush ScaleBrush
+ {
+ get { return (Brush)GetValue(ScaleBrushProperty); }
+ set { SetValue(ScaleBrushProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the scale tick brush.
+ ///
+ public SolidColorBrush ScaleTickBrush
+ {
+ get { return (SolidColorBrush)GetValue(ScaleTickBrushProperty); }
+ set { SetValue(ScaleTickBrushProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the scale tick cornerradius.
+ ///
+ public double ScaleTickCornerRadius
+ {
+ get { return (double)GetValue(ScaleTickCornerRadiusProperty); }
+ set { SetValue(ScaleTickCornerRadiusProperty, value); }
+ }
+
+
+ ///
+ /// Gets or sets the outer tick brush.
+ ///
+ public SolidColorBrush TickBrush
+ {
+ get { return (SolidColorBrush)GetValue(TickBrushProperty); }
+ set { SetValue(TickBrushProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the value string format.
+ ///
+ public string ValueStringFormat
+ {
+ get { return (string)GetValue(ValueStringFormatProperty); }
+ set { SetValue(ValueStringFormatProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the tick spacing, in units. Values of zero or less will be ignored when drawing.
+ ///
+ public int TickSpacing
+ {
+ get { return (int)GetValue(TickSpacingProperty); }
+ set { SetValue(TickSpacingProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the needle length, in percentage of the gauge radius.
+ ///
+ public double NeedleLength
+ {
+ get { return (double)GetValue(NeedleLengthProperty); }
+ set { SetValue(NeedleLengthProperty, value); }
+ }
+
+
+ ///
+ /// Gets or sets the needle length, in percentage of the gauge radius.
+ ///
+ public double NeedleBorderThickness
+ {
+ get { return (double)GetValue(NeedleBorderThicknessProperty); }
+ set { SetValue(NeedleBorderThicknessProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the needle width, in percentage of the gauge radius.
+ ///
+ public double NeedleWidth
+ {
+ get { return (double)GetValue(NeedleWidthProperty); }
+ set { SetValue(NeedleWidthProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the distance of the scale from the outside of the control, in percentage of the gauge radius.
+ ///
+ public double ScalePadding
+ {
+ get { return (double)GetValue(ScalePaddingProperty); }
+ set { SetValue(ScalePaddingProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the distance of the ticks from the outside of the control, in percentage of the gauge radius.
+ ///
+ public double TickPadding
+ {
+ get { return (double)GetValue(TickPaddingProperty); }
+ set { SetValue(TickPaddingProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the width of the scale ticks, in percentage of the gauge radius.
+ ///
+ public double ScaleTickWidth
+ {
+ get { return (double)GetValue(ScaleTickWidthProperty); }
+ set { SetValue(ScaleTickWidthProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the length of the ticks, in percentage of the gauge radius.
+ ///
+ public double ScaleTickLength
+ {
+ get { return (double)GetValue(ScaleTickLengthProperty); }
+ set { SetValue(ScaleTickLengthProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the length of the ticks, in percentage of the gauge radius.
+ ///
+ public double TickLength
+ {
+ get { return (double)GetValue(TickLengthProperty); }
+ set { SetValue(TickLengthProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the width of the ticks, in percentage of the gauge radius.
+ ///
+ public double TickWidth
+ {
+ get { return (double)GetValue(TickWidthProperty); }
+ set { SetValue(TickWidthProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the CornerRadius of the ticks, in percentage of the gauge radius.
+ ///
+ public double TickCornerRadius
+ {
+ get { return (double)GetValue(TickCornerRadiusProperty); }
+ set { SetValue(TickCornerRadiusProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the start angle of the scale, which corresponds with the Minimum value, in degrees.
+ ///
+ /// Changing MinAngle may require retemplating the control.
+ public int MinAngle
+ {
+ get { return (int)GetValue(MinAngleProperty); }
+ set { SetValue(MinAngleProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the end angle of the scale, which corresponds with the Maximum value, in degrees.
+ ///
+ /// Changing MaxAngle may require retemplating the control.
+ public int MaxAngle
+ {
+ get { return (int)GetValue(MaxAngleProperty); }
+ set { SetValue(MaxAngleProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the current angle of the needle (between MinAngle and MaxAngle). Setting the angle will update the Value.
+ ///
+ protected double ValueAngle
+ {
+ get { return (double)GetValue(ValueAngleProperty); }
+ set { SetValue(ValueAngleProperty, value); }
+ }
+
+ private static void OnUnitChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ OnUnitChanged(d);
+ }
+ private static void OnScaleChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ OnScaleChanged(d);
+ }
+
+ private static void OnFaceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (!DesignTimeHelpers.IsRunningInLegacyDesignerMode)
+ {
+ OnFaceChanged(d);
+ }
+ }
+}
diff --git a/components/RadialGauge/src/RadialGauge.cs b/components/RadialGauge/src/RadialGauge.cs
new file mode 100644
index 00000000..abb84889
--- /dev/null
+++ b/components/RadialGauge/src/RadialGauge.cs
@@ -0,0 +1,572 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using CommunityToolkit.WinUI.Helpers;
+using System.Numerics;
+#if WINAPPSDK
+using Path = Microsoft.UI.Xaml.Shapes.Path;
+using Microsoft.UI.Xaml.Hosting;
+using Microsoft.UI.Composition;
+#else
+using Path = Windows.UI.Xaml.Shapes.Path;
+using Windows.UI.Xaml.Hosting;
+using Windows.UI.Composition;
+#endif
+
+namespace CommunityToolkit.WinUI.Controls;
+///
+/// A Modern UI Radial Gauge using XAML and Composition API.
+/// The scale of the gauge is a clockwise arc that sweeps from MinAngle (default lower left, at -150°) to MaxAngle (default lower right, at +150°).
+///
+//// All calculations are for a 200x200 square. The viewbox will do the rest.
+[TemplatePart(Name = ContainerPartName, Type = typeof(Grid))]
+[TemplatePart(Name = ScalePartName, Type = typeof(Path))]
+[TemplatePart(Name = TrailPartName, Type = typeof(Path))]
+[TemplatePart(Name = ValueTextPartName, Type = typeof(TextBlock))]
+[TemplateVisualState(Name = NormalState, GroupName = CommonStates)]
+[TemplateVisualState(Name = DisabledState, GroupName = CommonStates)]
+public partial class RadialGauge : RangeBase
+{
+ // States
+ private const string NormalState = "Normal";
+ private const string DisabledState = "Disabled";
+ private const string CommonStates = "CommonStates";
+
+ // Template Parts.
+ private const string ContainerPartName = "PART_Container";
+ private const string ScalePartName = "PART_Scale";
+ private const string TrailPartName = "PART_Trail";
+ private const string ValueTextPartName = "PART_ValueText";
+ private const string UnitTextPartName = "PART_UnitText";
+
+ // For convenience.
+ private const double Degrees2Radians = Math.PI / 180;
+
+ // High-contrast accessibility
+ private static readonly ThemeListener ThemeListener = new ThemeListener();
+ private SolidColorBrush? _needleBrush;
+ private SolidColorBrush? _needleBorderBrush;
+ private Brush? _trailBrush;
+ private Brush? _scaleBrush;
+ private SolidColorBrush? _scaleTickBrush;
+ private SolidColorBrush? _tickBrush;
+ private Brush? _foreground;
+
+ private double _normalizedMinAngle;
+ private double _normalizedMaxAngle;
+
+ private Compositor? _compositor;
+ private ContainerVisual? _root;
+ private CompositionSpriteShape? _needle;
+
+ ///
+ /// Initializes a new instance of the class.
+ /// Create a default radial gauge control.
+ ///
+ public RadialGauge()
+ {
+ DefaultStyleKey = typeof(RadialGauge);
+
+ SmallChange = 1;
+ LargeChange = 10;
+
+ SetKeyboardAccelerators();
+ }
+
+ private void RadialGauge_Unloaded(object sender, RoutedEventArgs e)
+ {
+ // TODO: We should just use a WeakEventListener for ThemeChanged here, but ours currently doesn't support it.
+ // See proposal for general helper here: https://github.com/CommunityToolkit/dotnet/issues/404
+ ThemeListener.ThemeChanged -= ThemeListener_ThemeChanged;
+ PointerReleased -= RadialGauge_PointerReleased;
+ IsEnabledChanged -= RadialGauge_IsEnabledChanged;
+ Unloaded -= RadialGauge_Unloaded;
+ }
+
+ ///
+ /// Update the visual state of the control when its template is changed.
+ ///
+ protected override void OnApplyTemplate()
+ {
+ PointerReleased -= RadialGauge_PointerReleased;
+ ThemeListener.ThemeChanged -= ThemeListener_ThemeChanged;
+ IsEnabledChanged -= RadialGauge_IsEnabledChanged;
+ Unloaded -= RadialGauge_Unloaded;
+
+ // Remember local brushes.
+ _needleBrush = ReadLocalValue(NeedleBrushProperty) as SolidColorBrush;
+ _needleBorderBrush = ReadLocalValue(NeedleBorderBrushProperty) as SolidColorBrush;
+ _trailBrush = ReadLocalValue(TrailBrushProperty) as SolidColorBrush;
+ _scaleBrush = ReadLocalValue(ScaleBrushProperty) as SolidColorBrush;
+ _scaleTickBrush = ReadLocalValue(ScaleTickBrushProperty) as SolidColorBrush;
+ _tickBrush = ReadLocalValue(TickBrushProperty) as SolidColorBrush;
+ _foreground = ReadLocalValue(ForegroundProperty) as SolidColorBrush;
+
+ PointerReleased += RadialGauge_PointerReleased;
+ ThemeListener.ThemeChanged += ThemeListener_ThemeChanged;
+ IsEnabledChanged += RadialGauge_IsEnabledChanged;
+ Unloaded += RadialGauge_Unloaded;
+
+ // Apply color scheme.
+ OnColorsChanged();
+ OnUnitChanged(this);
+ OnEnabledChanged();
+ OnInteractivityChanged(this);
+ base.OnApplyTemplate();
+ }
+
+ private void ThemeListener_ThemeChanged(ThemeListener sender)
+ {
+ OnColorsChanged();
+ }
+
+ private void RadialGauge_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e)
+ {
+ OnEnabledChanged();
+ }
+
+ ///
+ /// Gets the normalized minimum angle.
+ ///
+ /// The minimum angle in the range from -180 to 180.
+ protected double NormalizedMinAngle => _normalizedMinAngle;
+
+ ///
+ /// Gets the normalized maximum angle.
+ ///
+ /// The maximum angle, in the range from -180 to 540.
+ protected double NormalizedMaxAngle => _normalizedMaxAngle;
+
+ ///
+ protected override AutomationPeer OnCreateAutomationPeer()
+ {
+ return new RadialGaugeAutomationPeer(this);
+ }
+
+ ///
+ protected override void OnMinimumChanged(double oldMinimum, double newMinimum)
+ {
+ base.OnMinimumChanged(oldMinimum, newMinimum);
+ OnScaleChanged(this);
+ }
+
+ ///
+ protected override void OnMaximumChanged(double oldMaximum, double newMaximum)
+ {
+ base.OnMaximumChanged(oldMaximum, newMaximum);
+ OnScaleChanged(this);
+ }
+
+ ///
+ protected override void OnValueChanged(double oldValue, double newValue)
+ {
+ OnValueChanged(this);
+ base.OnValueChanged(oldValue, newValue);
+ if (AutomationPeer.ListenerExists(AutomationEvents.LiveRegionChanged))
+ {
+ var peer = FrameworkElementAutomationPeer.FromElement(this) as RadialGaugeAutomationPeer;
+ peer?.RaiseValueChangedEvent(oldValue, newValue);
+ }
+ }
+
+ private static void OnValueChanged(DependencyObject d)
+ {
+ RadialGauge radialGauge = (RadialGauge)d;
+ if (!double.IsNaN(radialGauge.Value))
+ {
+ if (radialGauge.StepSize != 0)
+ {
+ radialGauge.Value = radialGauge.RoundToMultiple(radialGauge.Value, radialGauge.StepSize);
+ }
+
+ var middleOfScale = 100 - radialGauge.ScalePadding - (radialGauge.ScaleWidth / 2);
+ if (middleOfScale >= 0)
+ {
+ var valueText = radialGauge.GetTemplateChild(ValueTextPartName) as TextBlock;
+ radialGauge.ValueAngle = radialGauge.ValueToAngle(radialGauge.Value);
+
+ // Needle
+ if (radialGauge._needle != null)
+ {
+ radialGauge._needle.RotationAngleInDegrees = (float)radialGauge.ValueAngle;
+ }
+
+ // Trail
+ var trail = radialGauge.GetTemplateChild(TrailPartName) as Path;
+ if (trail != null)
+ {
+ if (radialGauge.ValueAngle > radialGauge.NormalizedMinAngle)
+ {
+ trail.Visibility = Visibility.Visible;
+
+ if (radialGauge.ValueAngle - radialGauge.NormalizedMinAngle == 360)
+ {
+ // Draw full circle.
+ var eg = new EllipseGeometry
+ {
+ Center = new Point(100, 100),
+ RadiusX = 100 - radialGauge.ScalePadding - (radialGauge.ScaleWidth / 2)
+ };
+ eg.RadiusY = eg.RadiusX;
+ trail.Data = eg;
+ }
+ else
+ {
+ // Draw arc.
+ var pg = new PathGeometry();
+ var pf = new PathFigure
+ {
+ IsClosed = false,
+ StartPoint = radialGauge.ScalePoint(radialGauge.NormalizedMinAngle, middleOfScale)
+ };
+ var seg = new ArcSegment
+ {
+ SweepDirection = SweepDirection.Clockwise,
+ IsLargeArc = radialGauge.ValueAngle > (180 + radialGauge.NormalizedMinAngle),
+ Size = new Size(middleOfScale, middleOfScale),
+ Point = radialGauge.ScalePoint(Math.Min(radialGauge.ValueAngle, radialGauge.NormalizedMaxAngle), middleOfScale) // On overflow, stop trail at MaxAngle.
+ };
+ pf.Segments.Add(seg);
+ pg.Figures.Add(pf);
+ trail.Data = pg;
+ }
+ }
+ else
+ {
+ trail.Visibility = Visibility.Collapsed;
+ }
+
+ }
+
+ // Value Text
+ if (valueText != null)
+ {
+ valueText.Text = radialGauge.Value.ToString(radialGauge.ValueStringFormat);
+ }
+ }
+ }
+ }
+
+ private static void OnInteractivityChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ OnInteractivityChanged(d);
+ }
+
+ private static void OnInteractivityChanged(DependencyObject d)
+ {
+ RadialGauge radialGauge = (RadialGauge)d;
+
+ if (radialGauge.IsInteractive)
+ {
+ radialGauge.Tapped += radialGauge.RadialGauge_Tapped;
+ radialGauge.ManipulationDelta += radialGauge.RadialGauge_ManipulationDelta;
+ radialGauge.ManipulationMode = ManipulationModes.TranslateX | ManipulationModes.TranslateY;
+ }
+ else
+ {
+ radialGauge.Tapped -= radialGauge.RadialGauge_Tapped;
+ radialGauge.ManipulationDelta -= radialGauge.RadialGauge_ManipulationDelta;
+ radialGauge.ManipulationMode = ManipulationModes.None;
+ }
+ }
+
+ private static void OnScaleChanged(DependencyObject d)
+ {
+ RadialGauge radialGauge = (RadialGauge)d;
+
+ radialGauge.UpdateNormalizedAngles();
+
+ if (radialGauge.GetTemplateChild(ScalePartName) is Path scale)
+ {
+ if (radialGauge.NormalizedMaxAngle - radialGauge.NormalizedMinAngle == 360)
+ {
+ // Draw full circle.
+ var eg = new EllipseGeometry
+ {
+ Center = new Point(100, 100),
+ RadiusX = 100 - radialGauge.ScalePadding - (radialGauge.ScaleWidth / 2)
+ };
+ eg.RadiusY = eg.RadiusX;
+ scale.Data = eg;
+ }
+ else
+ {
+ // Draw arc.
+ var pg = new PathGeometry();
+ var pf = new PathFigure
+ {
+ IsClosed = false
+ };
+ var middleOfScale = 100 - radialGauge.ScalePadding - (radialGauge.ScaleWidth / 2);
+ pf.StartPoint = radialGauge.ScalePoint(radialGauge.NormalizedMinAngle, middleOfScale);
+ var seg = new ArcSegment
+ {
+ SweepDirection = SweepDirection.Clockwise,
+ IsLargeArc = radialGauge.NormalizedMaxAngle > (radialGauge.NormalizedMinAngle + 180),
+ Size = new Size(middleOfScale, middleOfScale),
+ Point = radialGauge.ScalePoint(radialGauge.NormalizedMaxAngle, middleOfScale)
+ };
+ pf.Segments.Add(seg);
+ pg.Figures.Add(pf);
+ scale.Data = pg;
+ }
+
+ if (!DesignTimeHelpers.IsRunningInLegacyDesignerMode)
+ {
+ OnFaceChanged(radialGauge);
+ }
+ }
+ }
+
+ private static void OnFaceChanged(DependencyObject d)
+ {
+ RadialGauge radialGauge = (RadialGauge)d;
+
+ var container = radialGauge.GetTemplateChild(ContainerPartName) as Grid;
+
+ if (container == null || DesignTimeHelpers.IsRunningInLegacyDesignerMode)
+ {
+ // Bad template.
+ return;
+ }
+
+ // TO DO: Replace with _radialGauge._root = container.GetVisual();
+ var hostVisual = ElementCompositionPreview.GetElementVisual(container);
+ var root = hostVisual.Compositor.CreateContainerVisual();
+ ElementCompositionPreview.SetElementChildVisual(container, root);
+ radialGauge._root = root;
+ //
+
+ radialGauge._root.Children.RemoveAll();
+ radialGauge._compositor = radialGauge._root.Compositor;
+
+ if (radialGauge.TickSpacing > 0)
+ {
+ // Ticks.
+ var tick = radialGauge._compositor.CreateShapeVisual();
+ tick.Size = new Vector2((float)(radialGauge.Height), (float)(radialGauge.Width));
+ tick.BorderMode = CompositionBorderMode.Soft;
+ tick.Opacity = (float)radialGauge.TickBrush.Opacity;
+
+ var roundedTickRectangle = radialGauge._compositor.CreateRoundedRectangleGeometry();
+ roundedTickRectangle.Size = new Vector2((float)radialGauge.TickWidth, (float)radialGauge.TickLength);
+ roundedTickRectangle.CornerRadius = new Vector2((float)radialGauge.TickCornerRadius, (float)radialGauge.TickCornerRadius);
+
+ for (double i = radialGauge.Minimum; i <= radialGauge.Maximum; i += radialGauge.TickSpacing)
+ {
+ var tickSpriteShape = radialGauge._compositor.CreateSpriteShape(roundedTickRectangle);
+ tickSpriteShape.FillBrush = radialGauge._compositor.CreateColorBrush(radialGauge.TickBrush.Color);
+ tickSpriteShape.Offset = new Vector2(100 - ((float)radialGauge.TickWidth / 2), (float)radialGauge.TickPadding);
+ tickSpriteShape.CenterPoint = new Vector2((float)radialGauge.TickWidth / 2, 100 - (float)radialGauge.TickPadding);
+ tickSpriteShape.RotationAngleInDegrees = (float)radialGauge.ValueToAngle(i);
+ tick.Shapes.Add(tickSpriteShape);
+ }
+
+ radialGauge._root.Children.InsertAtTop(tick);
+
+ // Scale Ticks.
+ var scaleTick = radialGauge._compositor.CreateShapeVisual();
+ scaleTick.Size = new Vector2((float)(radialGauge.Height), (float)(radialGauge.Width));
+ scaleTick.BorderMode = CompositionBorderMode.Soft;
+ scaleTick.Opacity = (float)radialGauge.ScaleTickBrush.Opacity;
+
+ var roundedScaleTickRectangle = radialGauge._compositor.CreateRoundedRectangleGeometry();
+ roundedScaleTickRectangle.Size = new Vector2((float)radialGauge.ScaleTickWidth, (float)radialGauge.ScaleTickLength);
+ roundedScaleTickRectangle.CornerRadius = new Vector2((float)radialGauge.ScaleTickCornerRadius, (float)radialGauge.ScaleTickCornerRadius);
+
+ for (double i = radialGauge.Minimum; i <= radialGauge.Maximum; i += radialGauge.TickSpacing)
+ {
+ var scaleTickSpriteShape = radialGauge._compositor.CreateSpriteShape(roundedScaleTickRectangle);
+ scaleTickSpriteShape.FillBrush = radialGauge._compositor.CreateColorBrush(radialGauge.ScaleTickBrush.Color);
+ scaleTickSpriteShape.Offset = new Vector2(100 - ((float)radialGauge.ScaleTickWidth / 2), (float)radialGauge.ScalePadding);
+ scaleTickSpriteShape.CenterPoint = new Vector2((float)radialGauge.ScaleTickWidth / 2, 100 - (float)radialGauge.ScalePadding);
+ scaleTickSpriteShape.RotationAngleInDegrees = (float)radialGauge.ValueToAngle(i);
+ scaleTick.Shapes.Add(scaleTickSpriteShape);
+ }
+ radialGauge._root.Children.InsertAtTop(scaleTick);
+ }
+
+ // Needle.
+ var shapeVisual = radialGauge._compositor.CreateShapeVisual();
+ shapeVisual.Size = new Vector2((float)radialGauge.Height, (float)radialGauge.Width);
+ shapeVisual.BorderMode = CompositionBorderMode.Soft;
+ shapeVisual.Opacity = (float)radialGauge.NeedleBrush.Opacity;
+ var roundedNeedleRectangle = radialGauge._compositor.CreateRoundedRectangleGeometry();
+ roundedNeedleRectangle.Size = new Vector2((float)radialGauge.NeedleWidth, (float)radialGauge.NeedleLength);
+ roundedNeedleRectangle.CornerRadius = new Vector2((float)radialGauge.NeedleWidth / 2, (float)radialGauge.NeedleWidth / 2);
+ radialGauge._needle = radialGauge._compositor.CreateSpriteShape(roundedNeedleRectangle);
+ radialGauge._needle.FillBrush = radialGauge._compositor.CreateColorBrush(radialGauge.NeedleBrush.Color);
+ radialGauge._needle.CenterPoint = new Vector2((float)radialGauge.NeedleWidth / 2, (float)radialGauge.NeedleLength);
+ radialGauge._needle.Offset = new Vector2(100 - ((float)radialGauge.NeedleWidth / 2), 100 - (float)radialGauge.NeedleLength);
+ radialGauge._needle.StrokeThickness = (float)radialGauge.NeedleBorderThickness;
+ radialGauge._needle.StrokeBrush = radialGauge._compositor.CreateColorBrush(radialGauge.NeedleBorderBrush.Color);
+ shapeVisual.Shapes.Add(radialGauge._needle);
+
+ radialGauge._root.Children.InsertAtTop(shapeVisual);
+
+ OnValueChanged(radialGauge);
+ }
+
+ private void OnColorsChanged()
+ {
+ if (ThemeListener.IsHighContrast)
+ {
+ // Apply High Contrast Theme.
+ ClearBrush(_needleBrush, NeedleBrushProperty);
+ ClearBrush(_needleBorderBrush, NeedleBorderBrushProperty);
+ ClearBrush(_trailBrush, TrailBrushProperty);
+ ClearBrush(_scaleBrush, ScaleBrushProperty);
+ ClearBrush(_scaleTickBrush, ScaleTickBrushProperty);
+ ClearBrush(_tickBrush, TickBrushProperty);
+ ClearBrush(_foreground, ForegroundProperty);
+ }
+ else
+ {
+ // Apply User Defined or Default Theme.
+ RestoreBrush(_needleBrush, NeedleBrushProperty);
+ RestoreBrush(_needleBorderBrush, NeedleBorderBrushProperty);
+ RestoreBrush(_trailBrush, TrailBrushProperty);
+ RestoreBrush(_scaleBrush, ScaleBrushProperty);
+ RestoreBrush(_scaleTickBrush, ScaleTickBrushProperty);
+ RestoreBrush(_tickBrush, TickBrushProperty);
+ RestoreBrush(_foreground, ForegroundProperty);
+ }
+
+ OnScaleChanged(this);
+ }
+
+ private void OnEnabledChanged()
+ {
+ VisualStateManager.GoToState(this, IsEnabled ? NormalState : DisabledState, true);
+ // OnColorsChanged();
+ }
+
+ private static void OnUnitChanged(DependencyObject d)
+ {
+ RadialGauge radialGauge = (RadialGauge)d;
+ if (radialGauge.GetTemplateChild(UnitTextPartName) is TextBlock unitTextBlock)
+ {
+ if (string.IsNullOrEmpty(radialGauge.Unit))
+ {
+ unitTextBlock.Visibility = Visibility.Collapsed;
+ }
+ else
+ {
+ unitTextBlock.Visibility = Visibility.Visible;
+ }
+ }
+ }
+
+ private void ClearBrush(Brush? brush, DependencyProperty prop)
+ {
+ if (brush != null)
+ {
+ ClearValue(prop);
+ }
+ }
+
+ private void RestoreBrush(Brush? source, DependencyProperty prop)
+ {
+ if (source != null)
+ {
+ SetValue(prop, source);
+ }
+ }
+
+ private void UpdateNormalizedAngles()
+ {
+ var result = Mod(MinAngle, 360);
+
+ if (result >= 180)
+ {
+ result = result - 360;
+ }
+
+ _normalizedMinAngle = result;
+
+ result = Mod(MaxAngle, 360);
+
+ if (result < 180)
+ {
+ result = result + 360;
+ }
+
+ if (result > NormalizedMinAngle + 360)
+ {
+ result = result - 360;
+ }
+
+ _normalizedMaxAngle = result;
+ }
+
+ private void SetGaugeValueFromPoint(Point p)
+ {
+ var pt = new Point(p.X - (ActualWidth / 2), -p.Y + (ActualHeight / 2));
+
+ var angle = Math.Atan2(pt.X, pt.Y) / Degrees2Radians;
+ var divider = Mod(NormalizedMaxAngle - NormalizedMinAngle, 360);
+ if (divider == 0)
+ {
+ divider = 360;
+ }
+
+ var value = Minimum + ((Maximum - Minimum) * Mod(angle - NormalizedMinAngle, 360) / divider);
+ if (value < Minimum || value > Maximum)
+ {
+ // Ignore positions outside the scale angle.
+ return;
+ }
+
+ Value = RoundToMultiple(value, StepSize);
+ }
+
+ private Point ScalePoint(double angle, double middleOfScale)
+ {
+ return new Point(100 + (Math.Sin(Degrees2Radians * angle) * middleOfScale), 100 - (Math.Cos(Degrees2Radians * angle) * middleOfScale));
+ }
+
+ private double ValueToAngle(double value)
+ {
+ // Off-scale on the left.
+ if (value < Minimum)
+ {
+ return MinAngle;
+ }
+
+ // Off-scale on the right.
+ if (value > Maximum)
+ {
+ return MaxAngle;
+ }
+
+ return ((value - Minimum) / (Maximum - Minimum) * (NormalizedMaxAngle - NormalizedMinAngle)) + NormalizedMinAngle;
+ }
+
+ private double Mod(double number, double divider)
+ {
+ var result = number % divider;
+ result = result < 0 ? result + divider : result;
+ return result;
+ }
+
+ private double RoundToMultiple(double number, double multiple)
+ {
+ double modulo = number % multiple;
+ if (double.IsNaN(modulo))
+ {
+ return number;
+ }
+
+ if ((multiple - modulo) <= modulo)
+ {
+ modulo = multiple - modulo;
+ }
+ else
+ {
+ modulo *= -1;
+ }
+
+ return number + modulo;
+ }
+}
diff --git a/components/RadialGauge/src/RadialGauge.xaml b/components/RadialGauge/src/RadialGauge.xaml
new file mode 100644
index 00000000..2b607ac8
--- /dev/null
+++ b/components/RadialGauge/src/RadialGauge.xaml
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/RadialGauge/src/RadialGaugeAutomationPeer.cs b/components/RadialGauge/src/RadialGaugeAutomationPeer.cs
new file mode 100644
index 00000000..2550a8ca
--- /dev/null
+++ b/components/RadialGauge/src/RadialGaugeAutomationPeer.cs
@@ -0,0 +1,93 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+#if WINAPPSDK
+using Microsoft.UI.Xaml.Automation.Provider;
+#else
+using Windows.UI.Xaml.Automation.Provider;
+#endif
+
+namespace CommunityToolkit.WinUI.Controls;
+
+///
+/// Exposes to Microsoft UI Automation.
+///
+public class RadialGaugeAutomationPeer :
+ RangeBaseAutomationPeer,
+ IRangeValueProvider
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The owner element to create for.
+ public RadialGaugeAutomationPeer(RadialGauge owner)
+ : base(owner)
+ {
+ }
+
+ ///
+ public new bool IsReadOnly => !((RadialGauge)Owner).IsInteractive;
+
+ ///
+ public new double LargeChange => ((RadialGauge)Owner).StepSize;
+
+ ///
+ public new double Maximum => ((RadialGauge)Owner).Maximum;
+
+ ///
+ public new double Minimum => ((RadialGauge)Owner).Minimum;
+
+ ///
+ public new double SmallChange => ((RadialGauge)Owner).StepSize;
+
+ ///
+ public new double Value => ((RadialGauge)Owner).Value;
+
+ ///
+ public new void SetValue(double value)
+ {
+ ((RadialGauge)Owner).Value = value;
+ }
+
+ ///
+ protected override IList? GetChildrenCore()
+ {
+ return null;
+ }
+
+ ///
+ protected override string GetNameCore()
+ {
+ var gauge = (RadialGauge)Owner;
+ return "radial gauge. " + (string.IsNullOrWhiteSpace(gauge.Unit) ? "no unit specified, " : "unit " + gauge.Unit + ", ") + Value;
+ }
+
+ ///
+ protected override object GetPatternCore(PatternInterface patternInterface)
+ {
+ if (patternInterface == PatternInterface.RangeValue)
+ {
+ // Expose RangeValue properties.
+ return this;
+ }
+
+ return base.GetPatternCore(patternInterface);
+ }
+
+ ///
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ {
+ return AutomationControlType.Custom;
+ }
+
+ ///
+ /// Raises the property changed event for this AutomationPeer for the provided identifier.
+ ///
+ /// Old value
+ /// New value
+ public void RaiseValueChangedEvent(double oldValue, double newValue)
+ {
+ RaisePropertyChangedEvent(RangeValuePatternIdentifiers.ValueProperty, PropertyValue.CreateDouble(oldValue), PropertyValue.CreateDouble(newValue));
+ }
+}
diff --git a/components/RadialGauge/src/Themes/Generic.xaml b/components/RadialGauge/src/Themes/Generic.xaml
new file mode 100644
index 00000000..d47de3c6
--- /dev/null
+++ b/components/RadialGauge/src/Themes/Generic.xaml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/components/RadialGauge/tests/RadialGauge.Tests.projitems b/components/RadialGauge/tests/RadialGauge.Tests.projitems
new file mode 100644
index 00000000..d75aaae6
--- /dev/null
+++ b/components/RadialGauge/tests/RadialGauge.Tests.projitems
@@ -0,0 +1,14 @@
+
+
+
+ $(MSBuildAllProjects);$(MSBuildThisFileFullPath)
+ true
+ 2D8A9068-EF5F-4569-982C-D7147398B174
+
+
+ RadialGaugeExperiment.Tests
+
+
+
+
+
\ No newline at end of file
diff --git a/components/RadialGauge/tests/RadialGauge.Tests.shproj b/components/RadialGauge/tests/RadialGauge.Tests.shproj
new file mode 100644
index 00000000..728d1a2a
--- /dev/null
+++ b/components/RadialGauge/tests/RadialGauge.Tests.shproj
@@ -0,0 +1,13 @@
+
+
+
+ 2D8A9068-EF5F-4569-982C-D7147398B174
+ 14.0
+
+
+
+
+
+
+
+
diff --git a/components/RadialGauge/tests/Test_RadialGauge.cs b/components/RadialGauge/tests/Test_RadialGauge.cs
new file mode 100644
index 00000000..a130a809
--- /dev/null
+++ b/components/RadialGauge/tests/Test_RadialGauge.cs
@@ -0,0 +1,34 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using CommunityToolkit.WinUI.Controls;
+
+namespace RadialGaugeExperiment.Tests;
+
+[TestClass]
+public class Test_RadialGauge
+{
+ ///
+ /// Verifies that the UIA name is valid and makes sense
+ ///
+ [TestCategory("Test_RadialGauge_Accesibility")]
+ [UITestMethod]
+ public void VerifyUIAName()
+ {
+ var gauge = new RadialGauge()
+ {
+ Minimum = 0,
+ Maximum = 100,
+ Value = 20
+ };
+
+ var gaugePeer = FrameworkElementAutomationPeer.CreatePeerForElement(gauge);
+
+ Assert.IsTrue(gaugePeer.GetName().Contains(gauge.Value.ToString()), "Verify that the UIA name contains the value of the RadialGauge.");
+ Assert.IsTrue(gaugePeer.GetName().Contains("no unit"), "The UIA name should indicate that unit was not specified.");
+
+ gauge.Unit = "KM/H";
+ Assert.IsTrue(gaugePeer.GetName().Contains(gauge.Unit), "The UIA name should report the unit of the RadialGauge.");
+ }
+}