diff --git a/AtomUI.Labs.slnx b/AtomUI.Labs.slnx
index 16c21e8..47a7b45 100644
--- a/AtomUI.Labs.slnx
+++ b/AtomUI.Labs.slnx
@@ -1,4 +1,6 @@
+
+
diff --git a/Directory.Build.props b/Directory.Build.props
index c197ec0..c28861a 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -3,7 +3,7 @@
enable
enable
latest
- $(RestoreAdditionalProjectSources);$(MSBuildThisFileDirectory)../AtomUI.Base/output/Nuget/Release
+ $(RestoreAdditionalProjectSources);$(MSBuildThisFileDirectory)../AtomUI.Base/output/Nuget/Release
@@ -11,4 +11,3 @@
-
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 17adcc4..d4d6903 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,6 +4,7 @@
+
@@ -11,6 +12,7 @@
+
diff --git a/README.md b/README.md
index b9b0214..1ef5257 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,12 @@ Labs package names follow this pattern:
dotnet add package AtomUI.Labs.Controls.
```
+For example, install the currently implemented LED family with:
+
+```bash
+dotnet add package AtomUI.Labs.Controls.Led
+```
+
Use a Labs package version that matches your AtomUI package version.
## Common Commands
@@ -45,6 +51,8 @@ Package artifacts are written to `output/Nuget/`.
- [Engineering overview](docs/engineering/overview.md)
- [Build and packaging](docs/architecture/build-and-packaging.md)
+- [Experimental controls](docs/controls/overview.md)
+- [LED control family](docs/controls/led/overview.md)
- [Global engineering guidelines](docs/global-engineering-guidelines.md)
## Status
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 7aa5b33..197b57a 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -27,6 +27,12 @@ Labs 包名遵循以下格式:
dotnet add package AtomUI.Labs.Controls.
```
+例如,安装当前已经实现的 LED 控件家族:
+
+```bash
+dotnet add package AtomUI.Labs.Controls.Led
+```
+
Labs 包版本应与应用使用的 AtomUI 主包版本保持一致。
## 常用命令
@@ -45,6 +51,8 @@ git diff --check
- [工程规范总览](docs/engineering/overview.md)
- [构建、打包与发布](docs/architecture/build-and-packaging.md)
+- [实验控件文档](docs/controls/overview.md)
+- [LED 控件家族](docs/controls/led/overview.md)
- [全局工程规范](docs/global-engineering-guidelines.md)
## 状态说明
diff --git a/controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj b/controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj
index 8d5ab37..067ca2b 100644
--- a/controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj
+++ b/controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj
@@ -6,6 +6,10 @@
AtomUILabsGallery
+
+
+
+
@@ -16,6 +20,7 @@
+
diff --git a/controlgallery/AtomUILabsGallery/AtomUILabsGalleryModule.cs b/controlgallery/AtomUILabsGallery/AtomUILabsGalleryModule.cs
index 64badcf..594f5ba 100644
--- a/controlgallery/AtomUILabsGallery/AtomUILabsGalleryModule.cs
+++ b/controlgallery/AtomUILabsGallery/AtomUILabsGalleryModule.cs
@@ -4,6 +4,7 @@
using AtomUI.Toolkits.GalleryBase.Configuration;
using AtomUI.Toolkits.GalleryBase.Routing;
using AtomUILabsGallery.ShowCases;
+using AtomUILabsGallery.ShowCases.Led;
using Avalonia.Controls;
using ReactiveUI;
@@ -51,13 +52,15 @@ private static void ConfigureNavigation(AtomUI.Toolkits.GalleryBase.Navigation.G
navigation.AddPage(OverviewViewModel.ID, "Overview", Icon(AntDesignIconKind.HomeOutlined));
var labs = navigation.AddGroup("Labs", "Labs Controls", Icon(AntDesignIconKind.AppstoreOutlined));
- labs.AddPage(BlankViewModel.ID, "Blank Showcase", Icon(AntDesignIconKind.ExperimentOutlined));
+ labs.AddPage(LedSegmentViewModel.ID, "LED Segment Display", Icon(AntDesignIconKind.FieldNumberOutlined));
+ labs.AddPage(LedMatrixViewModel.ID, "LED Matrix Display", Icon(AntDesignIconKind.TableOutlined));
}
private static void ConfigureRoutes(GalleryRouteRegistry routes)
{
routes.Map(OverviewViewModel.ID, screen => new OverviewViewModel(screen), () => new OverviewShowCase());
- routes.Map(BlankViewModel.ID, screen => new BlankViewModel(screen), () => new BlankShowCase());
+ routes.Map(LedSegmentViewModel.ID, screen => new LedSegmentViewModel(screen), () => new LedSegmentShowCase());
+ routes.Map(LedMatrixViewModel.ID, screen => new LedMatrixViewModel(screen), () => new LedMatrixShowCase());
}
private static Func Icon(AntDesignIconKind kind)
diff --git a/controlgallery/AtomUILabsGallery/ShowCases/BlankShowCase.axaml b/controlgallery/AtomUILabsGallery/ShowCases/BlankShowCase.axaml
deleted file mode 100644
index 07b0e1d..0000000
--- a/controlgallery/AtomUILabsGallery/ShowCases/BlankShowCase.axaml
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/controlgallery/AtomUILabsGallery/ShowCases/BlankShowCase.axaml.cs b/controlgallery/AtomUILabsGallery/ShowCases/BlankShowCase.axaml.cs
deleted file mode 100644
index 507c75d..0000000
--- a/controlgallery/AtomUILabsGallery/ShowCases/BlankShowCase.axaml.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-using AtomUI.Controls;
-using AtomUI.Toolkits.GalleryBase.Controls;
-using ReactiveUI;
-
-namespace AtomUILabsGallery.ShowCases;
-
-public partial class BlankShowCase : GalleryReactiveUserControl
-{
- public BlankShowCase()
- {
- InitializeComponent();
- }
-}
-
-public sealed class BlankViewModel : ReactiveObject, IRoutableViewModel
-{
- public static EntityKey ID => "BlankShowCase";
-
- public BlankViewModel(IScreen hostScreen)
- {
- HostScreen = hostScreen;
- }
-
- public IScreen HostScreen { get; }
-
- public string UrlPathSegment => ID.ToString();
-}
diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedDynamicDisplays.cs b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedDynamicDisplays.cs
new file mode 100644
index 0000000..d3898ee
--- /dev/null
+++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedDynamicDisplays.cs
@@ -0,0 +1,105 @@
+using AtomUI.Labs.Controls.Led.Matrix;
+using AtomUI.Labs.Controls.Led.Segment;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+
+namespace AtomUILabsGallery.ShowCases.Led;
+
+public sealed class LedDynamicDisplays : StackPanel
+{
+ private readonly SegmentDisplay _clockDisplay;
+ private readonly SegmentDisplay _segmentCounterDisplay;
+ private readonly MatrixDisplay _matrixCounterDisplay;
+ private readonly DispatcherTimer _timer;
+ private int _counter;
+
+ public LedDynamicDisplays()
+ {
+ Spacing = 12;
+ _clockDisplay = CreateSegmentDisplay();
+ _segmentCounterDisplay = CreateSegmentDisplay();
+ _matrixCounterDisplay = new MatrixDisplay
+ {
+ DotSize = 7,
+ DotSpacing = 2,
+ CharacterSpacing = 9,
+ Padding = new Thickness(14),
+ Background = new SolidColorBrush(Color.FromRgb(9, 18, 14)),
+ ActiveBrush = new SolidColorBrush(Color.FromRgb(97, 247, 157)),
+ InactiveBrush = new SolidColorBrush(Color.FromArgb(34, 97, 247, 157)),
+ CornerRadius = new CornerRadius(8),
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center
+ };
+ Children.Add(_clockDisplay);
+ Children.Add(new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("*,*"),
+ ColumnSpacing = 12,
+ Children =
+ {
+ _segmentCounterDisplay,
+ PlaceInSecondColumn(_matrixCounterDisplay)
+ }
+ });
+
+ _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
+ _timer.Tick += HandleTimerTick;
+ AttachedToVisualTree += HandleAttachedToVisualTree;
+ DetachedFromVisualTree += HandleDetachedFromVisualTree;
+ UpdateDisplays();
+ }
+
+ private static SegmentDisplay CreateSegmentDisplay()
+ {
+ return new SegmentDisplay
+ {
+ CharacterHeight = 54,
+ SegmentThickness = 6,
+ SegmentGap = 2,
+ CharacterSpacing = 7,
+ Padding = new Thickness(14),
+ Background = new SolidColorBrush(Color.FromRgb(8, 12, 16)),
+ ActiveBrush = new SolidColorBrush(Color.FromRgb(83, 237, 255)),
+ InactiveBrush = new SolidColorBrush(Color.FromArgb(34, 83, 237, 255)),
+ CornerRadius = new CornerRadius(10),
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center
+ };
+ }
+
+ private static Control PlaceInSecondColumn(Control control)
+ {
+ Grid.SetColumn(control, 1);
+ return control;
+ }
+
+ private void HandleAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
+ {
+ UpdateDisplays();
+ _timer.Start();
+ }
+
+ private void HandleDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
+ {
+ _timer.Stop();
+ }
+
+ private void HandleTimerTick(object? sender, EventArgs e)
+ {
+ UpdateDisplays();
+ }
+
+ private void UpdateDisplays()
+ {
+ var now = DateTime.Now;
+ _clockDisplay.Text = $"{now:HH}:{now:mm}:{now:ss}";
+ _segmentCounterDisplay.Text = _counter.ToString("D4");
+ _matrixCounterDisplay.Text = _counter.ToString("D6");
+ _counter = (_counter + 1) % 1_000_000;
+ }
+}
diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs
new file mode 100644
index 0000000..c54a8dc
--- /dev/null
+++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs
@@ -0,0 +1,597 @@
+using AtomUI.Labs.Controls.Led.Matrix;
+using AtomUI.Labs.Controls.Led.Segment;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Styling;
+using Avalonia.VisualTree;
+using AtomComboBox = AtomUI.Desktop.Controls.ComboBox;
+using AtomSegmented = AtomUI.Desktop.Controls.Segmented;
+using AtomSlider = AtomUI.Desktop.Controls.Slider;
+using AtomTextBox = AtomUI.Desktop.Controls.TextBox;
+using AtomToggleSwitch = AtomUI.Desktop.Controls.ToggleSwitch;
+
+namespace AtomUILabsGallery.ShowCases.Led;
+
+public sealed class LedGlowWorkbench : StackPanel
+{
+ private static readonly GlowBrushOption[] BrushOptions =
+ [
+ new("Cyan", Color.FromRgb(74, 222, 255)),
+ new("Red", Color.FromRgb(255, 48, 48)),
+ new("Magenta", Color.FromRgb(255, 76, 210)),
+ new("Green", Color.FromRgb(105, 255, 168)),
+ new("Amber", Color.FromRgb(255, 190, 82))
+ ];
+
+ private readonly MatrixDisplay _matrix;
+ private readonly SegmentDisplay _segment;
+ private readonly AtomToggleSwitch _enabled;
+ private readonly AtomComboBox _brushPreset;
+ private readonly AtomTextBox _brushHex;
+ private readonly TextBlock _brushValidation;
+ private readonly AtomSegmented _mode;
+ private readonly AtomSlider _opacity;
+ private readonly AtomSlider _radius;
+ private readonly TextBlock _opacityValue;
+ private readonly TextBlock _radiusValue;
+ private CancellationTokenSource? _animationCancellation;
+ private Color _selectedBrushColor = BrushOptions[0].Color;
+ private bool _isSynchronizingBrushEditor;
+ private bool _isAttachedToVisualTree;
+
+ internal bool HasActiveAnimation => _animationCancellation is { IsCancellationRequested: false };
+
+ internal bool GlowEnabled
+ {
+ get => _enabled.IsChecked == true;
+ set => _enabled.IsChecked = value;
+ }
+
+ internal int AnimationModeIndex
+ {
+ get => _mode.SelectedIndex;
+ set => _mode.SelectedIndex = value;
+ }
+
+ internal int BrushPresetIndex
+ {
+ get => _brushPreset.SelectedIndex;
+ set => _brushPreset.SelectedIndex = value;
+ }
+
+ internal string? BrushHexText
+ {
+ get => _brushHex.Text;
+ set => _brushHex.Text = value;
+ }
+
+ internal bool HasBrushValidationError => _brushValidation.IsVisible;
+
+ internal double GlowOpacity
+ {
+ get => _opacity.Value;
+ set => _opacity.Value = value;
+ }
+
+ internal double GlowRadius
+ {
+ get => _radius.Value;
+ set => _radius.Value = value;
+ }
+
+ internal IBrush? MatrixGlowBrush => _matrix.GlowBrush;
+
+ internal IBrush? SegmentGlowBrush => _segment.GlowBrush;
+
+ internal double MatrixGlowOpacity => _matrix.GlowOpacity;
+
+ internal double SegmentGlowOpacity => _segment.GlowOpacity;
+
+ internal double MatrixGlowRadius => _matrix.GlowRadius;
+
+ internal double SegmentGlowRadius => _segment.GlowRadius;
+
+ public LedGlowWorkbench()
+ {
+ Spacing = 12;
+ _matrix = CreateMatrixPreview();
+ _segment = CreateSegmentPreview();
+ _enabled = new AtomToggleSwitch
+ {
+ OnContent = "Enabled",
+ OffContent = "Disabled",
+ IsChecked = true
+ };
+ _brushPreset = new AtomComboBox
+ {
+ ItemsSource = BrushOptions.Select(option => option.Name).ToArray(),
+ SelectedIndex = 0,
+ Width = 150
+ };
+ _brushHex = new AtomTextBox
+ {
+ Text = FormatHexColor(_selectedBrushColor),
+ PlaceholderText = "#RRGGBB",
+ Width = 140
+ };
+ _brushValidation = new TextBlock
+ {
+ Text = "Use #RRGGBB or #AARRGGBB.",
+ Foreground = Brushes.OrangeRed,
+ IsVisible = false
+ };
+ _mode = new AtomSegmented
+ {
+ ItemsSource = new[] { "Static", "Breathe", "Pulse" },
+ SelectedIndex = 0,
+ MinWidth = 300,
+ IsExpanding = true,
+ HorizontalAlignment = HorizontalAlignment.Stretch
+ };
+ _opacity = new AtomSlider
+ {
+ Minimum = 0,
+ Maximum = 1,
+ Value = 0.35,
+ TickFrequency = 0.05,
+ HorizontalAlignment = HorizontalAlignment.Stretch
+ };
+ _radius = new AtomSlider
+ {
+ Minimum = 0,
+ Maximum = 24,
+ Value = 6,
+ TickFrequency = 1,
+ HorizontalAlignment = HorizontalAlignment.Stretch
+ };
+ _opacityValue = new TextBlock { Width = 48, VerticalAlignment = VerticalAlignment.Center };
+ _radiusValue = new TextBlock { Width = 48, VerticalAlignment = VerticalAlignment.Center };
+
+ Children.Add(new TextBlock
+ {
+ Text = "Glow workbench",
+ FontSize = 18,
+ FontWeight = FontWeight.SemiBold
+ });
+ Children.Add(CreateConfigurationPanel());
+ Children.Add(new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("*,*"),
+ ColumnSpacing = 12,
+ Children =
+ {
+ _matrix,
+ PlaceInSecondColumn(_segment)
+ }
+ });
+
+ _enabled.IsCheckedChanged += HandleConfigurationChanged;
+ _brushPreset.SelectionChanged += HandleBrushPresetChanged;
+ _brushHex.PropertyChanged += HandleBrushHexPropertyChanged;
+ _mode.SelectionChanged += HandleConfigurationChanged;
+ _opacity.PropertyChanged += HandleSliderPropertyChanged;
+ _radius.PropertyChanged += HandleSliderPropertyChanged;
+ AttachedToVisualTree += HandleAttachedToVisualTree;
+ DetachedFromVisualTree += HandleDetachedFromVisualTree;
+ ApplyConfiguration();
+ }
+
+ private Control CreateBrushEditor()
+ {
+ return new StackPanel
+ {
+ Spacing = 4,
+ Children =
+ {
+ new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 8,
+ Children =
+ {
+ _brushPreset,
+ _brushHex
+ }
+ },
+ _brushValidation
+ }
+ };
+ }
+
+ private Control CreateConfigurationPanel()
+ {
+ var panel = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("72,*,56"),
+ RowDefinitions = new RowDefinitions("Auto,Auto,Auto,Auto,Auto"),
+ ColumnSpacing = 12,
+ RowSpacing = 12,
+ MaxWidth = 620,
+ HorizontalAlignment = HorizontalAlignment.Left
+ };
+
+ AddConfigurationRow(panel, 0, "State", _enabled);
+ AddConfigurationRow(panel, 1, "Brush", CreateBrushEditor());
+ AddConfigurationRow(panel, 2, "Mode", _mode);
+ AddConfigurationRow(panel, 3, "Opacity", _opacity, _opacityValue);
+ AddConfigurationRow(panel, 4, "Radius", _radius, _radiusValue);
+ return panel;
+ }
+
+ private static void AddConfigurationRow(
+ Grid panel,
+ int row,
+ string label,
+ Control control,
+ Control? value = null)
+ {
+ var labelBlock = new TextBlock
+ {
+ Text = label,
+ FontWeight = FontWeight.SemiBold,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ Grid.SetRow(labelBlock, row);
+ panel.Children.Add(labelBlock);
+
+ Grid.SetRow(control, row);
+ Grid.SetColumn(control, 1);
+ panel.Children.Add(control);
+
+ if (value is null)
+ {
+ return;
+ }
+
+ Grid.SetRow(value, row);
+ Grid.SetColumn(value, 2);
+ panel.Children.Add(value);
+ }
+
+ private void HandleAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
+ {
+ _isAttachedToVisualTree = true;
+ ApplyConfiguration();
+ }
+
+ private void HandleDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
+ {
+ _isAttachedToVisualTree = false;
+ CancelAnimations();
+ }
+
+ private void HandleConfigurationChanged(object? sender, EventArgs e)
+ {
+ ApplyConfiguration();
+ }
+
+ private void HandleBrushPresetChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (_isSynchronizingBrushEditor ||
+ _brushPreset.SelectedIndex < 0 ||
+ _brushPreset.SelectedIndex >= BrushOptions.Length)
+ {
+ return;
+ }
+
+ _selectedBrushColor = BrushOptions[_brushPreset.SelectedIndex].Color;
+ _isSynchronizingBrushEditor = true;
+ try
+ {
+ _brushHex.Text = FormatHexColor(_selectedBrushColor);
+ }
+ finally
+ {
+ _isSynchronizingBrushEditor = false;
+ }
+
+ SetBrushValidationError(false);
+ ApplyConfiguration();
+ }
+
+ private void HandleBrushHexPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (_isSynchronizingBrushEditor || e.Property != AtomTextBox.TextProperty)
+ {
+ return;
+ }
+
+ if (!TryParseHexColor(_brushHex.Text, out var color))
+ {
+ SetBrushValidationError(true);
+ return;
+ }
+
+ _selectedBrushColor = color;
+ _isSynchronizingBrushEditor = true;
+ try
+ {
+ _brushPreset.SelectedIndex = FindBrushPresetIndex(color);
+ }
+ finally
+ {
+ _isSynchronizingBrushEditor = false;
+ }
+
+ SetBrushValidationError(false);
+ ApplyConfiguration();
+ }
+
+ private void HandleSliderPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == RangeBase.ValueProperty)
+ {
+ ApplyConfiguration();
+ }
+ }
+
+ private void ApplyConfiguration()
+ {
+ CancelAnimations();
+ _opacityValue.Text = _opacity.Value.ToString("0.00");
+ _radiusValue.Text = _radius.Value.ToString("0");
+ var enabled = _enabled.IsChecked == true;
+ var brush = enabled ? CreateSelectedBrush() : null;
+ ApplyStaticValues(_matrix, brush);
+ ApplyStaticValues(_segment, brush);
+ if (!_isAttachedToVisualTree || !enabled || _mode.SelectedIndex == 0)
+ {
+ return;
+ }
+
+ _animationCancellation = new CancellationTokenSource();
+ var mode = _mode.SelectedIndex == 1 ? GlowAnimationMode.Breathe : GlowAnimationMode.Pulse;
+ _ = RunAnimationAsync(
+ _matrix,
+ CreateAnimation(
+ mode,
+ MatrixDisplay.GlowOpacityProperty,
+ MatrixDisplay.GlowRadiusProperty,
+ _opacity.Value,
+ _radius.Value),
+ _animationCancellation.Token);
+ _ = RunAnimationAsync(
+ _segment,
+ CreateAnimation(
+ mode,
+ SegmentDisplay.GlowOpacityProperty,
+ SegmentDisplay.GlowRadiusProperty,
+ _opacity.Value,
+ _radius.Value),
+ _animationCancellation.Token);
+ }
+
+ private void ApplyStaticValues(MatrixDisplay display, IBrush? brush)
+ {
+ display.GlowBrush = brush;
+ display.GlowOpacity = _opacity.Value;
+ display.GlowRadius = _radius.Value;
+ }
+
+ private void ApplyStaticValues(SegmentDisplay display, IBrush? brush)
+ {
+ display.GlowBrush = brush;
+ display.GlowOpacity = _opacity.Value;
+ display.GlowRadius = _radius.Value;
+ }
+
+ private Animation CreateAnimation(
+ GlowAnimationMode mode,
+ AvaloniaProperty opacityProperty,
+ AvaloniaProperty radiusProperty,
+ double opacity,
+ double radius)
+ {
+ var animation = new Animation
+ {
+ Duration = mode == GlowAnimationMode.Breathe
+ ? TimeSpan.FromSeconds(2.4)
+ : TimeSpan.FromSeconds(1.2),
+ IterationCount = IterationCount.Infinite,
+ FillMode = FillMode.Both,
+ Easing = new SineEaseInOut()
+ };
+ if (mode == GlowAnimationMode.Breathe)
+ {
+ var minimumOpacity = opacity * 0.35;
+ AddKeyFrame(animation, 0, opacityProperty, minimumOpacity);
+ AddKeyFrame(animation, 0.5, opacityProperty, opacity);
+ AddKeyFrame(animation, 1, opacityProperty, minimumOpacity);
+ }
+ else
+ {
+ var minimumOpacity = opacity * 0.5;
+ var minimumRadius = radius * 0.5;
+ AddKeyFrame(animation, 0, opacityProperty, minimumOpacity, radiusProperty, minimumRadius);
+ AddKeyFrame(animation, 0.5, opacityProperty, opacity, radiusProperty, radius);
+ AddKeyFrame(animation, 1, opacityProperty, minimumOpacity, radiusProperty, minimumRadius);
+ }
+
+ return animation;
+ }
+
+ private static void AddKeyFrame(
+ Animation animation,
+ double cue,
+ AvaloniaProperty property,
+ double value)
+ {
+ animation.Children.Add(new KeyFrame
+ {
+ Cue = new Cue(cue),
+ Setters = { new Setter(property, value) }
+ });
+ }
+
+ private static void AddKeyFrame(
+ Animation animation,
+ double cue,
+ AvaloniaProperty firstProperty,
+ double firstValue,
+ AvaloniaProperty secondProperty,
+ double secondValue)
+ {
+ animation.Children.Add(new KeyFrame
+ {
+ Cue = new Cue(cue),
+ Setters =
+ {
+ new Setter(firstProperty, firstValue),
+ new Setter(secondProperty, secondValue)
+ }
+ });
+ }
+
+ private static async Task RunAnimationAsync(Control target, Animation animation, CancellationToken token)
+ {
+ try
+ {
+ await animation.RunAsync(target, token);
+ }
+ catch (OperationCanceledException) when (token.IsCancellationRequested)
+ {
+ }
+ }
+
+ private void CancelAnimations()
+ {
+ _animationCancellation?.Cancel();
+ _animationCancellation?.Dispose();
+ _animationCancellation = null;
+ }
+
+ private IBrush CreateSelectedBrush()
+ {
+ return new SolidColorBrush(_selectedBrushColor);
+ }
+
+ private void SetBrushValidationError(bool hasError)
+ {
+ _brushValidation.IsVisible = hasError;
+ }
+
+ private static int FindBrushPresetIndex(Color color)
+ {
+ for (var index = 0; index < BrushOptions.Length; index++)
+ {
+ if (BrushOptions[index].Color == color)
+ {
+ return index;
+ }
+ }
+
+ return -1;
+ }
+
+ private static string FormatHexColor(Color color)
+ {
+ return color.A == byte.MaxValue
+ ? $"#{color.R:X2}{color.G:X2}{color.B:X2}"
+ : $"#{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}";
+ }
+
+ private static bool TryParseHexColor(string? value, out Color color)
+ {
+ color = default;
+ if (value is null || value.Length is not (7 or 9) || value[0] != '#')
+ {
+ return false;
+ }
+
+ var hex = value.AsSpan(1);
+ var alpha = byte.MaxValue;
+ if (hex.Length == 8)
+ {
+ if (!byte.TryParse(
+ hex[..2],
+ System.Globalization.NumberStyles.HexNumber,
+ null,
+ out alpha))
+ {
+ return false;
+ }
+
+ hex = hex[2..];
+ }
+
+ if (!byte.TryParse(
+ hex[..2],
+ System.Globalization.NumberStyles.HexNumber,
+ null,
+ out var red) ||
+ !byte.TryParse(
+ hex.Slice(2, 2),
+ System.Globalization.NumberStyles.HexNumber,
+ null,
+ out var green) ||
+ !byte.TryParse(
+ hex.Slice(4, 2),
+ System.Globalization.NumberStyles.HexNumber,
+ null,
+ out var blue))
+ {
+ return false;
+ }
+
+ color = Color.FromArgb(alpha, red, green, blue);
+ return true;
+ }
+
+ private static Control PlaceInSecondColumn(Control control)
+ {
+ Grid.SetColumn(control, 1);
+ return control;
+ }
+
+ private static MatrixDisplay CreateMatrixPreview()
+ {
+ return new MatrixDisplay
+ {
+ Text = "GLOW 2026",
+ Height = 132,
+ DotSize = 7,
+ DotSpacing = 3,
+ CharacterSpacing = 9,
+ Padding = new Thickness(20),
+ Background = new SolidColorBrush(Color.FromRgb(6, 10, 14)),
+ ActiveBrush = Brushes.White,
+ InactiveBrush = new SolidColorBrush(Color.FromArgb(28, 110, 138, 148)),
+ CornerRadius = new CornerRadius(8),
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center
+ };
+ }
+
+ private static SegmentDisplay CreateSegmentPreview()
+ {
+ return new SegmentDisplay
+ {
+ Text = "88:88",
+ Height = 132,
+ CharacterHeight = 72,
+ SegmentThickness = 8,
+ SegmentGap = 2,
+ CharacterSpacing = 8,
+ Padding = new Thickness(20),
+ Background = new SolidColorBrush(Color.FromRgb(6, 10, 14)),
+ ActiveBrush = Brushes.White,
+ InactiveBrush = new SolidColorBrush(Color.FromArgb(28, 110, 138, 148)),
+ CornerRadius = new CornerRadius(8),
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center
+ };
+ }
+
+ private enum GlowAnimationMode
+ {
+ Breathe,
+ Pulse
+ }
+
+ private sealed record GlowBrushOption(string Name, Color Color);
+}
diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ViewModels/LedMatrixViewModel.cs b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ViewModels/LedMatrixViewModel.cs
new file mode 100644
index 0000000..68e150e
--- /dev/null
+++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ViewModels/LedMatrixViewModel.cs
@@ -0,0 +1,18 @@
+using AtomUI.Controls;
+using ReactiveUI;
+
+namespace AtomUILabsGallery.ShowCases.Led;
+
+public sealed class LedMatrixViewModel : ReactiveObject, IRoutableViewModel
+{
+ public static EntityKey ID => "LedMatrixShowCase";
+
+ public LedMatrixViewModel(IScreen hostScreen)
+ {
+ HostScreen = hostScreen;
+ }
+
+ public IScreen HostScreen { get; }
+
+ public string UrlPathSegment => ID.ToString();
+}
diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ViewModels/LedSegmentViewModel.cs b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ViewModels/LedSegmentViewModel.cs
new file mode 100644
index 0000000..eb953b6
--- /dev/null
+++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ViewModels/LedSegmentViewModel.cs
@@ -0,0 +1,18 @@
+using AtomUI.Controls;
+using ReactiveUI;
+
+namespace AtomUILabsGallery.ShowCases.Led;
+
+public sealed class LedSegmentViewModel : ReactiveObject, IRoutableViewModel
+{
+ public static EntityKey ID => "LedSegmentShowCase";
+
+ public LedSegmentViewModel(IScreen hostScreen)
+ {
+ HostScreen = hostScreen;
+ }
+
+ public IScreen HostScreen { get; }
+
+ public string UrlPathSegment => ID.ToString();
+}
diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml
new file mode 100644
index 0000000..96f95cc
--- /dev/null
+++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml
@@ -0,0 +1,187 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml.cs b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml.cs
new file mode 100644
index 0000000..eac2b63
--- /dev/null
+++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml.cs
@@ -0,0 +1,11 @@
+using AtomUI.Toolkits.GalleryBase.Controls;
+
+namespace AtomUILabsGallery.ShowCases.Led;
+
+public partial class LedMatrixShowCase : GalleryReactiveUserControl
+{
+ public LedMatrixShowCase()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml
new file mode 100644
index 0000000..8809ab8
--- /dev/null
+++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml.cs b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml.cs
new file mode 100644
index 0000000..df798f9
--- /dev/null
+++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml.cs
@@ -0,0 +1,11 @@
+using AtomUI.Toolkits.GalleryBase.Controls;
+
+namespace AtomUILabsGallery.ShowCases.Led;
+
+public partial class LedSegmentShowCase : GalleryReactiveUserControl
+{
+ public LedSegmentShowCase()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/controlgallery/AtomUILabsGallery/ThemeManagerBuilderExtensions.cs b/controlgallery/AtomUILabsGallery/ThemeManagerBuilderExtensions.cs
index fba1129..a5d442c 100644
--- a/controlgallery/AtomUILabsGallery/ThemeManagerBuilderExtensions.cs
+++ b/controlgallery/AtomUILabsGallery/ThemeManagerBuilderExtensions.cs
@@ -1,4 +1,5 @@
using AtomUI.Theme;
+using AtomUI.Labs.Controls.Led;
using AtomUI.Toolkits.GalleryBase;
namespace AtomUILabsGallery;
@@ -7,6 +8,7 @@ public static class ThemeManagerBuilderExtensions
{
public static IThemeManagerBuilder UseLabsGalleryControls(this IThemeManagerBuilder themeManagerBuilder)
{
+ themeManagerBuilder.UseLed();
themeManagerBuilder.UseGalleryBase(AtomUILabsGalleryModule.Configure);
return themeManagerBuilder;
}
diff --git a/docs/controls/led/family-common-boundary-audit.md b/docs/controls/led/family-common-boundary-audit.md
new file mode 100644
index 0000000..a93bc18
--- /dev/null
+++ b/docs/controls/led/family-common-boundary-audit.md
@@ -0,0 +1,79 @@
+# LED 家族公共边界审计
+
+> 文档状态:历史审计记录(审计日期 2026-07-10)。结论用于解释共享边界的形成过程;其中宿主名称、构建结果和测试数量不是当前验证状态,当前契约以 [overview](overview.md) 为准。
+
+- 审计日期:2026-07-10
+- 审计对象:`Led.Segment` 与 `Led.Matrix`
+- 目标:只提取已经由两套稳定实现证明语义相同的内部基础代码
+- 公共 API 变化:无
+
+## 最终结论
+
+当前 LED 家族共享两项内部基础设施:
+
+```text
+LedCharacterNormalizer
+ ASCII小写 -> 大写
+
+LedDisplayLayoutMath
+ 理想尺寸 + 最终Bounds -> ScaleDown比例 + 内容对齐偏移
+```
+
+二者都是无状态纯计算,不读取控件属性,不持有缓存,不生成Geometry,也不提交DrawingContext命令。
+
+## 新增共享边界
+
+`LedDisplayLayoutMath` 直接位于 `src/AtomUI.Labs.Controls.Led/` 根目录,不新增 `Primitives`、`Shared` 或 `Internal` 目录。
+
+它只包含:
+
+- `CalculateScaleDown(Size desiredSize, Size bounds)`:按宽高限制等比缩小,最大值为1;非正或非有限尺寸返回1。
+- `CalculateAlignmentOffset(...)`:根据缩放后内容尺寸计算Left/Center/Right、Top/Center/Bottom偏移,Stretch按Center处理。
+
+Segment和Matrix仍各自判断自己的公开`OverflowMode`是否为`ScaleDown`,共享工具不知道两个公开枚举的存在。
+
+## 保持独立的部分
+
+| 候选 | 结论 | 原因 |
+|---|---|---|
+| `SegmentOverflowMode` / `MatrixOverflowMode` | 不共享 | 二者是已经冻结的独立公开合同,合并会造成API变更和路线耦合 |
+| StyledProperty与控件基类 | 不共享 | 公共基类会扩大公开API,并把两条显示路线强制绑定到同一继承合同 |
+| ValueSanitizer | 不共享 | Matrix对布局参数设置`1,000,000`上限;Segment没有该上限且额外支持范围规整 |
+| LayoutEngine、Layout、Slot | 不共享 | Segment使用十四段字符格并支持窄符号;Matrix使用固定5x7等宽Rune布局 |
+| CharacterMap与fallback | 不共享 | Segment未知字符为空格,Matrix未知Unicode标量为问号,语义不同 |
+| Geometry与缓存 | 不共享 | Segment缓存字符槽拓扑和十四段骨架;Matrix按字模bit缓存亮暗点Geometry |
+| AutomationPeer | 不共享 | 外壳相似,但提取需要基类、接口或委托,两个消费者不足以抵消复杂度 |
+| ControlTheme | 不共享 | 每个ControlTheme必须保持独立目标类型和可独立演进的主题合同 |
+| 背景绘制 | 不共享 | 只有一次`DrawRectangle`调用,提取helper只会隐藏直观代码 |
+
+## 控件保留职责
+
+Segment继续拥有:
+
+- 基于 `CharacterHeight` 生成理想布局,并在绘制阶段处理最终 Bounds、对齐和溢出。
+- Segment专属几何缓存与Glow绘制。
+- 将`SegmentOverflowMode`映射为是否调用共享ScaleDown计算。
+
+Matrix继续拥有:
+
+- 固定5x7布局和Rune字符合同。
+- 可见字符二分剔除及视口逆变换。
+- 将`MatrixOverflowMode`映射为是否调用共享ScaleDown计算。
+
+## 验证要求
+
+- 共享数学直接测试宽限制、高限制、不放大、非正/非有限尺寸和全部对齐模式。
+- Segment与Matrix现有Render、Pixel、Clip和ScaleDown测试必须保持通过。
+- Labs完整测试、Sample Debug/Release、net8/net10 pack和依赖扫描必须保持通过。
+- `docs`不得修改。
+
+## 验证结果
+
+- `LedDisplayLayoutMathTests`:16/16通过。
+- 布局数学、Segment Render、Matrix Render/Pixel相关测试:92/92通过。
+- Labs全量测试:275/275通过,Release net10.0。
+- Labs Sample Debug/Release:均为0 warning、0 error。
+- Labs NuGet pack:net8/net10成功,依赖仍只有`AtomUI.Core`和Avalonia。
+- 共享工具静态扫描:不引用Segment、Matrix、Control、StyledProperty、Geometry或DrawingContext。
+
+本次提取是内部去重,不声明性能收益,也不改变视觉结果。
diff --git a/docs/controls/led/glow-prototype-evaluation.md b/docs/controls/led/glow-prototype-evaluation.md
new file mode 100644
index 0000000..c9b6270
--- /dev/null
+++ b/docs/controls/led/glow-prototype-evaluation.md
@@ -0,0 +1,548 @@
+# LED Glow 首轮原型评估
+
+> 文档状态:历史原型与评估记录(首轮日期 2026-07-11)。测试数量、Labs Sample、警告和性能数值仅表示当时环境;当前 Glow 契约以 [技术路线选型](glow-technical-options.md)和当前实现文档为准。
+
+## 评估状态
+
+本报告记录2026-07-11的首轮可行性Smoke,不是最终选型或正式性能结论。原型只存在于`AtomUI.Labs.Controls.Led.Performance`工具,不进入Labs运行时程序集。
+
+## Avalonia公开API审计
+
+Avalonia 12.0.5公开以下能力:
+
+- `DrawingContext.PushEffect(IEffect, Rect)`:Effect只包围作用域内绘制命令;传入内容的预膨胀Bounds,DrawingContext按Effect输出Padding扩张。
+- `BlurEffect.Radius`:仓库已有AXAML使用案例。
+- `RenderTargetBitmap.CreateDrawingContext()`和`Bitmap.CopyPixels(...)`:允许建立CPU读回链路,但Avalonia高层公开API未提供独立的`Blur(Bitmap)`操作。
+
+依据来自本机NuGet包`Avalonia.Base.xml`及编译/Headless Skia像素原型。仓库当前没有`.referenceprojects/Avalonia`源码镜像,因此不把未测量的Avalonia内部成本当作事实。
+
+## 原型实现
+
+### 路线A
+
+`VectorExpansionGlowPrototype`使用4层缓存Pen和递增透明度,再绘制低透明度填充。它只使用公开Geometry绘制API。
+
+### 路线B
+
+独立的应用层Alpha Mask + CPU Gaussian Blur原型在Gate 1停止:公开API可以创建/读回位图,但需要AtomUI自行实现和维护像素模糊、边缘扩张、降采样、格式转换和多缓冲生命周期。该路径重复渲染后端能力且不符合第一版复杂度约束。
+
+路线B的视觉语义没有被否定;Avalonia后端通过Scoped Blur Effect执行的Mask/Blur属于路线C候选,不需要AtomUI手写CPU模糊。
+
+### 路线C
+
+`ScopedBlurEffectGlowPrototype`在`PushOpacity`和`PushEffect(new BlurEffect { Radius = radius }, source.Bounds)`作用域中仅绘制GlowBrush Geometry,退出作用域后绘制清晰Active本体。没有设置整个Control的`Visual.Effect`,也没有新增子Visual。
+
+Skia自定义`ICustomDrawOperation`没有启动。公开Scoped Effect已通过首轮可行性Gate,在其失败前没有理由承担后端耦合。
+
+## 像素语料
+
+首轮覆盖:
+
+- Matrix:Circle、Square、RoundedSquare。
+- Segment:Horizontal、Vertical、Diagonal、ColonDots。
+- GlowRadius:6、12、24。
+- RenderScaling:100%、125%、150%、200%。
+- 两条可运行路线各84组,总计168组。
+
+判定条件为源Geometry Bounds外存在可见非背景像素。结果:
+
+| 路线 | 通过 | 总数 | 最少外部可见像素 |
+|---|---:|---:|---:|
+| A.VectorExpansion | 84 | 84 | 406 |
+| C.ScopedBlurEffect | 84 | 84 | 108 |
+
+该Gate只证明真实外扩,不证明视觉质量、图层隔离、缓存正确性或最终性能。
+
+## 命令提交Smoke
+
+场景为40x40 Circle、Radius 12、Opacity 0.65、600帧、单进程。数据包含每帧新建DrawingGroup的固定成本,不包含真实GPU展示;只能用于初筛。
+
+| 路线 | 微秒/帧 | 字节/帧 | 相对无Glow耗时 | 相对无Glow分配 |
+|---|---:|---:|---:|---:|
+| Baseline.NoGlow | 5.88 | 1520.1 | 1.00x | 1.00x |
+| A.VectorExpansion | 39.42 | 9920.1 | 6.70x | 6.53x |
+| C.ScopedBlurEffect | 14.92 | 4008.1 | 2.54x | 2.64x |
+
+不得把该单次Smoke描述为性能证明。正式比较仍需5个独立进程、600帧预热、6000帧测量和真实窗口数据。
+
+## 首轮结论
+
+- 路线A通过公开API和外扩像素Gate,保留为工程保底,但命令数和提交成本需要重点审计。
+- 独立手写路线B在Gate 1停止,不进入运行时或后续长稳测试。
+- 路线C的Scoped Blur Effect通过公开API和全部84组外扩像素Gate,进入下一轮图层隔离、Brush、裁剪和真实窗口验证。
+- Skia自定义路线未获得启动条件。
+- 当前没有最终技术选型,不修改Matrix或Segment公共API与Render路径。
+
+## 下一轮
+
+- 验证Background、Inactive和Border不进入Scoped Blur。
+- 验证Solid、Alpha、LinearGradient、RadialGradient Brush。
+- 验证Padding、Border内缘裁剪、ScaleDown和Radius随内容缩放。
+- 增加无Glow、Opacity变化和Radius变化的Effect/命令缓存计数。
+- 运行多进程性能审计和真实窗口视觉验收。
+
+## 第二轮:Brush、隔离、裁剪与Scale
+
+2026-07-11扩展原型后使用相同路线覆盖:
+
+- 3种Matrix Geometry和4类Segment Geometry。
+- Solid、Alpha Solid、LinearGradient、RadialGradient四类Brush。
+- Radius 6、12、24。
+- RenderScaling 100%、125%、150%、200%。
+- 2条路线,共672组像素Case。
+
+结果:路线A `336/336`、Scoped Blur `336/336`全部在源Geometry Bounds外产生可见像素。Gradient Brush在公开Scoped Effect路径中可运行,没有纯色强制转换。
+
+Scoped Blur行为Gate:
+
+| Gate | 结果 | 证据 |
+|---|---|---|
+| Brush/Opacity复用Effect | 通过 | EffectBuildCount保持1 |
+| Radius更新复用当前Effect | 通过 | EffectBuildCount=1,RadiusUpdateCount=1 |
+| 图层隔离 | 通过 | Background保持Black,Inactive保持Gray,Border保持Yellow |
+| 严格裁剪 | 通过 | Clip Bounds外可见像素为0 |
+| Glow跟随Content Scale | 通过 | Scale 1.0外扩5 DIP,Scale 0.5外扩3 DIP,比值0.600 |
+
+图层颜色探针按`ILockedFramebuffer.Format`区分RGBA/BGRA,不能假定固定字节顺序。严格裁剪Gate验证的是最终像素不越界;Padding和真实Matrix Border内缘仍需在正式控件接入前成对验证。
+
+### 五进程6000帧命令提交
+
+每个进程都重新执行672组像素Gate,然后对40x40 Circle、Radius 12、Opacity 0.65测量6000帧DrawingGroup命令提交。该数据仍不包含真实GPU展示成本。
+
+| 进程 | NoGlow μs/frame | 路线A μs/frame | Scoped Blur μs/frame |
+|---:|---:|---:|---:|
+| 1 | 7.58 | 45.43 | 11.38 |
+| 2 | 7.97 | 44.38 | 10.64 |
+| 3 | 7.99 | 46.76 | 12.21 |
+| 4 | 6.99 | 43.54 | 11.75 |
+| 5 | 7.21 | 43.93 | 11.98 |
+| Median | 7.58 | 44.38 | 11.75 |
+| P95(线性插值) | 7.99 | 46.50 | 12.16 |
+
+每帧托管分配在五个进程中一致:NoGlow `1520` bytes、路线A `9920` bytes、Scoped Blur `4008` bytes。相对NoGlow中位命令提交耗时,路线A约`5.85x`,Scoped Blur约`1.55x`;相对固定DrawingGroup基线的新增分配分别为`8400`和`2488` bytes/frame。
+
+这些分配主要来自原型每帧创建DrawingGroup和提交Effect/Opacity节点,尚未证明正式控件稳态分配契约。下一轮必须在真实控件复用Render路径和真实窗口渲染器中分离固定框架开销。
+
+### 第二轮结论
+
+- Scoped Blur通过本轮全部正确性Gate,继续作为主候选。
+- 路线A仍通过正确性Gate,但命令提交中位耗时和分配都明显高于Scoped Blur,仅保留工程保底资格。
+- 独立CPU路线B和Skia自定义路线仍无启动条件。
+- 仍未完成真实窗口GPU成本、正式控件缓存生命周期和Glow关闭零成本证明,因此不能宣布最终选型。
+
+## 桌面真实窗口原型入口
+
+新增独立工具项目:
+
+```text
+tools/performances/
+ AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/
+```
+
+该项目引用Performance试验程序集并通过friend assembly复用同一份路线A与Scoped Blur Renderer,不复制算法,也不引用或修改Labs运行时Glow实现。它并排展示:
+
+- 路线A与Scoped Blur。
+- Radius 6、12、24。
+- Circle、RoundedSquare、Segment diagonal和Colon dots。
+- 灰色Inactive、白色Active、青色Glow和黄色裁剪边框。
+- Radius 24下的严格Clip案例。
+
+运行:
+
+```powershell
+dotnet run --project tools\performances\AtomUI.Labs.Controls.Led.GlowPrototype.Desktop\AtomUI.Labs.Controls.Led.GlowPrototype.Desktop.csproj -c Release
+```
+
+Release构建为0警告、0错误。短时真实Win32进程Smoke保持运行5秒且未提前退出;该结果只证明桌面生命周期和窗口建立成功,不代表人工视觉验收或真实GPU性能已经通过。
+
+桌面原型代码不进入NuGet包。最终选型后,失败路线和只服务对比的桌面原型应删除;有长期价值的性能Case改为直接测试正式LedGlowRenderer和真实Matrix/Segment。
+
+## 第三轮:真实窗口Render回调压力Gate
+
+桌面原型新增`--benchmark`自动化入口。每个进程只运行一条路线,创建64个独立Geometry实例,预热30次Dispatcher Tick,再执行120次测量Tick。每次Tick使全部实例失效,窗口使用真实Win32 Avalonia后端;进程完成后自动退出并写出原始报告。
+
+该方法记录窗口Render回调和命令提交,但计时包含Dispatcher调度、失效合并、渲染线程及窗口后端工作,不是隔离的GPU计时。`Expected callbacks`是请求上限,不保证Avalonia必须逐请求绘制;完成率下降表示测量窗口内发生了失效合并或渲染未赶上请求速率,不等同于丢失业务状态。
+
+运行示例:
+
+```powershell
+dotnet run --project tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop.csproj -c Release --no-build -- --benchmark --route scoped --instances 64 --warmup 30 --ticks 120
+```
+
+### 五个独立进程
+
+| 路线 | 进程 | 耗时ms | Render回调完成率 | bytes/已完成回调 |
+|---|---:|---:|---:|---:|
+| NoGlow | 1 | 2449.16 | 100.00% | 632.9 |
+| NoGlow | 2 | 2493.62 | 100.00% | 639.4 |
+| NoGlow | 3 | 2498.92 | 100.00% | 632.8 |
+| NoGlow | 4 | 2314.19 | 100.00% | 632.9 |
+| NoGlow | 5 | 2581.39 | 100.00% | 660.7 |
+| Vector | 1 | 2456.07 | 99.17% | 1430.4 |
+| Vector | 2 | 2360.49 | 100.00% | 1429.9 |
+| Vector | 3 | 2435.24 | 100.00% | 1458.8 |
+| Vector | 4 | 2388.59 | 99.17% | 1431.7 |
+| Vector | 5 | 2958.37 | 85.00% | 1433.8 |
+| Scoped Blur | 1 | 2611.95 | 89.17% | 923.7 |
+| Scoped Blur | 2 | 2722.65 | 85.83% | 926.1 |
+| Scoped Blur | 3 | 2612.66 | 89.17% | 919.0 |
+| Scoped Blur | 4 | 2606.79 | 85.83% | 932.2 |
+| Scoped Blur | 5 | 2844.43 | 67.50% | 924.4 |
+| NoGlow中位数 | - | 2493.62 | 100.00% | 632.9 |
+| Vector中位数 | - | 2435.24 | 99.17% | 1431.7 |
+| Scoped Blur中位数 | - | 2612.66 | 85.83% | 924.4 |
+
+### 关闭路径契约
+
+五个NoGlow进程的Glow提交数均为0,证明该原型在关闭Glow时通过空Renderer分支完全跳过Glow命令提交。该证据只适用于桌面试验宿主;正式控件接入后必须在Matrix和Segment各自的Render路径重新证明,不能直接继承本结论。
+
+### 第三轮结论
+
+- Scoped Blur每次已完成回调的托管分配中位数比Vector低约35.4%,方向与Headless命令提交测试一致。
+- Scoped Blur的Render回调完成率中位数只有85.83%,低于Vector的99.17%;真实窗口证据不支持立即宣布Scoped Blur胜出。
+- 当前测试无法把Dispatcher失效合并、CPU命令构造、渲染线程和GPU Effect成本拆开,因此不根据耗时列做纯GPU性能推断。
+- NoGlow关闭路径契约通过,但仍没有正式控件零开销证据。
+- Gate结论为`BLOCKED FOR PRODUCTION INTEGRATION`:保留Scoped Blur主候选和Vector保底候选,禁止把Glow接入正式Matrix/Segment,直到完成帧呈现时间来源审计、不同实例规模拐点测试和长稳资源生命周期测试。
+
+## 第四轮:原因拆分与实例规模拐点
+
+基准入口增加以下变量:
+
+- `--instances`:1、4、16、32、64、128。
+- `--hz`:0表示静态观察,1表示时钟类低频更新,60表示高频压力。
+- `--radius`:0、6、12、24 DIP。
+- `--route`:NoGlow、Geometry、Opacity、Vector、Scoped。
+- `--topology batch`:在单一根Visual中绘制N个Geometry,减少多子Visual失效合并对原因定位的干扰。
+
+Cells拓扑的NoGlow也会随机出现低于100%的回调完成率,证明旧指标混入了子Visual失效合并和Dispatcher波动。后续原因定位使用Batch拓扑;Cells拓扑只保留为接近多控件场景的系统压力证据。
+
+### Batch拓扑实例阶梯
+
+单进程、60Hz、Radius 12的探索数据如下。它用于定位需要重复验证的区间,不作为统计稳定的最终百分比:
+
+| Geometry数量 | NoGlow | Vector | Scoped Blur |
+|---:|---:|---:|---:|
+| 1 | 85.00% | 93.33% | 93.33% |
+| 4 | 91.67% | 93.33% | 91.67% |
+| 16 | 90.00% | 83.33% | 90.00% |
+| 32 | 100.00% | 98.33% | 100.00% |
+| 64 | 98.33% | 100.00% | 88.33% |
+| 128 | 88.33% | 83.33% | 38.33% |
+
+由于Windows桌面调度存在明显单进程噪声,不能把1到32实例的非单调变化解释为算法复杂度。可信信号是Scoped在64到128规模重复出现明显下降,因此原因拆分聚焦这两个规模。
+
+### Effect与Radius拆分
+
+| Geometry数量 | Geometry | Opacity | Scoped R0 | Scoped R6 | Scoped R12 | Scoped R24 |
+|---:|---:|---:|---:|---:|---:|---:|
+| 64 | 86.67% | 95.00% | 88.33% | 91.67% | 85.00% | 86.67% |
+| 128 | 100.00% | 100.00% | 80.00% | 71.67% | 63.33% | 63.33% |
+
+128 Geometry时,Geometry和PushOpacity均完成100%,加入`PushEffect(BlurEffect Radius=0)`后降至80%。这证明主要固定压力从Effect作用域开始,不是Geometry或Opacity本身导致。Radius从0增至6后继续下降,但12到24没有继续下降;当前只能确认Effect固定成本和非零Blur均有影响,不能声称成本与Radius线性相关。
+
+### 静态与1Hz常见负载
+
+| 场景 | Geometry数量 | NoGlow | Scoped Blur |
+|---|---:|---:|---:|
+| 1Hz | 64 | 100% | 100% |
+| 1Hz | 128 | 100% | 100% |
+| 静态观察 | 128 | 0次持续Render、0次Glow提交 | 0次持续Render、0次Glow提交 |
+
+Scoped Blur在64和128 Geometry的1Hz低频场景完成全部请求;静态场景没有持续Render或Glow提交。该结果支持时钟、仪表盘等低频LED显示的可行性,但不解除高频动画场景的规模限制。
+
+### 当前原因判断与边界
+
+- 已排除Geometry和PushOpacity是128 Geometry高频下降的主要原因。
+- 已定位到Scoped Effect作用域存在显著固定压力,非零Blur进一步增加压力。
+- 60Hz压力下的实用拐点位于32到64个Scoped Geometry之间;精确上限仍需围绕该区间做多进程重复。
+- 1Hz下至少128 Geometry稳定,静态状态不持续重绘。
+- 生产集成仍保持阻断:下一Gate是10分钟长稳、窗口反复创建关闭、Brush/Opacity/Radius切换、Glow启停和对象可回收验证。
+
+## 第五轮:生命周期与长稳Gate
+
+桌面原型新增`--lifecycle`模式,由协调窗口反复创建和关闭真实Win32子窗口。每个子窗口包含64个Scoped Glow Renderer,并在60Hz Tick中循环执行:
+
+- Glow启用与关闭。
+- Cyan与Magenta Brush切换。
+- Opacity按0、0.2、0.4、0.6、0.8循环。
+- Radius按0、6、12、24循环。
+- Renderer复用同一BlurEffect并更新状态。
+- 子窗口关闭时停止Timer、解绑Tick/Open/Closed事件并清空Content。
+
+协调器对Window和Batch Surface只持有`WeakReference`,每20个窗口执行三轮`GC.Collect + WaitForPendingFinalizers`并记录稳定托管内存。最后静置500ms,避免把Closed事件调用栈和渲染后端延迟释放误判为泄漏。
+
+### 校准
+
+20窗口、每窗口16实例、每窗口2 Tick的首次即时GC判定残留最后1个Window和Surface。增加关闭后500ms静置后重新校准,残留均为0。这说明生命周期判定必须等待关闭事件调用栈和后端清理完成,不能在`Closed`回调内部立即断言回收。
+
+### 长时运行与完整收尾
+
+首次正式配置为600窗口、每窗口64实例、每窗口60 Tick。进程持续754秒无崩溃或未处理异常,但在外部命令超时前没有完成600窗口,因此该次运行只能作为超过12分钟的持续稳定性Smoke,不能作为最终弱引用回收证据。
+
+随后运行可完整收尾的100窗口轮次:
+
+| 指标 | 结果 |
+|---|---:|
+| 窗口周期 | 100 |
+| 每窗口Renderer | 64 |
+| 每窗口状态变更Tick | 60 |
+| Retained Window | 0 |
+| Retained Surface | 0 |
+| 稳定内存采样 | 918632、914480、916800、920920、921896 bytes |
+| 首尾差值 | +3264 bytes |
+| 弱引用生命周期契约 | PASS |
+
+稳定内存先下降再小幅波动,不是逐采样单调增长;首尾3264 bytes只占首个采样约0.36%。RSS未作为泄漏判据。Window和Surface全部回收,间接证明其持有的Renderer、BlurEffect、Brush和Geometry对象图没有被原型的托管引用永久保留。
+
+### 第五轮结论
+
+- 超过12分钟的真实窗口高频运行没有崩溃,但因外部超时不提供最终回收结论。
+- 完整100窗口轮次通过弱引用和稳定托管内存Gate。
+- 原型级Scoped Glow没有发现Window、Surface或Renderer对象图泄漏。
+- 该结论不自动适用于正式Matrix/Segment。正式接入后必须重新覆盖控件Attach/Detach、Glow关闭路径、Theme/Token资源绑定和窗口关闭回收。
+- 生命周期阻断项已解除;高频大规模Effect成本限制仍然存在。
+
+## 第六轮:Effect粒度最终选型
+
+在Scoped Blur路线内比较四种组织粒度:
+
+- `PerGeometry`:每个Active Geometry一次`PushEffect`,作为旧基线。
+- `PerControl`:每个控件一次`PushEffect`,在作用域内绘制全部已剔除的可见Active Geometry。
+- `Batch8`、`Batch16`:每8或16个Geometry一次Effect,用于验证大Bounds是否需要折中。
+
+相邻Active Geometry的Glow允许自然叠加融合。四种粒度每帧提交相同数量的Glow Geometry;32 Geometry计数校准中,每帧Effect作用域分别为32、1、4、2,证明比较没有通过减少源Geometry作弊。
+
+### 五进程核心结果
+
+| Geometry | 粒度 | 中位测量窗口ms | P95测量窗口ms | 中位回调完成率 | bytes/完成回调 |
+|---:|---|---:|---:|---:|---:|
+| 64 | PerGeometry | 2458.13 | 2909.66 | 99.17% | 35879.4 |
+| 64 | PerControl | 2235.28 | 2472.88 | 100.00% | 25835.7 |
+| 64 | Batch8 | 2401.19 | 2696.73 | 94.17% | 27717.9 |
+| 64 | Batch16 | 2547.63 | 2666.24 | 90.83% | 26813.8 |
+| 128 | PerGeometry | 2812.54 | 2911.08 | 46.67% | 65502.5 |
+| 128 | PerControl | 2521.92 | 2647.78 | 90.83% | 44485.1 |
+| 128 | Batch8 | 2687.26 | 2712.70 | 90.00% | 48375.7 |
+| 128 | Batch16 | 2530.93 | 2684.96 | 88.33% | 46355.4 |
+
+64 Geometry下,PerControl相对PerGeometry中位测量窗口降低约9.1%、P95降低约15.0%、完成回调分配降低约28.0%,Effect作用域从每帧64个降为1个。128 Geometry下,回调完成率中位数提高44.16个百分点,分配降低约32.1%,Effect作用域从每帧128个降为1个。
+
+这些真实窗口耗时仍包含Dispatcher和渲染后端,不描述为纯GPU时间。Effect作用域和分配是结构性证据;完成率是相同机器、相同请求策略下的系统压力结果。
+
+### 稀疏Bounds反证测试
+
+64 Geometry间距从40 DIP扩大到80 DIP后运行五进程:
+
+| 粒度 | 中位窗口ms | P95窗口ms | 中位完成率 | bytes/完成回调 |
+|---|---:|---:|---:|---:|
+| PerControl | 2494.54 | 2664.30 | 93.33% | 25944.6 |
+| Batch8 | 2557.41 | 2617.01 | 92.50% | 27766.4 |
+| Batch16 | 2611.72 | 2628.34 | 92.50% | 26769.9 |
+
+PerControl的P95比最佳Batch16高约1.4%,没有达到预先约定的15%改选阈值;其中位完成率和分配仍为三者最佳。因此不引入Batch运行时复杂度。
+
+### 上限与正确性
+
+- 256 Geometry单进程压力Smoke:PerGeometry完成率27.50%,PerControl 91.67%,Batch8/16均为83.33%。该单进程结果只证明继续聚合的方向,不作为稳定百分比。
+- PerControl在Radius 0、6、12、24下均可运行;128 Geometry、Radius 24、1Hz完成率100%,静态为0 Render、0 Glow提交、0 Effect作用域。
+- Headless分组像素Gate覆盖100%、125%、150%、200% RenderScaling和Radius 6、12、24,共12/12通过。
+- 原有672组像素、Brush、图层隔离、严格裁剪和Scale Gate继续全部通过。
+
+### 最终粒度决议
+
+- 正式候选固定为`PerControl Scoped Blur Effect`。
+- 正式Renderer先执行包含GlowRadius的可见性剔除,再在一个Effect作用域内绘制当前控件全部可见Active Geometry。
+- 相邻Geometry的Glow允许自然融合;退出Effect后重新绘制清晰Active本体。
+- `PerGeometry`、`Batch8`和`Batch16`不进入正式运行时,不增加粒度公开属性,也不根据运行负载动态切换算法。
+- 该决议结束技术路线与Effect粒度游移。下一阶段直接设计共享`LedGlowRenderer`并接入Matrix/Segment。
+
+## 第七轮:正式控件接入
+
+Scoped Blur 与 PerControl 粒度已进入 `AtomUI.Labs.Controls.Led` 正式运行时。共享实现位于 `Glow/`,Matrix 和 Segment 不复制 Effect 创建、数值规整或作用域释放逻辑。
+
+正式公共契约:
+
+| 控件 | 属性 | 默认值 | 有效语义 |
+|---|---|---:|---|
+| MatrixDisplay | `GlowBrush` | `null` | null关闭Glow |
+| MatrixDisplay | `GlowOpacity` | `0.35` | 内部规整为0..1 |
+| MatrixDisplay | `GlowRadius` | `6` | 内部规整为0..24 DIP,0关闭 |
+| SegmentDisplay | `GlowBrush` | `null` | null关闭Glow |
+| SegmentDisplay | `GlowOpacity` | `0.35` | 内部规整为0..1 |
+| SegmentDisplay | `GlowRadius` | `6` | 内部规整为0..24 DIP,0关闭 |
+
+原始StyledProperty值不被内部规整回写。NaN和正负Infinity按0处理。Segment旧同形叠色Glow已删除,不保留兼容开关。
+
+正式Render顺序固定为Background、Inactive、一次Scoped Glow、清晰Active、Matrix Border。Matrix和Segment均先按内容视口加有效GlowRadius执行可见字符剔除,再计算可见Active Geometry联合Bounds;不可见长文本不进入Effect。Glow处于现有内容Clip和布局Transform中,不参与Measure,ScaleDown同时缩放Geometry和Glow语义。
+
+关闭路径采用惰性Renderer:控件构造和`GlowBrush=null`稳态不创建`LedGlowRenderer`或`BlurEffect`,不提交Effect作用域。首次有效Glow创建一个BlurEffect;Brush、Opacity和Radius变化复用同一实例。`GlowBrush`清回null时释放Renderer及其BlurEffect引用。
+
+正式测试从334项增加到371项,新增覆盖:
+
+- Matrix与Segment公共属性名称、默认值和配置值。
+- Opacity 8组、Radius 11组数值边界。
+- Glow关闭零Effect、单Render单Effect、Brush/Opacity/Radius复用Effect。
+- GlowBrush清空释放Renderer状态。
+- Matrix与Segment在100%/200% RenderScaling、Radius 6/24下的8组真实像素比较。
+- Matrix Border像素保持不变,Active白色核心保持清晰。
+
+Labs Sample增加Matrix默认关闭、Radius 6/12/24和多色Glow案例,以及Segment Radius 6/12/24案例。Release构建和真实Win32窗口8秒启动Smoke通过。
+
+## 第八轮:正式控件性能门禁
+
+性能工具新增`--formal-glow`入口,直接测量正式`MatrixDisplay`和`SegmentDisplay`,不再以原型Renderer代替。固定文本为16字符、视口800x140,覆盖:
+
+- `DisabledNullBrush`:`GlowBrush=null`。
+- `DisabledZeroOpacity`:Brush非空、`GlowOpacity=0`。
+- `Static`:Radius 6、Opacity 0.35。
+- `OpacityAnimation`:0..1循环。
+- `RadiusAnimation`:0..24往返。
+
+每场景先执行100帧控件预热;完整测量函数再预热1000帧以跨过Tiered JIT阈值,正式测量6000帧。首批只预热10帧的数据出现第一个Matrix NullBrush场景异常偏慢,确认为顺序/JIT污染并作废,不进入正式结论。
+
+### 五进程正式结果
+
+| 控件 | 模式 | Median μs/frame | P95 μs/frame | bytes/frame |
+|---|---|---:|---:|---:|
+| Matrix | DisabledNullBrush | 86.56 | 96.44 | 88560.0 |
+| Matrix | DisabledZeroOpacity | 91.97 | 99.52 | 88560.0 |
+| Matrix | Static | 139.24 | 143.57 | 132352.0 |
+| Matrix | OpacityAnimation | 141.16 | 151.32 | 132067.1 |
+| Matrix | RadiusAnimation | 138.02 | 151.18 | 130717.3 |
+| Segment | DisabledNullBrush | 138.30 | 152.72 | 169104.0 |
+| Segment | DisabledZeroOpacity | 144.66 | 163.65 | 169104.0 |
+| Segment | Static | 208.59 | 229.40 | 242760.0 |
+| Segment | OpacityAnimation | 214.94 | 218.80 | 242226.2 |
+| Segment | RadiusAnimation | 210.41 | 220.13 | 237992.9 |
+
+数据是Headless Skia下DrawingGroup命令提交,包含DrawingGroup和GeometryDrawing分配,不是隔离GPU呈现成本。动画模式经过Opacity或Radius为0的帧会跳过Glow,因此其平均分配可能略低于静态Glow,不能解释为动画比静态渲染更便宜。
+
+### 关闭路径配对门禁
+
+为排除同进程前后环境漂移,每轮在同一循环内交替执行NullBrush与ZeroOpacity,并逐帧使用`Stopwatch.GetTimestamp`累计。五进程结果:
+
+| 控件 | Median slower/faster | P95 | 最大值 | 1.05x Gate |
+|---|---:|---:|---:|---|
+| Matrix | 1.002 | 1.010 | 1.012 | PASS |
+| Segment | 1.002 | 1.041 | 1.049 | PASS |
+
+两个关闭模式的`bytes/frame`、Geometry命令数完全相同,EffectBuildCount和EffectScopeCount均为0。静态Glow在预热后的EffectBuild增量为0,每个正式测量帧恰好一个Effect作用域。Opacity与Radius动画不重建BlurEffect;作用域数只在有效值为0的帧减少。
+
+运行方式:
+
+```powershell
+dotnet run --project tools/performances/AtomUI.Labs.Controls.Led.Performance/AtomUI.Labs.Controls.Led.Performance.csproj -c Release --no-build -- --formal-glow --frames 6000 --markdown output/formal-glow.md
+```
+
+## 第九轮:正式控件真实Win32窗口门禁
+
+桌面性能宿主新增`--formal-controls`,真实创建`MatrixDisplay`或`SegmentDisplay`控件,而不是绘制原型Geometry。模式语义严格分离:
+
+- `noglow`:Glow关闭,只请求重绘。
+- `static`:静态Glow,只请求重绘。
+- `opacity`:只改变GlowOpacity。
+- `radius`:只改变GlowRadius。
+- `dynamictext`:静态Glow并改变Text,包含布局和Geometry更新成本。
+
+早期校准把Static与动态Text绑定,名称与负载不一致,该批数据作废。修正后窗口固定为1600x1100,确保50实例均处于可见布局区域。每场景预热30 Tick、测量120 Tick;10实例核心场景运行5个独立进程。
+
+### 10实例、60Hz五进程
+
+| 控件 | 模式 | 中位Render回调完成率 | P5完成率 | bytes/完成回调中位数 |
+|---|---|---:|---:|---:|
+| Matrix | NoGlow | 100.00% | 99.34% | 3447.1 |
+| Matrix | Static | 99.17% | 95.83% | 4795.0 |
+| Matrix | Opacity | 100.00% | 100.00% | 4857.6 |
+| Matrix | Radius | 100.00% | 100.00% | 4884.5 |
+| Matrix | DynamicText | 100.00% | - | 5317.6 |
+| Segment | NoGlow | 61.67% | 57.00% | 6699.6 |
+| Segment | Static | 63.33% | 54.83% | 8618.8 |
+| Segment | Opacity | 61.67% | 60.17% | 8729.1 |
+| Segment | Radius | 78.33% | 70.50% | 8714.4 |
+| Segment | DynamicText | 63.33% | - | 9894.6 |
+
+Render回调完成率表示测量窗口内实际Render回调与失效请求上限之比。低于100%说明Avalonia合并失效或渲染未赶上请求速率,不表示最终属性状态丢失。Radius动画经过0附近时会跳过Effect,因此Segment Radius完成率高于持续有效Glow,不能解释为Radius动画本身更便宜。
+
+Segment在10实例60Hz时,NoGlow、Static和DynamicText均处于约62%同一量级;主要压力来自每个Segment控件基础层约179个Geometry命令,Glow不是独立性能断崖。Matrix在相同条件下全部核心模式接近或达到100%。
+
+### 实例阶梯与常见负载
+
+单进程60Hz探索:
+
+| 实例 | Matrix NoGlow | Matrix Static | Segment NoGlow | Segment Static |
+|---:|---:|---:|---:|---:|
+| 1 | 99.17% | 96.67% | 100.00% | 100.00% |
+| 10 | 96.67% | 98.33% | 51.67% | 50.83% |
+| 50 | 65.83% | 50.83% | 12.50% | 10.00% |
+
+该表是拐点探索,不作为统计稳定百分比。50实例、1Hz下Matrix/Segment的NoGlow与Static四个场景均完成100%,支持时钟和仪表盘等低频常见负载。50实例60Hz属于明确压力场景,不应承诺满帧。
+
+运行示例:
+
+```powershell
+dotnet run --project tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop.csproj -c Release --no-build -- --formal-controls --control matrix --mode opacity --instances 10 --hz 60 --warmup 30 --ticks 120
+```
+
+## 第十轮:Segment基础Geometry命令聚合
+
+真实窗口门禁定位到Segment在NoGlow和Glow下均受大量Geometry命令限制。旧实现对每个段分别提交命令:16字符正式性能场景中NoGlow为179条、Glow为258条。优化后按当前可见范围构建并缓存两个`GeometryGroup`:
+
+- Inactive层聚合为一个Geometry命令。
+- Active层聚合为一个Geometry命令,同一Geometry同时供Glow和清晰Active绘制。
+- Background仍独立;因此无背景正式性能场景NoGlow为2条、Glow为3条。
+- 仍然先按视口加GlowRadius剔除,100与1000字符窄视口命令数保持一致。
+
+聚合缓存键包含LayoutCacheVersion、GeometryCacheVersion和可见索引范围。Brush、GlowBrush、Opacity和Radius变化不重建聚合Geometry;Text模式变化重建Active聚合但复用基础段Geometry。
+
+第一轮同时重建Active与Inactive聚合,虽然静态和动画性能显著改善,但DynamicText分配从优化前约9894.6上升到22394 B/完成回调,因此不能收工。第二轮把缓存所有权拆开:Inactive仅由GeometryVersion和可见范围决定,同槽位数字文本变化继续复用;Active随LayoutVersion更新。不保留历史文本缓存,长期只持有当前Active和当前Inactive各一代。
+
+### Headless五进程结果
+
+| 模式 | 优化前Median μs/frame | 优化后Median | 优化前bytes/frame | 优化后bytes/frame | 命令数 |
+|---|---:|---:|---:|---:|---:|
+| DisabledNullBrush | 138.30 | 11.64 | 169104.0 | 5472.0 | 179→2 |
+| DisabledZeroOpacity | 144.66 | 11.30 | 169104.0 | 5472.0 | 179→2 |
+| Static | 208.59 | 11.56 | 242760.0 | 7768.0 | 258→3 |
+| OpacityAnimation | 214.94 | 14.50 | 242226.2 | 8396.9 | 258→3 |
+| RadiusAnimation | 210.41 | 16.39 | 237992.9 | 9219.9 | 258→3 |
+
+命令结构降幅约98.8%;静态Glow中位命令提交耗时降低约94.5%,分配降低约96.8%。聚合后单帧耗时进入约5到16微秒区间,原逐帧时间戳配对门禁分辨率不足并出现一次1.078误报;改为每个时间戳测16帧批次后,五进程关闭门禁全部通过,最大slower/faster为1.040。
+
+### 正式Win32窗口10实例、60Hz五进程
+
+| 模式 | 优化前中位完成率 | 优化后 | 提升 | 优化前bytes/回调 | 优化后 | 分配改善 |
+|---|---:|---:|---:|---:|---:|---:|
+| NoGlow | 61.67% | 91.67% | +30.00pp | 6699.6 | 1474.6 | 78.0% |
+| Static | 63.33% | 87.50% | +24.17pp | 8618.8 | 1831.3 | 78.8% |
+| Opacity | 61.67% | 88.33% | +26.66pp | 8729.1 | 1916.5 | 78.0% |
+| Radius | 78.33% | 90.83% | +12.50pp | 8714.4 | 1966.1 | 77.4% |
+| DynamicText | 63.33% | 81.67% | +18.34pp | 9894.6 | 8893.9 | 10.1% |
+
+生命周期测试证明Pattern变化后旧Active聚合可回收、Inactive聚合复用;Geometry参数变化后旧Inactive聚合可回收。Segment专项测试146项、Labs全量376项、Sample、win-x64 NativeAOT和真实窗口Smoke均通过。
+
+## 第十一轮:视觉验收工作台与动画契约
+
+Labs Sample新增`GlowWorkbench`工程验收面板,同屏显示Matrix与Segment,并提供:
+
+- Enabled开关;关闭时取消动画并设置`GlowBrush=null`。
+- Cyan、Red、Magenta、Green、Amber五种Brush;Red使用高饱和颜色,便于直观确认Brush切换生效。
+- Opacity 0..1滑块。
+- Radius 0..24滑块。
+- Static、Breathe、Pulse三种模式。
+
+工作台不增加Labs公共API。动画直接使用Avalonia`Animation`和现有`GlowOpacity`、`GlowRadius` StyledProperty,不使用DispatcherTimer或Sample自制插值器。切换配置先取消并释放旧CancellationTokenSource,窗口关闭时解除控件事件并取消动画。
+
+动画周期与参数关系为:
+
+```text
+Breathe: 2.4秒循环,Opacity 用户值×35% -> 用户值 -> 用户值×35%
+ Radius 保持用户值
+Pulse: 1.2秒循环,Opacity 用户值×50% -> 用户值 -> 用户值×50%
+ Radius 用户值×50% -> 用户值 -> 用户值×50%
+```
+
+初版默认模式为Breathe,使Sample启动Smoke实际覆盖无限动画运行和取消路径。真实Win32窗口持续10秒未提前退出,关闭时无异常;Sample Release和win-x64 NativeAOT发布通过。
+
+交互复核发现初版动画关键帧使用固定Opacity和Radius,导致动画优先级覆盖滑块值,Workbench展示的可调契约与实际行为不一致。修正后默认模式改为Static,所有选项直接映射到控件属性;Breathe以用户Opacity的35%到100%循环并保持用户Radius,Pulse以用户Opacity和Radius的50%到100%循环。动画模式不再忽略用户参数。
+
+人工视觉验收需要在工作台中比较同一参数下Matrix和Segment,重点观察深色背景上的灯珠融合、Segment斜段边缘、Radius 12以上的字符间融合以及Opacity 0.6附近是否刺眼。自动像素测试继续负责Active核心清晰、Border不受Blur和DPI裁剪,不替代审美判断。
diff --git a/docs/controls/led/glow-technical-options.md b/docs/controls/led/glow-technical-options.md
new file mode 100644
index 0000000..c7622f7
--- /dev/null
+++ b/docs/controls/led/glow-technical-options.md
@@ -0,0 +1,97 @@
+# LED Glow 当前技术合同
+
+> 文档状态:当前实现与发布契约,更新于 2026-07-20。候选路线、原型过程和旧性能数据见 [LED Glow 原型评估](glow-prototype-evaluation.md),不作为当前运行时事实。
+
+## 当前结论
+
+Segment 与 Matrix 使用同一个内部 `Glow/LedGlowRenderer.cs`。正式路线是 Avalonia 公共 API `DrawingContext.PushEffect(BlurEffect, bounds)`:控件先生成并剔除可见 Active Geometry,在一个局部 Effect 作用域内用 `GlowBrush` 绘制一次,再退出作用域绘制清晰 Active 本体。
+
+当前实现没有应用层 Alpha Mask、离屏位图、LRU、字符级 Glow 缓存、`MaskBuildCount` 或 `BlurBuildCount`。这些概念只属于被否决或未采用的历史候选路线。
+
+```text
+Background / Inactive
+ -> 已剔除的可见 Active Geometry
+ -> PushOpacity(GlowOpacity)
+ -> PushEffect(共享 BlurEffect, Active Bounds)
+ -> GlowBrush 绘制 Active Geometry
+ -> 退出 Effect
+ -> ActiveBrush 绘制清晰本体
+ -> Border(Matrix)
+```
+
+Glow 是可选视觉增强,不参与字符映射、字模、SegmentParts、Measure 或 DesiredSize。关闭 Glow 后,两个基础控件必须保持完整可用。
+
+## 公共属性
+
+Segment 与 Matrix 分别注册同名、同类型、同默认值的 StyledProperty:
+
+| 属性 | 类型 | 默认值 | 内部有效值 |
+|---|---|---:|---|
+| `GlowBrush` | `IBrush?` | `null` | `null` 表示硬关闭 |
+| `GlowOpacity` | `double` | `0.35` | 非有限值回退默认值,之后限制到 `0..1` |
+| `GlowRadius` | `double` | `6` | 非有限值回退默认值,之后限制到 `0..24` DIP |
+
+规整只影响内部有效值,不回写 StyledProperty。当前不公开 `GlowEnabled`、Offset、Spread、Quality、RenderMode、LayerCount 或专用动画属性。
+
+## 绘制与资源语义
+
+- `GlowBrush=null`、有效 `GlowOpacity=0`、有效 `GlowRadius=0` 或无可用 Active Bounds 时不建立 Effect 作用域。
+- `GlowBrush=null` 时控件释放其 `LedGlowRenderer` 引用;这是资源和语义上的硬关闭。
+- `GlowOpacity=0` 只暂停 Effect 提交,并允许保留已经创建的 `BlurEffect`,避免呼吸动画经过零点时重建。
+- 每个控件实例最多持有一个 `LedGlowRenderer` 和一个可复用 `BlurEffect`;没有全局静态资源表。
+- 每个控件每帧最多建立一个 Glow Effect 作用域,不逐段、逐点或逐字模建立 Effect。
+- `GlowBrush`、`GlowOpacity` 和 `GlowRadius` 不进入 Segment/Matrix 基础 Geometry 缓存键。
+- Brush 或 Opacity 变化不得重建基础 Geometry;Radius 变化只更新复用 Effect 的半径。
+- Effect Bounds 必须有限且具有正面积。异常 Bounds 跳过 Glow,但不得阻止清晰 Active 本体绘制。
+- Glow 由现有内容裁剪约束:Matrix 不越过内容视口/边框内缘,Segment 不越过控件 Bounds。开发者使用 Padding 为完整光晕预留空间。
+
+## 失效与生命周期
+
+Glow 属性只触发重绘,不触发 Measure。`GlowBrush` 变为 `null` 时立即丢弃 Renderer;控件不可达后 Renderer 和 Effect 随实例回收。Glow 不创建计时器、不订阅事件,也不拥有动画生命周期。动画由 Avalonia Animation 修改现有 StyledProperty。
+
+## 自动化验证
+
+必须覆盖:
+
+- 三项公共属性的名称、类型、默认值、CLR wrapper 和 AXAML 转换。
+- `NaN`、正负 Infinity、负数、零、边界值和极大有限值的内部规整。
+- Background、Inactive、Active、Glow 和 Border 的图层隔离。
+- null Brush、零 Opacity、零 Radius 和无效 Bounds 均不建立 Effect。
+- 静态 Glow 预热后不新增 Effect;每帧恰好一个 Effect scope。
+- Brush、Opacity、Radius 动画不重建基础 Geometry,缓存数量不随帧数增长。
+- Matrix 可见字形剔除包含有效 GlowRadius,长文本工作量受视口约束。
+- Segment 使用一份可见 Active 聚合 Geometry 完成一次 Glow 和一次清晰本体绘制。
+
+## 性能门禁
+
+`tools/performances/AtomUI.Labs.Controls.Led.Performance --formal-glow` 是当前正式命令提交基准。它测量 DrawingGroup 构建和命令提交,不宣称代表 GPU 呈现时间。
+
+PR 级单进程门禁:
+
+- 默认预热后测量至少 6000 帧。
+- 两种关闭路径的 Effect builds 和 Effect scopes 都必须为 0。
+- 静态 Glow 预热后 Effect builds 必须为 0,Effect scopes 必须等于测量帧数。
+- runner 在同一进程内执行 5 次交错顺序的配对测量,并以倍率中位数判定。以 `GlowBrush=null` 为硬关闭基线;零 Opacity 路径仅在耗时超过基线 `1.05x` 且新增耗时同时超过 `0.25 us/frame` 时失败。绝对阈值用于避免 Segment 约数微秒基线把亚微秒计时噪声放大成虚假倍率回归。
+- 零 Opacity 相对 null Brush 的稳态新增分配必须同时不超过基线的 `2.5%` 和 `4096 bytes/frame`。两条路径语义不同:null Brush 会释放 Renderer,零 Opacity 允许保留 Effect,因此不要求逐字节相等;零 Opacity 低于基线不视为回归。
+
+发布级门禁:
+
+- 使用 Release 构建,至少运行 5 个独立进程。
+- 报告耗时倍率和分配差异的 Median,并保留每个进程的原始结果。
+- Median 必须满足上述耗时和分配阈值;单个进程的偶发超限要记录,但不替代 Median 判定。
+- 额外执行 100000 帧静态/动态长稳、固定长度文本更新、拓扑变化和完整回收检查;缓存、Effect 数和存活内存不得随历史帧数或文本无界增长。
+
+## 发布失败条件
+
+以下任一情况阻止发布:
+
+- Glow 关闭仍提交 Effect、创建后端 Effect 或增加 Glow 绘制命令。
+- 静态 Glow 在稳态帧重复创建 Effect。
+- 一个控件一帧建立多个逐点、逐段或逐字模 Effect scope。
+- Background、Inactive 或 Border 被纳入 Glow。
+- Glow 属性变化破坏基础 Geometry 缓存或影响 DesiredSize。
+- Release 五进程 Median 超过正式耗时或分配阈值。
+
+## 历史路线边界
+
+多层矢量扩张、应用层 Alpha Mask、离屏 Blur、LRU 和 Skia 自定义绘制曾作为候选方案评估。它们的工程约束和原型证据保留在 [LED Glow 原型评估](glow-prototype-evaluation.md) 及带日期的性能文档中。除非形成新的书面技术决议,否则不得把这些历史设计描述为当前实现,也不得为满足旧路线指标而在运行时引入 Mask 或缓存系统。
diff --git a/docs/controls/led/matrix-implementation.md b/docs/controls/led/matrix-implementation.md
new file mode 100644
index 0000000..f143a3f
--- /dev/null
+++ b/docs/controls/led/matrix-implementation.md
@@ -0,0 +1,659 @@
+# LED Matrix 工业级实现原理
+
+> 文档状态:当前实现契约,更新于 2026-07-20。性能数值链接指向带日期的历史证据,不代表当前机器基线。
+
+本文记录 `AtomUI.Labs.Controls.Led.Matrix` 的当前实现设计。Matrix 是 LED 家族中的点阵屏路线,不是字体控件,不是 Segment 的升级版,也不是硬件 LED 控制器。
+
+公共控件类型为 `MatrixDisplay`,基础显示为单行 `5x7` 等宽点阵文本,并可选启用 Glow 和单向穿屏 Marquee。
+
+## 核心链路
+
+```text
+原始 Text
+ -> Unicode Rune 遍历和 ASCII 字符规范化
+ -> 5x7 字模映射和 fallback
+ -> 字符格布局测量
+ -> 最终空间下的对齐或缩小变换
+ -> 查询或生成字模亮暗 Geometry
+ -> DrawingContext 按字模批量绘制
+ -> Avalonia 底层渲染
+```
+
+每一层只承担自己的职责:
+
+- 字符规范化只处理 ASCII 小写转大写。
+- 字模映射只回答一个字符对应哪些点位。
+- 布局只计算字符格原点和整体理想尺寸。
+- 几何工厂把35个点位互斥分流到亮点和暗点 Geometry。
+- 绘制按可见字符提交每字模最多两个 Geometry 命令。
+- Avalonia 负责抗锯齿、DPI、render scale 和底层平台渲染。
+
+字符判断、bit 解析、坐标计算和 `DrawingContext` 调用不得混成一组字符专用分支。
+
+## 工程目录
+
+Matrix 按当前真实职责组织:
+
+```text
+src/AtomUI.Labs.Controls.Led/Matrix/
+ MatrixDisplay.cs
+ MatrixDisplayAutomationPeer.cs
+ MatrixDotShape.cs
+ MatrixOverflowMode.cs
+ MatrixValueSanitizer.cs
+ Character/
+ MatrixCharacterMap.cs
+ MatrixCharacterPattern.cs
+ MatrixFiveBySevenGlyphMap.cs
+ MatrixGlyph.cs
+ Layout/
+ MatrixDisplayLayout.cs
+ MatrixGlyphSlot.cs
+ MatrixLayoutEngine.cs
+ MatrixLayoutOptions.cs
+ Rendering/
+ MatrixGlyphGeometry.cs
+ MatrixGlyphGeometryCacheKey.cs
+ MatrixGlyphGeometryFactory.cs
+ MatrixDotShapeResolver.cs
+ MatrixPanelBorderGeometryFactory.cs
+ Themes/
+ MatrixThemes.axaml
+ MatrixDisplayTheme.axaml
+```
+
+`MatrixDisplay.cs` 保留完整公共契约、生命周期、`MeasureOverride`、`Render` 和属性失效入口。内部字模和布局进入对应稳定职责目录。
+
+主题聚合链固定为:
+
+```text
+LedThemesProvider.axaml
+ -> Themes/LedThemes.axaml
+ -> Matrix/Themes/MatrixThemes.axaml
+ -> Matrix/Themes/MatrixDisplayTheme.axaml
+```
+
+第一版不创建 Provider、FontSet、Primitives 或 Shared 目录。`Rendering/` 是性能基线证明逐点命令提交成本后形成的真实职责边界,不是为了机械复制 Segment。
+
+## 5x7 的含义
+
+一个字符使用横向 5 列、纵向 7 行的点阵字符格,共 35 个逻辑点位。例如 `A`:
+
+```text
+01110
+10001
+10001
+11111
+10001
+10001
+10001
+```
+
+`1` 表示亮点,`0` 表示暗点。多个字符由多个 `5x7` 字符格横向排列,并在字符格之间加入 `CharacterSpacing`。
+
+必须区分两个概念:
+
+```text
+Glyph Resolution
+ 固定为 5 列 x 7 行,属于字模数据合同。
+
+DotSize
+ 每个点轮廓外接框的边长,单位是DIP;Circle下同时是圆直径。
+```
+
+用户可以配置 `DotSize` 和间距,但不能通过 `GlyphWidth`、`GlyphHeight` 把 `5x7` 字模改成另一种规格。第一版不公开这两个属性。
+
+## 字符合同
+
+第一版内置 45 个字模:
+
+```text
+ABCDEFGHIJKLMNOPQRSTUVWXYZ
+0123456789
+空格
+? - . : _ + = /
+```
+
+规则固定为:
+
+```text
+a-z -> A-Z
+受支持字符 -> 对应字模
+每个不受支持的 Unicode 标量 -> 一个 ?
+每个非法 UTF-16 代理项 -> 一个 ?
+null 或空字符串 -> 没有字符格
+```
+
+`?` 是字模集的强制成员,因此未知字符 fallback 不需要运行时寻找第二候选。空格使用 35 个全灭点位,但仍占据一个完整等宽字符格。
+
+输入遍历以 `System.Text.Rune` 为边界,不以 UTF-16 `char` 数量作为显示字符数量。例如 `A😀Z` 必须形成 `A?Z` 三个字符格,不能把一个补充平面字符拆成两个 fallback。布局槽位、自动化名称和实际绘制必须复用同一 Rune 映射合同。
+
+第一版不支持:
+
+- 中文、CJK 和复杂脚本。
+- 小写字母独立字形。
+- 多行文本和自动换行。
+- 滚动字幕、闪烁和复杂动画。
+
+## 字模数据结构
+
+固定 `5x7` 只有 35 bit,使用单个 `ulong` 表达完整字模:
+
+```csharp
+internal readonly record struct MatrixGlyph(ulong Bits)
+{
+ public bool IsActive(int row, int column)
+ {
+ var bitIndex = row * 5 + column;
+ return ((Bits >> bitIndex) & 1UL) != 0;
+ }
+}
+```
+
+bit 顺序必须固定:
+
+```text
+左上角 = bit 0
+第一行最右侧 = bit 4
+第二行最左侧 = bit 5
+右下角 = bit 34
+```
+
+也就是严格使用 row-major 索引:
+
+```text
+bitIndex = row * 5 + column
+```
+
+不要使用 `ReadOnlyMemory` 或每次查询时创建行数组。`ulong` 是不可变值,具备稳定的值相等性,也不会为每次字模查询分配数组。
+
+字模常量应由可读的内部构造辅助在静态初始化阶段打包,绘制热路径只读取 `Bits`。
+
+## 字模映射边界
+
+第一版只有一套固定字模,不创建 `IMatrixGlyphProvider`。字模知识由内部静态映射拥有:
+
+```csharp
+internal static class MatrixFiveBySevenGlyphMap
+{
+ public const int Width = 5;
+ public const int Height = 7;
+
+ public static bool TryGetGlyph(char character, out MatrixGlyph glyph)
+ {
+ // Supported normalized character -> fixed 5x7 glyph
+ }
+}
+```
+
+`MatrixFiveBySevenGlyphMap` 只拥有 45 个受支持字符的原始 bit 数据,不处理大小写或 fallback。`MatrixCharacterMap` 负责统一语义:
+
+```csharp
+internal static class MatrixCharacterMap
+{
+ public static MatrixCharacterPattern GetPattern(Rune rune)
+ {
+ // Normalize supported BMP ASCII lowercase
+ // Query MatrixFiveBySevenGlyphMap
+ // Fallback once per Unicode scalar
+ }
+}
+```
+
+`MatrixFiveBySevenGlyphMap` 仍以 `char` 查询固定 ASCII 字模;Rune 到固定字模的规范化和 fallback 属于 `MatrixCharacterMap`。不得让布局和自动化各自处理代理项。
+
+映射结果固定为:
+
+```csharp
+internal readonly record struct MatrixCharacterPattern(
+ char Character,
+ MatrixGlyph Glyph);
+```
+
+Provider 不属于 Matrix MVP 的内部或公开合同。若新增其它真实字模来源,必须单独设计以下边界后才能引入:
+
+- Provider 是否公开给开发者。
+- 字模数据是否固定分辨率。
+- 数据所有权、不可变性和值相等性。
+- 缺字 fallback、Provider identity 和缓存失效。
+- 大型资源的加载、生命周期、AOT 和发布体积。
+
+不得仅为了模式完整而创建“一个接口加唯一实现”。
+
+## 公共 API
+
+`MatrixDisplay` 第一版公开以下 StyledProperty:
+
+| 属性 | 类型 | 代码默认值 | 作用 |
+|---|---|---:|---|
+| `Text` | `string?` | `null` | 待显示的单行文本 |
+| `DotSize` | `double` | `6` | 圆点直径,单位 DIP |
+| `DotSpacing` | `double` | `2` | 字符格内部点间距 |
+| `DotShape` | `MatrixDotShape` | `Circle` | Circle、Square或RoundedSquare点轮廓 |
+| `DotCornerRadiusRatio` | `double` | `0.25` | RoundedSquare圆角半径相对DotSize的比例 |
+| `CharacterSpacing` | `double` | `8` | 相邻字符格间距 |
+| `Padding` | `Thickness` | `0` | 内容内边距 |
+| `HorizontalContentAlignment` | `HorizontalAlignment` | `Left` | 多余水平空间中的内容位置 |
+| `VerticalContentAlignment` | `VerticalAlignment` | `Top` | 多余垂直空间中的内容位置 |
+| `OverflowMode` | `MatrixOverflowMode` | `Clip` | 小空间处理策略 |
+| `Background` | `IBrush?` | `null` | 控件背景 |
+| `BorderBrush` | `IBrush?` | `null` | 可选面板边框画刷 |
+| `BorderThickness` | `Thickness` | `0` | 面板四边边框厚度 |
+| `CornerRadius` | `CornerRadius` | `0` | 背景圆角 |
+| `ActiveBrush` | `IBrush?` | `null` | 亮点画刷 |
+| `InactiveBrush` | `IBrush?` | `null` | 暗点画刷 |
+| `ShowInactiveDots` | `bool` | `true` | 是否绘制熄灭点位 |
+| `GlowBrush` | `IBrush?` | `null` | 可选 Glow 画刷;`null` 完全关闭 |
+| `GlowOpacity` | `double` | `0.35` | Glow 透明度,规整到 `0..1` |
+| `GlowRadius` | `double` | `6` | scoped blur 半径 |
+| `IsMarqueeEnabled` | `bool` | `false` | 是否启用单向穿屏 |
+| `MarqueeSpeed` | `double` | `48` | 穿屏速度,单位 DIP/秒 |
+| `MarqueeRepeatDelay` | `TimeSpan` | `500ms` | 两轮穿屏间隔 |
+
+当前仍不公开 Provider、FontSet、`GlyphWidth`、`GlyphHeight` 或运动策略注入。Glow 技术范围见[LED Glow技术路线选型](glow-technical-options.md),Marquee 生命周期与运动语义见[LED Matrix Marquee最小契约](matrix-marquee-minimum-contract.md)。
+
+`MatrixOverflowMode` 只包含:
+
+```csharp
+public enum MatrixOverflowMode
+{
+ Clip,
+ ScaleDown
+}
+```
+
+Matrix 和 Segment 是独立路线。Matrix 不引用 `SegmentOverflowMode`,也不为了消除两个枚举而修改 Segment 的既有公共类型。
+
+## 数值规整
+
+用户输入在进入测量和绘制前统一规整:
+
+- `DotSize`:非有限值或小于 `1` 时使用 `1`。
+- `DotSpacing`:非有限值或负数时使用 `0`。
+- `CharacterSpacing`:非有限值或负数时使用 `0`。
+- `Padding`:四个方向分别规整为非负有限值。
+- `BorderThickness`:四个方向分别规整为非负有限值;原始StyledProperty值不回写。
+- `CornerRadius`:四角分别规整为非负有限值,重叠半径按最终Bounds等比收敛。
+- 单项布局参数的内部有效值上限为 `1,000,000 DIP`,防止 `double.MaxValue` 等有限超大值在尺寸公式和绘制坐标中溢出。
+- 缩放比例:限制到 `0..1`,绝不放大。
+
+规整只影响内部有效值,不回写 StyledProperty,避免破坏 binding 和属性优先级。
+
+`MatrixValueSanitizer` 第一版保留在 Matrix 根目录。虽然它和 `SegmentValueSanitizer` 存在相似数学规则,但在两条路线真正形成稳定重复前,不提前提升到 LED 家族公共抽象。
+
+## 布局测量
+
+布局输入:
+
+- 原始 `Text`。
+- 规整后的 `DotSize`、`DotSpacing`、`CharacterSpacing` 和 `Padding`。
+- 固定 `5x7` 字模映射。
+
+单个字符格理想尺寸:
+
+```text
+glyphWidth = 5 * DotSize + 4 * DotSpacing
+glyphHeight = 7 * DotSize + 6 * DotSpacing
+```
+
+全部尺寸均为 Avalonia DIP,不是物理屏幕像素。
+
+布局输出:
+
+```csharp
+internal readonly record struct MatrixGlyphSlot(
+ MatrixCharacterPattern Pattern,
+ Point Origin);
+
+internal sealed class MatrixDisplayLayout
+{
+ public Size DesiredSize { get; }
+ public Size GlyphSize { get; }
+ public IReadOnlyList Slots { get; }
+}
+```
+
+`GlyphSize` 是布局引擎根据有效 `DotSize` 和 `DotSpacing` 计算出的单字符几何尺寸。Render 必须复用该结果,不得再次维护一套字符宽高公式。
+
+布局算法独立:
+
+```csharp
+internal static class MatrixLayoutEngine
+{
+ public static MatrixDisplayLayout Calculate(
+ string? text,
+ MatrixLayoutOptions options)
+ {
+ // text + fixed glyph map + options -> slots + desired size
+ }
+}
+```
+
+`MeasureOverride` 返回同一布局算法产生的 `DesiredSize`。Matrix 没有子控件,不需要在 `ArrangeOverride` 生成点位或字模数据。
+
+## 最终空间、对齐与溢出
+
+Matrix 的理想点尺寸由 `DotSize` 决定,不因最终 Bounds 自动改变。
+
+`Render` 必须先得到理想布局,再根据最终空间处理:
+
+```text
+Render
+ -> 绘制 Bounds 内背景
+ -> PushClip 到控件 Bounds
+ -> 根据 OverflowMode 计算 scale
+ -> 根据内容对齐计算 offset
+ -> PushTransform
+ -> 绘制当前字符点阵
+```
+
+规则固定为:
+
+- `Clip`:保持理想尺寸,超出控件 Bounds 的内容被裁剪。
+- `ScaleDown`:当空间不足时整体等比缩小到 Bounds 内;空间充足时 scale 保持 `1`。
+- `Left` / `Top`:额外空间偏移为 `0`。
+- `Center`:额外空间偏移一半。
+- `Right` / `Bottom`:使用全部额外空间作为偏移。
+- `Stretch`:点阵内容不拉伸,按 Center 处理。
+
+不能默认偷偷缩小,也不能让绘制越过控件 Bounds 污染相邻视觉。
+
+ScaleDown比例和内容对齐偏移由LED家族根目录的`LedDisplayLayoutMath`计算。Matrix仍自行判断`MatrixOverflowMode`,并保留可见视口逆变换和字符剔除逻辑;共享工具不参与layout、Geometry或Render命令提交。
+
+面板边框采用`Border + Padding + Glyph` Box Model。Measure在原内容DesiredSize外增加四边有效BorderThickness。Render按Background、内侧内容视口、Border顺序提交;内容对齐、裁剪和ScaleDown以Border内侧视口为边界,边框自身保持DIP厚度。Background铺满外框,因此半透明BorderBrush会与背景混色。
+
+## 点位几何与绘制
+
+Matrix 不创建 `MatrixDotSlot[]`。`MatrixGlyphGeometryFactory` 在几何缓存未命中时使用固定的7行、5列循环,把35个点位互斥分流到两个`StreamGeometry`,并在同一个循环中根据有效DotShape追加圆、方或圆角方子路径:
+
+```text
+dotX = column * (DotSize + DotSpacing)
+dotY = row * (DotSize + DotSpacing)
+dotBounds = Rect(dotX, dotY, DotSize, DotSize)
+```
+
+每个点是`StreamGeometry`中独立闭合的子路径,不连接相邻点。Circle保留原有双ArcTo路径,Square使用直边闭合路径,RoundedSquare使用直边和四段圆角弧。亮暗点在同一个`if/else`中分类,满足:
+
+```text
+ActiveDotCount + InactiveDotCount = 35
+ActiveDotCount = PopCount(Glyph.Bits)
+```
+
+Render 复用几何,并通过槽位平移定位字符:
+
+```csharp
+foreach (var slot in layout.Slots)
+{
+ var geometry = GetGlyphGeometry(slot.Pattern.Glyph, options);
+ using (drawingContext.PushTransform(CreateTranslation(slot.Origin)))
+ {
+ if (geometry.ActiveDotCount > 0)
+ {
+ drawingContext.DrawGeometry(activeBrush, null, geometry.ActiveGeometry);
+ }
+ if (ShowInactiveDots && inactiveBrush is not null && geometry.InactiveDotCount > 0)
+ {
+ drawingContext.DrawGeometry(inactiveBrush, null, geometry.InactiveGeometry);
+ }
+ }
+}
+```
+
+完整绘制顺序:
+
+```text
+1. 可选背景
+2. 当前字模的亮点或暗点
+```
+
+明确语义:
+
+- `ActiveBrush = null` 时只绘制背景,不绘制亮点或暗点。
+- `ShowInactiveDots = false` 时只绘制亮点。
+- `InactiveBrush = null` 时不绘制暗点。
+- 亮点位置不先绘制暗点底层,避免半透明画刷发生隐式混色。
+- 背景为空时仍然正常绘制点阵。
+
+## 属性失效
+
+| 属性 | 布局缓存 | 字模几何缓存 | Avalonia 失效 |
+|---|---|---|---|
+| `Text` | 清理 | 保留 | Measure + Render |
+| `DotSize`、`DotSpacing` | 清理 | 清理 | Measure + Render |
+| `DotShape` | 保留 | 清理 | Render |
+| RoundedSquare下`DotCornerRadiusRatio` | 保留 | 清理 | Render |
+| Circle/Square下`DotCornerRadiusRatio` | 保留 | 保留 | Render |
+| `CharacterSpacing`、`Padding` | 清理 | 保留 | Measure + Render |
+| 内容对齐、`OverflowMode` | 保留 | 保留 | Render |
+| `Background`、`CornerRadius` | 保留 | 保留 | Render |
+| `BorderBrush` | 保留 | 保留 | Render |
+| `BorderThickness` | 保留 | 保留 | Measure + Render |
+| `ActiveBrush`、`InactiveBrush` | 保留 | 保留 | Render |
+| `ShowInactiveDots` | 保留 | 保留 | Render |
+
+颜色、圆角、暗点开关、内容对齐和溢出策略不得进入 layout cache key。
+
+## 缓存策略
+
+Matrix 使用单实例 layout 缓存和有界字模几何缓存,不做全局缓存:
+
+```text
+Layout cache key
+ = Text
+ + 有效 DotSize
+ + 有效 DotSpacing
+ + 有效 CharacterSpacing
+ + 有效 Padding
+```
+
+缓存内容是当前 `MatrixDisplayLayout`。相同输入的重复 Render 复用布局;文本或布局参数变化后重建。
+
+字模几何缓存键固定为:
+
+```text
+Geometry cache key
+ = Glyph.Bits
+ + 有效 DotSize
+ + 有效 DotSpacing
+```
+
+缓存值包含一个亮点`StreamGeometry`、一个暗点`StreamGeometry`和两类点数。缓存键包含字模Bits、有效DotSize、DotSpacing、DotShape和DotCornerRadiusRatio。Circle/Square的有效圆角比例固定为0。`Text`只选择和摆放字模,不改变字模内部形状,因此动态文本不得清理几何缓存;画刷、Padding、对齐和溢出模式同样不得进入几何键。
+
+几何缓存属于 `MatrixDisplay` 实例,随控件一起释放。当前固定字模集使缓存最多覆盖45种字模形状;`DotSize` 或 `DotSpacing` 变化时立即清空旧几何,避免不同尺寸历史无限增长。
+
+生命周期验收必须证明:
+
+- 输入全部支持字符后,缓存数量不超过固定字模形状数量;后续历史 Text 不增加上界。
+- 连续修改 `DotSize`、`DotSpacing` 时,缓存只保留当前参数对应的几何。
+- 清理缓存后,旧 Geometry 在没有其它引用时可被 GC。
+- `MatrixDisplay` 失去外部引用后,控件和实例缓存整体可被 GC;不得引入静态 Geometry 字典或事件订阅。
+
+layout 缓存只能保留当前一份布局,不维护随文本增长的历史集合。几何缓存只按固定字模形状复用,不按字符槽位或历史 Text 缓存。
+
+面板边框另有单实例复杂Geometry缓存,键为最终Bounds、有效BorderThickness和CornerRadius。均匀且未吞没内框的边框走缓存Pen;非均匀或过厚边框走外内Geometry排除。画刷变化复用Geometry,Thickness、CornerRadius或Bounds变化替换旧Geometry,历史外壳不得累积。
+
+## 可见字符剔除
+
+`PushClip` 只保证最终像素不越过控件边界,不能代替 CPU 侧的绘制剔除。若 `Clip` 模式仍向 Avalonia 提交屏外字符的全部圆点,长文本会产生无效绘制调用。
+
+当前布局槽位按 X 坐标递增且字符等宽。Render 将控件视口逆变换到布局坐标后:
+
+1. 先判断单行字符在垂直方向是否与视口相交;
+2. 使用二分查找定位第一个右边界超过视口左边界的槽位;
+3. 从该槽位向右绘制,到槽位左边界到达视口右边界时停止。
+
+稳定布局下,字符选择复杂度为 `O(log n + visible)`,其中 `visible` 是实际与视口相交的字符数量。`ScaleDown` 将完整内容缩入视口时,全部字符仍然必须参与绘制,不能错误剔除。
+
+零宽或零高 Bounds 不提交圆点绘制。逻辑剔除之后仍保留 `PushClip`,前者负责避免无效工作,后者负责最终像素边界正确性,二者职责不同。
+
+## 自动化与可访问性
+
+点阵只是视觉表达,辅助功能系统必须能读取实际显示文本。
+
+`MatrixDisplayAutomationPeer` 规则:
+
+- AutomationControlType 为只读 `Text`。
+- ClassName 为 `MatrixDisplay`。
+- 默认 Name 为规范化和 fallback 后的实际显示文本。
+- 开发者显式设置的 `AutomationProperties.Name` 优先,包括显式空字符串。
+- `Text` 动态变化时,已创建的 peer 发出 Name 属性变化通知。
+- 如果新旧输入映射为相同显示文本,不发送无效通知。
+
+自动化文本必须复用 `MatrixCharacterMap` 的字符合同,不得维护第二套 fallback 逻辑。
+
+## 主题与 Token
+
+Matrix 可以使用 AtomUI 基础设施:
+
+- `AtomUI.Core` 的 ThemeManager、Shared Token 和资源能力。
+- `AtomUI.Generator`。
+
+Matrix 不得使用 AtomUI 已经成型的控件包:
+
+- `AtomUI.Controls`
+- `AtomUI.Desktop.Controls`
+- `AtomUI.Desktop.Controls.Extras`
+- `AtomUI.Desktop.Controls.DataGrid`
+- `AtomUI.Desktop.Controls.ColorPicker`
+
+第一版不创建 `MatrixToken.cs`。主题通过 Setter 为下列属性接入 Shared Token 默认值:
+
+- `Background` -> `ColorBgContainer`
+- `CornerRadius` -> `BorderRadiusLG`
+- `Padding` -> `PaddingLG`
+- `ActiveBrush` -> `ColorPrimary`
+- `InactiveBrush` -> `ColorFillTertiary`
+
+渲染层只读取 StyledProperty 的最终有效值,不直接查询 Token。
+
+主题运行时合同:
+
+- 未设置本地值时,ControlTheme Setter 必须解析为当前 Shared Token 值。
+- 应用主题变化后,Shared Token 默认值必须动态更新。
+- 开发者设置的 StyledProperty 本地值优先于主题 Setter,主题切换不得覆盖本地值。
+- Matrix 是 Visual 控件,Token 资源绑定由 Avalonia 视觉资源宿主管理;不得为此引入全局 Token binding 或额外订阅。
+
+## AXAML 使用合同
+
+Labs 程序集通过 `https://atomui.net/labs` XML 命名空间公开 `MatrixDisplay`。验收必须包含真实编译 AXAML,而不能只通过 C# 构造器证明类型可用:
+
+```xml
+
+```
+
+编译 AXAML 验收同时覆盖类型解析、StyledProperty 转换、ControlTheme 发现和 Shared Token 资源解析。
+
+## AOT 边界
+
+第一版字模通过显式静态映射注册,不扫描程序集、不反射发现 Provider、不使用字符串 binding,也不通过 `Activator` 创建字模来源。
+
+字模、布局和自动化路径都使用编译期已知类型。Matrix MVP 不引入需要独立释放的 subscription、binding、timer、动态视觉或非 Visual 资源宿主。
+
+真实发布验收使用 `controlgallery/AtomUILabsGallery.Desktop` 的 Release NativeAOT 配置和专用 `GalleryPublishAot` 开关,避免把全局 `PublishAot` 属性传播到 Analyzer 项目。当前 LED 源码必须保持无 AOT/trim 分析警告;依赖程序集警告应在每次发布结果中单独归因。
+
+## 性能基线
+
+Matrix 使用独立测量程序:
+
+```text
+tools/performances/AtomUI.Labs.Controls.Led.Performance
+```
+
+逐点基线位于 [matrix-performance-baseline.md](matrix-performance-baseline.md),几何批处理结果位于 [matrix-performance-geometry-batch.md](matrix-performance-geometry-batch.md),600帧常见动态负载位于 [matrix-performance-dynamic-load.md](matrix-performance-dynamic-load.md),分配归因与36,000帧长稳结果位于 [matrix-performance-allocation-and-soak.md](matrix-performance-allocation-and-soak.md)。测量范围是 CPU 侧布局与 `DrawingGroup` 命令提交,不包含 GPU 或平台呈现成本,也不把机器相关毫秒数作为单元测试阈值。
+
+当前结论:
+
+- `Clip` 下1000和10000字符均只提交7个可见字模 Geometry 命令,保留二分剔除收益。
+- 1000字符 `ScaleDown` 的 Geometry 命令从17000降至1000,本机单次 smoke 的分配约从16.7 MB降至2.83 MB,耗时约从39.6 ms降至4.79 ms。
+- 时间数据属于同机单次 smoke,不作为跨机器速度承诺;命令数从每激活点一次降为每字模最多两次是稳定结构收益。
+- 6、8、16字符分别在亮点单层和亮暗双层下运行600次固定长度更新;预热后所有场景新增 Geometry 数均为0,最终缓存稳定在10或15种形状。
+- 动态负载每帧都按合同重建 layout,但 Geometry 命令数稳定为字符数乘以可见图层数,不随运行帧数增长。
+- 16字符双层动态帧约为64.2 KB/帧,其中调用方字符串格式化约120 B,Matrix layout约672 B,固定layout下的绘制命令记录约63.4 KB;当前分配主要不在字模映射、layout或Geometry构建。
+- 36,000帧预生成文本长稳测试中,Geometry新增为0、缓存保持`15 -> 15`、Gen2回收为0,强制完整回收后的存活托管内存没有增长;独立复跑得到相同的缓存、GC和存活内存结论。
+- 每帧分配包含测量程序新建`DrawingGroup`的CPU命令记录对象,不能直接等同于真实平台渲染器或GPU呈现分配。当前没有证据支持为了约1%的layout分配引入更复杂的缓冲复用。
+
+## NuGet 交付合同
+
+Labs 包必须使用独立标题、描述、标签和 README,明确不保证 Ant Design 视觉一致性,也不要求安装 `AtomUI.Desktop.Controls`。net8/net10 包依赖只允许包含 `AtomUI.Core` 与 Avalonia,不得出现 AtomUI 成型控件包。
+
+## 当前能力边界
+
+以下列表描述当前实现。静态 DotShape 的完整合同见 [matrix-static-visual-system.md](matrix-static-visual-system.md),Marquee 合同见 [matrix-marquee-minimum-contract.md](matrix-marquee-minimum-contract.md)。
+
+当前已经实现:
+
+- 单行静态 `5x7` 等宽点阵文本。
+- 固定 45 个字模和未知字符 fallback。
+- Circle、Square、RoundedSquare 三种点形和亮暗互斥绘制。
+- 可配置点尺寸、点间距、字符间距和 Padding。
+- 背景、圆角、亮点画刷、暗点画刷和暗点开关。
+- 水平/垂直内容对齐。
+- `Clip` 和显式 `ScaleDown`。
+- 自绘、测量、实例级 layout 缓存和自动化支持。
+- Shared Token 主题默认值和 Gallery。
+- 共享 scoped `BlurEffect` Glow。
+- 可选的单向穿屏 Marquee;有效速度为 0 时回退为普通静态显示。
+
+当前不支持:
+
+- Provider 接口或自定义字模来源。
+- 多套字模规格或任意分辨率配置。
+- 中文、CJK、复杂脚本和独立小写字形。
+- Glow 材质、扫描线、偏移、质量等级或自定义渲染后端。
+- 除已冻结单向穿屏Marquee之外的滚动模式、闪烁、多行和自动换行。
+- 图片点阵化或硬件 LED 控制。
+- 依赖 AtomUI 成型控件包。
+
+## 测试与 sample 验收
+
+自动化测试至少覆盖:
+
+- 全部 45 个字模均可查询,只有空格为全灭字模。
+- `A` 等代表字符的 bit 方向正确,不发生左右或上下镜像。
+- ASCII 小写转大写,未知字符 fallback 到 `?`。
+- Emoji 等补充平面字符按一个 Rune 回退为一个 `?`;孤立代理项稳定回退且不抛异常。
+- 固定种子的随机 UTF-16 输入保持映射文本、布局槽位和自动化语义一致。
+- 单字符、多字符、空格和空文本的理想尺寸。
+- `DotSize`、点间距、字符间距和 Padding 的尺寸公式。
+- 负数、NaN、Infinity、`double.MaxValue`、零尺寸和极小 Bounds。
+- 圆点总数、亮暗互斥、隐藏暗点和空画刷语义。
+- 全部45个字模满足亮点数加暗点数等于35,亮点数等于字模 bit 的 PopCount。
+- 每字模最多提交两个 Geometry 命令,重复字形复用缓存。
+- 三种DotShape保持相同外接框、35点互斥分区和每字模最多两个Geometry命令。
+- DotShape和RoundedSquare圆角比例正确失效并释放旧Geometry;Circle/Square忽略圆角比例缓存变化。
+- `Text`、画刷、Padding 和对齐保留几何缓存;`DotSize`、`DotSpacing` 清理并重建。
+- 全字模和历史文本缓存上界、参数抖动、旧几何释放和控件 WeakReference 回收。
+- 6、8、16字符在亮点单层和亮暗双层下连续600帧更新后,缓存大小和命令数保持稳定。
+- 背景和圆角绘制顺序。
+- 大空间内容对齐、`Clip`、`ScaleDown` 和绝不放大。
+- 重复 Render 复用 layout;文本和尺寸参数正确失效;颜色只重绘。
+- 布局属性使 Measure 失效;纯视觉属性只使 Render 失效并保持 Measure 有效。
+- Shared Token 默认值、Dark/Compact主题动态更新、StyledProperty本地值优先级,以及主题下显式`null`画刷覆盖。
+- 负数、NaN和Infinity圆角输入不导致Render异常。
+- BorderBrush与BorderThickness双条件绘制、四边独立测量、内侧视口对齐、ScaleDown不缩放边框,以及复杂边框缓存释放。
+- 通过 Labs XML 命名空间在编译 AXAML 中创建 `MatrixDisplay`。
+- 自动化名称、显式名称优先级和动态文本同步。
+- `A`、`M`、`0`、`8`、`?`、冒号和空格的 Headless 像素语义基线。
+- 100%、125%、150%、200% RenderScaling 下的物理内容边界。
+- 非均匀圆角边框在100%、125%、150%、200% RenderScaling下保持单一连通区域,并按四边有效厚度落点。
+- 像素层面的 Clip、ScaleDown、内容对齐和暗点开关。
+
+Gallery 至少展示:
+
+- 大写字母和小写输入。
+- 数字。
+- `? - . : _ + = /` 全部符号。
+- 未知字符 fallback。
+- 默认主题、隐藏暗点、自定义亮暗颜色。
+- 小点、大点、字符间距和 Padding。
+- Circle、Square和两个不同圆角比例的RoundedSquare对比。
+- 大容器中的不同内容对齐。
+- 小空间下的 Clip 和 ScaleDown。
+- 长文本裁剪、极小视口缩放和动态 Matrix 数字更新。
+- 短文本穿屏、长公告穿屏以及Marquee与Glow组合。
+
+视觉回归使用 Avalonia.Skia Headless 帧缓冲建立像素语义基线:代表字模的每个点必须形成独立连通区域,内容像素边界必须随 RenderScaling 成比例,裁剪区外不得出现亮点。测试不锁定整张 PNG 哈希,避免将 Skia 抗锯齿的非语义字节差异误判为控件回归。
+
+像素测试和几何测试职责不同:像素测试验证最终栅格化结果;字模 bit 方向、布局尺寸、绘制数量、属性失效和自动化合同仍由结构化测试负责,不能只依赖截图或肉眼判断。
diff --git a/docs/controls/led/matrix-marquee-minimum-contract.md b/docs/controls/led/matrix-marquee-minimum-contract.md
new file mode 100644
index 0000000..0945b11
--- /dev/null
+++ b/docs/controls/led/matrix-marquee-minimum-contract.md
@@ -0,0 +1,84 @@
+# LED Matrix Marquee 最小契约
+
+> 文档状态:当前实现契约,更新于 2026-07-20。
+
+## 领域边界
+
+Marquee与Glow同属LED基础显示之上的可选动态增强领域,但二者是平行模块:
+
+```text
+src/AtomUI.Labs.Controls.Led/
+├── Matrix/ MatrixDisplay、字符、布局和Geometry
+├── Segment/ SegmentDisplay及十四段基础实现
+├── Glow/ 可见Active Geometry的光效增强
+└── Marquee/ 内容运动策略与动画生命周期
+```
+
+`MatrixDisplay`属于Matrix基础显示模块,并作为最终组合入口。Marquee只输出一帧中的内容放置位置,不读取字模、Geometry、Brush或Glow;Glow只处理Matrix已经选择出的可见Active Geometry,不读取Marquee状态。二者不得互相依赖。Segment首版不接入Marquee。
+
+Matrix静态显示在Marquee关闭时必须独立、完整可用。关闭路径不创建Controller或Animation,不保留订阅、绘制命令或持续分配。
+
+## 第一版公共契约
+
+`MatrixDisplay`增加三个StyledProperty:
+
+| 属性 | 类型 | 默认值 | 语义 |
+|---|---|---:|---|
+| `IsMarqueeEnabled` | `bool` | `false` | 是否启用自动单向穿屏 |
+| `MarqueeSpeed` | `double` | `48` | 线性移动速度,单位DIP/秒 |
+| `MarqueeRepeatDelay` | `TimeSpan` | `500ms` | 完整离开后到下一轮开始前的停顿 |
+
+第一版不公开方向、缓动、重复次数、暂停、悬停暂停、手动Offset、完成事件、运动策略接口或模式枚举。内部有效速度限制为`0..10000 DIP/s`,重复间隔限制为`0..1分钟`;非法或越界输入只影响内部有效值,不回写StyledProperty。
+
+## 单向穿屏语义
+
+启用后无论文本长短均执行相同规则:
+
+1. 整段文字从内容视口右侧外部开始;
+2. 以恒定速度从右向左移动;
+3. 整段文字完整离开内容视口左侧;
+4. 保持离开位置等待`MarqueeRepeatDelay`;
+5. 从右侧外部开始下一轮。
+
+第一轮立即开始,不应用前置延迟。Marquee运行时忽略`HorizontalContentAlignment`,保留`VerticalContentAlignment`;内部按`Clip`语义绘制且不回写`OverflowMode`。空文本或无效视口不启动动画;有效速度为0时不启动动画,并按普通静态 `Clip/ScaleDown`、对齐语义渲染。
+
+## 内部扩展结构
+
+```text
+MatrixDisplay
+ -> LedMarqueeController:Avalonia Animation生命周期和进度
+ -> LeftThroughMarqueeMotion:无Avalonia绘制依赖的静态纯运动数学
+ -> MarqueeRenderPlan:一帧中一个或多个内容放置位置
+ -> Matrix可见字符剔除、Geometry和Render
+```
+
+当前只有静态 `LeftThroughMarqueeMotion.Calculate`。在出现第二个真实运动实现前不引入接口;帧计划仍可表达一个或多个放置位置,但不向开发者开放策略注入,也不使用反射、动态发现、DI或插件注册。
+
+动画使用Avalonia Animation驱动内部归一化进度,不使用`DispatcherTimer`,也不按帧累加固定像素。Text、点尺寸、间距、Padding、边框、Bounds、速度或重复间隔变化时取消旧周期并从右侧重新开始。Detach、控件自身或任一视觉祖先隐藏、禁用和空文本都会立即释放动画;重新进入可运行状态后从头开始。祖先可见性订阅只在控件已挂载且 `IsMarqueeEnabled=true` 期间存在;关闭 Marquee 或 Detach 时完整解除。
+
+## 渲染与性能契约
+
+- Marquee只改变内容X变换,不修改`Text`,不进入layout或Geometry缓存键。
+- 每帧继续使用现有二分查找,复杂度保持`O(log n + visible)`。
+- 禁止为完整长文本创建位图或提交全部不可见字模。
+- Glow跟随当前移动后的可见Geometry,并继续受内容视口裁剪。
+- 预热后运动不得持续创建layout、字模Geometry或无界历史状态。
+- Controller、Animation Style及控件实例必须在Detach后可回收。
+
+## 验收
+
+- 公共属性默认值、AXAML、失效语义和本地值优先级。
+- 起点、穿过、完整离开、等待、循环及短文本一致行为。
+- NaN、Infinity、负数、零、极大速度和极大间隔。
+- Attach、Detach、隐藏、启停、Text/Bounds/参数变化和WeakReference回收。
+- Clip、垂直对齐、Border、Inactive、Glow和不同DPI下的像素边界。
+- 1000与10000字符窄视口保持相同数量级的可见绘制命令。
+- Labs全量测试、Gallery Release、真实Windows窗口长稳和win-x64 NativeAOT。
+
+## 首轮实现验证结果
+
+- 当前测试覆盖公共合同、纯运动数学、非法输入、AXAML、渲染位置、长文本视口剔除、600帧缓存稳定、父级有效可见性和Controller WeakReference释放;精确数量以本仓库最新 `dotnet test` 结果为准。
+- 100与10000字符在相同窄视口和中段进度下提交相同数量级的可见字模命令,不随完整文本长度线性增长。
+- `net8.0`与`net10.0` Release双目标构建通过,0 warning、0 error。
+- Gallery Release 和 win-x64 NativeAOT 是当前发布验收入口;发布警告必须区分 LED 源码与依赖程序集。
+- Headless后端不会随墙钟等待自动推进Avalonia渲染动画时钟,因此自动测试使用确定性的进度注入验证各位置Render;真实时钟运动由Win32 Smoke和最终人工视觉验收负责。
diff --git a/docs/controls/led/matrix-mvp-audit.md b/docs/controls/led/matrix-mvp-audit.md
new file mode 100644
index 0000000..7cf1738
--- /dev/null
+++ b/docs/controls/led/matrix-mvp-audit.md
@@ -0,0 +1,124 @@
+# Matrix MVP 收口审计
+
+> 文档状态:历史 MVP 收口审计(审计日期 2026-07-10)。本文保留当时边界和修复证据,不描述后续 Glow、Marquee、Gallery 或当前测试状态。
+
+- 审计日期:2026-07-10
+- 审计对象:当时命名为 `AtomUI.Labs.Led.Matrix.MatrixDisplay` 的 `MatrixDisplay`;当前命名见实现文档。
+- 审计类型:MVP 交付收口,不进行行为修改或性能优化
+- 结论:未发现高严重度运行缺陷;审计发现已于同日修复并进入回归验证
+
+## 审计边界
+
+```text
+主要职责:5x7 单行点阵文本映射、测量、对齐、溢出处理、自绘和自动化语义
+状态所有者:MatrixDisplay 的 StyledProperty;当前 layout 缓存;当前实例字模 Geometry 缓存
+生命周期:无事件订阅、timer、binding、动态视觉或全局缓存;实例缓存随控件释放
+字符流:Text -> Rune -> 规范化/fallback -> glyph slot -> Geometry -> DrawingContext
+公开合同:MatrixDisplay、MatrixOverflowMode、13个 StyledProperty、Labs AXAML namespace
+主题合同:Shared Token -> ControlTheme Setter -> StyledProperty 最终有效值
+本轮不改:Public API、默认值、字模、视觉输出、缓存策略、Sample 行为和包依赖
+```
+
+## 审计发现
+
+以下问题均已完成对应修复;保留原始发现和证据,避免丢失审计上下文。
+
+### 中:Labs 架构文档存在阶段性漂移
+
+`architecture.md` 和 `overview.md` 仍保留 Matrix 落地前的描述:
+
+- 项目结构树只展开 Segment,没有展开当前 Matrix 的 Character、Layout、Rendering 和 Themes 结构。
+- LED 家族章节仍写“未来 Matrix 应与 Segment 同级”,与当前源码不符。
+- 主题聚合链只写到 Segment,没有记录 `LedThemes.axaml -> MatrixThemes.axaml -> MatrixDisplayTheme.axaml`。
+- Namespace 策略仍把 `https://atomui.net/labs` 写成待评估选项,但程序集已经正式使用该 namespace。
+- 模块概览仍写“第一阶段只放入 Dashboard”,不能反映当前 Dashboard、Segment、Matrix 三个入口。
+- Token 章节包含 Matrix 落地前的后续假设,不符合当前状态文档应只描述已实现事实的要求。
+
+影响:不会造成运行错误,但会让维护者对目录、主题入口、AXAML namespace 和当前控件范围形成错误判断。
+
+建议:下一轮先修正文档为当前状态,删除阶段性和未来式表述;只修改 `dev-and-mark`,不得修改 `docs`。
+
+### 中:主题优先级合同仍有两个直接测试缺口
+
+当前测试已经验证:
+
+- ControlTheme 能解析五个 Shared Token 默认值。
+- Dark ThemeVariant 切换后 Token 默认值动态刷新。
+- 非空本地 `ActiveBrush` 在主题切换后保持本地优先级。
+
+仍未直接验证:
+
+- Compact ThemeVariant 下 `PaddingLG` 等 Shared Token 默认值是否更新到 Matrix。
+- 在主题已经提供画刷默认值时,开发者显式设置 `ActiveBrush = null` 或 `InactiveBrush = null` 是否仍能压过主题 Setter,并保持“禁用亮点/暗点”的公开语义。
+
+这不是已证实的运行 bug,而是绑定优先级和 nullable 禁用语义的回归盲点。下一轮应补测试,失败时再做根因修复。
+
+### 低:Sample 不能完成主题切换的人工视觉验收
+
+Sample 已覆盖默认值、大小写、数字、符号、fallback、暗点开关、自定义颜色、内容对齐、Clip、ScaleDown、极小视口和动态计数器,但没有 Light/Dark/Compact 切换入口。
+
+此外,Sample 分组标签固定使用 `Brushes.Black` 和 `Brushes.DimGray`。如果加入 Dark 主题切换,这些标签可能不再适合作为可读的主题验收界面。
+
+建议:使用 Avalonia 原生控件提供简洁主题切换入口,标签颜色使用可随主题变化的资源;Sample 不得因此依赖 AtomUI 成型控件。
+
+### 低:NuGet README 没有完整列出当前 Labs 控件
+
+包内 README 只给出 `MatrixDisplay` 示例,没有说明同包还包含 `SegmentDisplay` 和用于链路验证的 `Dashboard`。这不影响 Matrix 使用,但会降低包的可发现性。
+
+建议:在不承诺稳定 API 的前提下,列出当前实际控件和最小用法;不要写未来控件清单。
+
+### 低:异常 CornerRadius 尚无专项鲁棒性测试
+
+Matrix 对 `DotSize`、间距和 Padding 有明确数值规整,但 `CornerRadius` 直接传给 `RoundedRect`。当前测试只覆盖正常圆角,没有覆盖负数、NaN 或 Infinity。
+
+目前没有证据证明这里存在 Avalonia 运行错误,因此不能直接增加私有规整逻辑。下一轮先添加不抛异常和有限绘制边界测试;只有测试暴露问题时才修复。
+
+## 已确认正确的边界
+
+- 13个 StyledProperty 的名称、代码默认值和 CLR wrapper 与设计文档一致。
+- `Clip`、`ScaleDown`、Left/Center/Right、Top/Center/Bottom 和 Stretch-as-Center 语义与文档一致。
+- `ActiveBrush = null`、`InactiveBrush = null`、`ShowInactiveDots = false` 的绘制语义在非主题宿主下已有结构化测试。
+- Unicode 以 Rune 为边界;补充平面字符和非法代理项均只产生一个 fallback。
+- 字模固定45种,实例 Geometry 缓存有界;参数变化释放旧缓存,控件整体可回收。
+- 长文本 Clip 使用二分定位可见槽位;ScaleDown 保留全部字符。
+- 自动化使用只读 Text 类型、规范化显示名称和动态 Name 变化通知。
+- 无 `_ignore`/`_suppress` 状态补丁,无 C# binding,无事件订阅,无静态 Geometry 缓存。
+- Labs 项目没有引用 `AtomUI.Controls`、`AtomUI.Desktop.Controls` 或其它成型控件包。
+- NuGet net8/net10 依赖只有 `AtomUI.Core` 和 Avalonia。
+
+## 验证结果
+
+- Matrix 专项测试:114/114 通过,Release net10.0。
+- Labs 全量测试:259/259 通过,Release net10.0。
+- Labs NuGet pack:当时成功生成旧名产物 `AtomUI.Labs.Led.6.0.8.nupkg`,同时包含 `net10.0` 与 `net8.0` 资产;该名称只记录历史结果。
+- 包目标:`lib/net8.0`、`lib/net10.0`。
+- 包依赖:`AtomUI.Core 6.0.8`、`Avalonia 12.0.5`。
+- Sample Debug和Release build:均为0 warning,0 error。
+- Sample win-x64 NativeAOT publish:成功;3条既有IL2026/IL3050/IL2060警告来自`AtomUI.Core/AppBuilderExtensions.cs`,不来自Labs或本轮改动。
+- Sample真实窗口人工视觉验收:用户确认显示、主题切换和交互观察完全正常。
+- 前序长稳测试:36,000帧 Geometry 新增0,缓存`15 -> 15`,完整GC后无存活内存增长。
+
+## 修复结果
+
+- `architecture.md`和`overview.md`已改为Dashboard、Segment、Matrix当前结构,补全Matrix主题聚合链和既定AXAML namespace,删除阶段性未来式描述。
+- 新增Compact ThemeVariant下Shared Token Padding刷新测试。
+- 新增主题已生效时显式`ActiveBrush = null`、`InactiveBrush = null`跨主题切换优先级测试。
+- 新增负数、NaN、正负Infinity CornerRadius Render探测测试;Avalonia现有绘制路径全部通过,因此没有增加Matrix私有规整逻辑。
+- Sample新增Avalonia原生Dark和Compact独立开关,支持Light、Dark、Compact、Dark+Compact四种组合。
+- Sample分组和案例标签改用Shared Token动态样式,不再硬编码黑色和灰色。
+- NuGet README已列出Dashboard、SegmentDisplay、MatrixDisplay,并包含两种LED显示控件的最小AXAML。
+
+## 实施顺序
+
+1. 修正 `dev-and-mark` 中 Labs 架构和概览文档的当前状态。
+2. 补 Compact ThemeVariant 和主题下显式 null 画刷优先级测试。
+3. 补异常 CornerRadius 探测测试;仅在测试失败时修改实现。
+4. 为 Sample 增加 Light/Dark/Compact 视觉验收入口并调整标签资源。
+5. 更新包内 README 的当前控件清单。
+6. 重跑 Matrix 专项、Labs 全量、Sample Debug/Release、pack 和 `git diff --check`。
+
+以上步骤均已完成。
+
+本审计不建议继续进行极端性能优化。若真实 Avalonia 宿主出现掉帧或内存问题,应建立平台渲染器级证据后再重新开启性能工作。
+
+Matrix MVP第一版至此完成,当前公共合同进入冻结状态;新增视觉能力或行为应作为独立版本设计,不与MVP收口修复混合。
diff --git a/docs/controls/led/matrix-performance-allocation-and-soak.md b/docs/controls/led/matrix-performance-allocation-and-soak.md
new file mode 100644
index 0000000..a6499d0
--- /dev/null
+++ b/docs/controls/led/matrix-performance-allocation-and-soak.md
@@ -0,0 +1,72 @@
+# Matrix Interaction Performance Baseline
+
+> 文档状态:历史性能快照。下列数值只适用于文内日期、配置和机器,不代表当前性能;回归时必须重新运行本仓库 runner。
+
+- Date: 2026-07-10 19:08:33 +08:00
+- Configuration: Release, .NET 10
+- Runner: `tools/performances/AtomUI.Labs.Controls.Led.Performance --count --frames --soak-frames `
+- Scope: CPU-side layout and DrawingGroup command submission; excludes GPU/platform presentation cost
+
+| Scenario | Characters | Updates | Total ms | us/update | KB total | bytes/update | Geometry commands |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| Matrix.CachedRender.8.Clip | 8 | 20 | 0.87 | 43.51 | 461.7 | 23636.8 | 7 |
+| Matrix.DynamicText.8.Clip | 8 | 20 | 0.77 | 38.74 | 476.4 | 24394.0 | 7 |
+| Matrix.CachedRender.1000.Clip | 1000 | 20 | 0.67 | 33.48 | 461.7 | 23636.8 | 7 |
+| Matrix.CachedRender.10000.Clip | 10000 | 20 | 0.77 | 38.67 | 461.7 | 23636.8 | 7 |
+| Matrix.CachedRender.1000.ScaleDown | 1000 | 20 | 113.12 | 5656.17 | 55242.3 | 2828404.8 | 1000 |
+
+## Fixed-Length Dynamic Load
+
+| Characters | Inactive dots | Frames | Total ms | us/frame | bytes/frame | Layout builds | Geometry builds | Final cache | Geometry commands/frame |
+| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| 6 | Off | 600 | 30.06 | 50.10 | 21216.1 | 600 | 0 | 10 | 6 |
+| 6 | On | 600 | 33.26 | 55.43 | 26592.1 | 600 | 0 | 10 | 12 |
+| 8 | Off | 600 | 31.03 | 51.71 | 26896.1 | 600 | 0 | 10 | 8 |
+| 8 | On | 600 | 41.28 | 68.80 | 34064.1 | 600 | 0 | 10 | 16 |
+| 16 | Off | 600 | 57.18 | 95.29 | 49848.1 | 600 | 0 | 15 | 16 |
+| 16 | On | 600 | 71.31 | 118.85 | 64184.1 | 600 | 0 | 15 | 32 |
+
+## Allocation Attribution
+
+All dynamic Matrix attribution scenarios use 16 characters with inactive dots enabled. `Precomputed` scenarios exclude caller-side string construction. The rows are independently measured and are not mathematically additive.
+
+| Stage | Operations | Total ms | us/operation | bytes/operation |
+| --- | ---: | ---: | ---: | ---: |
+| Caller.TextFormatting.16 | 600 | 0.14 | 0.23 | 120.1 |
+| Harness.EmptyDrawingGroup | 600 | 0.36 | 0.59 | 568.1 |
+| Matrix.LayoutOnly.Precomputed.16 | 600 | 3.28 | 5.46 | 672.1 |
+| Matrix.CachedRenderOnly.EmptyText | 600 | 4.91 | 8.19 | 3776.1 |
+| Matrix.CachedRenderOnly.16.InactiveOff | 600 | 54.23 | 90.38 | 49056.1 |
+| Matrix.CachedRenderOnly.16.InactiveOn | 600 | 68.24 | 113.74 | 63392.1 |
+| Matrix.DynamicPrecomputed.16.InactiveOn | 600 | 74.53 | 124.21 | 64064.1 |
+| EndToEnd.DynamicFormatted.16.InactiveOn | 600 | 72.31 | 120.52 | 64184.1 |
+
+## Long-Running Soak
+
+The soak uses a precomputed 1,000-value text ring after warmup. Natural GC counts are captured without forced collections during the measured loop. Retained bytes are compared only after full collections before and after the loop.
+
+| Frames | Total ms | us/frame | bytes/frame | Gen0 | Gen1 | Gen2 | Live before | Live after | Live delta | Sampled peak | Layout builds | Geometry builds | Cache | Commands/frame |
+| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: |
+| 36000 | 1560.05 | 43.33 | 63526.1 | 273 | 83 | 0 | 354080 | 281928 | -72152 | 8712128 | 36000 | 0 | 15->15 | 32 |
+
+## 归因结论
+
+- 16字符双层动态帧的完整分配为 `64,184.1 bytes/frame`。调用方字符串格式化只占 `120.1 bytes/frame`,约 `0.19%`。
+- 使用预生成文本时,动态布局和绘制为 `64,064.1 bytes/frame`;其中单独布局为 `672.1 bytes/frame`,约占 `1.05%`。
+- 固定布局下的双层绘制命令记录为 `63,392.1 bytes/frame`,约占预生成文本动态帧的 `98.95%`。因此当前分配主要不在字模映射、layout 对象或 Geometry 构建。
+- 空 `DrawingGroup` 自身为 `568.1 bytes/frame`;空文本 Matrix Render 为 `3,776.1 bytes/frame`。16字符仅亮点为 `49,056.1 bytes/frame`,亮暗双层为 `63,392.1 bytes/frame`,分配随可见字符和 Geometry 命令层数增长。
+- 各行是独立测量,不能简单相加;百分比仅用于识别主要成本所在。
+
+## 长稳结论
+
+- 36,000帧累计约产生 `2.29 GB` 短生命周期托管分配流量;自然 GC 为 Gen0 `273`、Gen1 `83`、Gen2 `0`。
+- 预热后 Geometry 新建数为 `0`,缓存从 `15` 保持到 `15`,布局版本严格增加36,000,最终每帧仍为32个 Geometry 命令。
+- 强制完整回收前后的存活托管内存从 `354,080` 变为 `281,928` bytes,差值 `-72,152` bytes;采样峰值约 `8.71 MB`。没有观察到单调存活内存增长。
+- 独立第二次36,000帧复跑得到 `63,522.8 bytes/frame`、Gen0/Gen1/Gen2=`273/83/0`、存活内存差值同为 `-72,152` bytes,缓存和命令计数完全一致。
+
+## 解释边界
+
+- 这些结果证明当前实例级 layout/Geometry 缓存没有随帧数增长,也没有形成可观测的托管对象滞留。
+- 测量程序每帧新建 `DrawingGroup` 来记录 CPU 侧绘制命令,因此 `bytes/frame` 包含测试记录对象。它不能直接等同于 Avalonia 平台渲染器、合成器或 GPU 呈现阶段的生产分配。
+- 时间数据是同机 Release 单次 smoke,只用于量级检查,不作为跨机器性能承诺或 CI 阈值。
+- 目前不应为了约1%的 layout 分配引入数组池、可变槽位缓冲或更复杂的生命周期。若继续优化绘制分配,必须先建立贴近真实渲染器的测量方法,并证明改动降低实际成本且不破坏可见结果。
diff --git a/docs/controls/led/matrix-performance-baseline.md b/docs/controls/led/matrix-performance-baseline.md
new file mode 100644
index 0000000..f96b508
--- /dev/null
+++ b/docs/controls/led/matrix-performance-baseline.md
@@ -0,0 +1,23 @@
+# Matrix Interaction Performance Baseline
+
+> 文档状态:历史性能快照。下列数值只适用于文内日期、配置和机器,不代表当前性能;回归时必须重新运行本仓库 runner。
+
+- Date: 2026-07-10 17:22:19 +08:00
+- Configuration: Release, .NET 10
+- Runner: `tools/performances/AtomUI.Labs.Controls.Led.Performance --count `
+- Scope: CPU-side layout and DrawingGroup command submission; excludes GPU/platform presentation cost
+
+| Scenario | Characters | Updates | Total ms | us/update | KB total | bytes/update | Submitted dots |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| Matrix.CachedRender.8.Clip | 8 | 20 | 4.21 | 210.59 | 1919.6 | 98284.8 | 97 |
+| Matrix.DynamicText.8.Clip | 8 | 20 | 4.22 | 210.88 | 2534.5 | 129764.8 | 133 |
+| Matrix.CachedRender.1000.Clip | 1000 | 20 | 4.47 | 223.41 | 2328.7 | 119228.8 | 119 |
+| Matrix.CachedRender.10000.Clip | 10000 | 20 | 3.88 | 193.89 | 2328.7 | 119228.8 | 119 |
+| Matrix.CachedRender.1000.ScaleDown | 1000 | 20 | 791.10 | 39555.08 | 326415.2 | 16712458.0 | 17000 |
+
+## Interpretation
+
+- `Clip` keeps command submission bounded by the visible viewport: the 1000- and 10000-character cached scenarios have the same submitted-dot count and similar allocation.
+- Fixed-length dynamic text includes string creation, Rune mapping, layout rebuild and drawing command submission.
+- `ScaleDown` intentionally keeps the complete text visible. At 1000 characters it submits 17000 active dots and is not suitable for frame-rate-sensitive updates under the current contract.
+- These values are a local baseline, not CI pass/fail thresholds. Compare future runs on the same machine and configuration.
diff --git a/docs/controls/led/matrix-performance-dynamic-load.md b/docs/controls/led/matrix-performance-dynamic-load.md
new file mode 100644
index 0000000..ae06066
--- /dev/null
+++ b/docs/controls/led/matrix-performance-dynamic-load.md
@@ -0,0 +1,35 @@
+# Matrix Interaction Performance Baseline
+
+> 文档状态:历史性能快照。下列数值只适用于文内日期、配置和机器,不代表当前性能;回归时必须重新运行本仓库 runner。
+
+- Date: 2026-07-10 18:53:37 +08:00
+- Configuration: Release, .NET 10
+- Runner: `tools/performances/AtomUI.Labs.Controls.Led.Performance --count --frames `
+- Scope: CPU-side layout and DrawingGroup command submission; excludes GPU/platform presentation cost
+
+| Scenario | Characters | Updates | Total ms | us/update | KB total | bytes/update | Geometry commands |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| Matrix.CachedRender.8.Clip | 8 | 20 | 1.06 | 53.02 | 461.7 | 23636.8 | 7 |
+| Matrix.DynamicText.8.Clip | 8 | 20 | 0.81 | 40.61 | 476.4 | 24394.0 | 7 |
+| Matrix.CachedRender.1000.Clip | 1000 | 20 | 0.71 | 35.38 | 461.7 | 23636.8 | 7 |
+| Matrix.CachedRender.10000.Clip | 10000 | 20 | 0.64 | 31.86 | 461.7 | 23636.8 | 7 |
+| Matrix.CachedRender.1000.ScaleDown | 1000 | 20 | 110.26 | 5513.25 | 55242.3 | 2828404.8 | 1000 |
+
+## Fixed-Length Dynamic Load
+
+| Characters | Inactive dots | Frames | Total ms | us/frame | bytes/frame | Layout builds | Geometry builds | Final cache | Geometry commands/frame |
+| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| 6 | Off | 600 | 21.61 | 36.01 | 21216.1 | 600 | 0 | 10 | 6 |
+| 6 | On | 600 | 26.38 | 43.97 | 26592.1 | 600 | 0 | 10 | 12 |
+| 8 | Off | 600 | 26.23 | 43.72 | 26896.1 | 600 | 0 | 10 | 8 |
+| 8 | On | 600 | 34.45 | 57.42 | 34064.1 | 600 | 0 | 10 | 16 |
+| 16 | Off | 600 | 49.91 | 83.19 | 49848.1 | 600 | 0 | 15 | 16 |
+| 16 | On | 600 | 61.52 | 102.53 | 64184.1 | 600 | 0 | 15 | 32 |
+
+## Interpretation
+
+- Warmup covers all decimal digits and the fixed `TEMP`/`RPM` letters. Every measured scenario reports zero new geometry builds across 600 frames.
+- Final geometry caches remain bounded at 10 numeric shapes or 15 numeric/status shapes; they do not grow with frame count.
+- Layout rebuilds equal frame count because every frame changes `Text`. Geometry reuse does not incorrectly reuse text layout.
+- Geometry commands remain exactly one per character with inactive dots off and two per character with inactive dots on.
+- Timing is a same-machine Release smoke measurement, not a CI threshold. The 16-character dual-layer case is about 102.5 us/frame, while its 64.2 KB/frame allocation identifies layout and drawing-command recording as the next measurable cost.
diff --git a/docs/controls/led/matrix-performance-geometry-batch.md b/docs/controls/led/matrix-performance-geometry-batch.md
new file mode 100644
index 0000000..480bebc
--- /dev/null
+++ b/docs/controls/led/matrix-performance-geometry-batch.md
@@ -0,0 +1,32 @@
+# Matrix Interaction Performance Baseline
+
+> 文档状态:历史性能快照。下列数值只适用于文内日期、配置和机器,不代表当前性能;回归时必须重新运行本仓库 runner。
+
+- Date: 2026-07-10 18:21:22 +08:00
+- Configuration: Release, .NET 10
+- Runner: `tools/performances/AtomUI.Labs.Controls.Led.Performance --count `
+- Scope: CPU-side layout and DrawingGroup command submission; excludes GPU/platform presentation cost
+
+| Scenario | Characters | Updates | Total ms | us/update | KB total | bytes/update | Geometry commands |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| Matrix.CachedRender.8.Clip | 8 | 20 | 0.90 | 45.09 | 461.7 | 23636.8 | 7 |
+| Matrix.DynamicText.8.Clip | 8 | 20 | 1.00 | 50.17 | 476.4 | 24394.0 | 7 |
+| Matrix.CachedRender.1000.Clip | 1000 | 20 | 0.64 | 32.21 | 461.7 | 23636.8 | 7 |
+| Matrix.CachedRender.10000.Clip | 10000 | 20 | 0.64 | 32.05 | 461.7 | 23636.8 | 7 |
+| Matrix.CachedRender.1000.ScaleDown | 1000 | 20 | 95.89 | 4794.65 | 55242.3 | 2828404.8 | 1000 |
+
+## Before/After
+
+The baseline is [matrix-performance-baseline.md](matrix-performance-baseline.md). Timing is a same-machine single-run smoke comparison, not a cross-machine speed guarantee. Geometry command counts are deterministic structural measurements.
+
+| Scenario | Metric | Baseline | Geometry batch | Formula | Improvement | Conclusion |
+| --- | --- | ---: | ---: | --- | ---: | --- |
+| Cached 8 Clip | Geometry commands/render | 97 | 7 | `(97-7)/97` | 92.8% fewer | Visible glyphs submit one active geometry each |
+| Cached 8 Clip | Allocated bytes/render | 98284.8 | 23636.8 | `(98284.8-23636.8)/98284.8` | 76.0% lower | Cached geometry removes per-dot recording objects |
+| Dynamic 8 Clip | Geometry commands/render | 133 | 7 | `(133-7)/133` | 94.7% fewer | Text changes reuse existing digit geometries |
+| Dynamic 8 Clip | Allocated bytes/update | 129764.8 | 24394.0 | `(129764.8-24394.0)/129764.8` | 81.2% lower | Dynamic layout rebuild no longer rebuilds dot commands |
+| Cached 10000 Clip | Geometry commands/render | 119 | 7 | `(119-7)/119` | 94.1% fewer | Binary visibility culling remains effective |
+| Cached 10000 Clip | Smoke time/update | 193.89 us | 32.05 us | `(193.89-32.05)/193.89` | 83.5% lower | Local smoke only |
+| Cached 1000 ScaleDown | Geometry commands/render | 17000 | 1000 | `(17000-1000)/17000` | 94.1% fewer | Complete ScaleDown now submits one active geometry per glyph |
+| Cached 1000 ScaleDown | Allocated bytes/render | 16712458.0 | 2828404.8 | `(16712458.0-2828404.8)/16712458.0` | 83.1% lower | Extreme path remains allocation-heavy but materially reduced |
+| Cached 1000 ScaleDown | Smoke time/update | 39555.08 us | 4794.65 us | `(39555.08-4794.65)/39555.08` | 87.9% lower | Local smoke only; still not a frame-rate target |
diff --git a/docs/controls/led/matrix-static-visual-system.md b/docs/controls/led/matrix-static-visual-system.md
new file mode 100644
index 0000000..89820ba
--- /dev/null
+++ b/docs/controls/led/matrix-static-visual-system.md
@@ -0,0 +1,166 @@
+# Matrix V2 静态视觉系统
+
+> 文档状态:历史 V2 设计与验收记录。点轮廓和面板边框仍已实现,但测试数量、宿主及构建结果停留在当时;当前公共契约以 [Matrix 实现原理](matrix-implementation.md) 为准。
+
+本文定义`MatrixDisplay`第二阶段的静态点轮廓系统。它只改变每个点的外轮廓,不改变字模、点位坐标、测量尺寸、颜色合同、溢出策略或动态文本行为。
+
+后续增量加入可选面板边框。边框属于显示器外壳,不属于点轮廓,也不由Labs主题强制开启。
+
+## 视觉目标
+
+三种形状表达三种明确视觉语言:
+
+| 形状 | 肉眼观感 | 适合场景 |
+|---|---|---|
+| `Circle` | 四角留白最多,柔和,像独立LED灯珠 | 仪表、电子设备、复古点阵屏 |
+| `Square` | 四角填满,同样DotSize下视觉面积最大,字符更粗、更密、更像像素屏 | 公告牌、像素字体、低分辨率终端 |
+| `RoundedSquare` | 密度接近方形但边缘更柔和,位于圆形和方形之间 | 现代信息面板、状态屏和较大点尺寸 |
+
+三种形状都占据同一个`DotSize x DotSize`外接框。切换形状不能改变控件`DesiredSize`、字符间距、Padding、对齐或ScaleDown比例。
+
+`DotSize`在当前合同中表示点轮廓外接框边长:Circle下它同时是圆直径;Square和RoundedSquare下它是正方形边长。
+
+## 公共API
+
+```csharp
+public enum MatrixDotShape
+{
+ Circle,
+ Square,
+ RoundedSquare
+}
+```
+
+`MatrixDisplay`新增:
+
+| 属性 | 类型 | 默认值 | 作用 |
+|---|---|---:|---|
+| `DotShape` | `MatrixDotShape` | `Circle` | 选择点轮廓 |
+| `DotCornerRadiusRatio` | `double` | `0.25` | RoundedSquare圆角半径相对DotSize的比例 |
+
+圆角比例有效范围为`0..0.5`:
+
+```text
+0.00 完全直角,视觉等同Square
+0.10 轻微削弱尖角,仍明显像方形
+0.25 默认平衡值
+0.50 圆角半径达到半边长,轮廓趋近Circle
+```
+
+负数截断为0,超过0.5截断为0.5,NaN和Infinity使用0。Circle和Square忽略圆角比例。非法`MatrixDotShape`枚举值按Circle处理。
+
+开发者设置的原始StyledProperty值不回写;规整只作用于Geometry和缓存键,保持Avalonia binding优先级不变。
+
+## Geometry实现
+
+`MatrixGlyphGeometryFactory`继续在固定7行、5列循环中把35个点互斥分流到亮点和暗点Geometry。亮暗状态使用同一种形状。
+
+- Circle:保留MVP原有的两段ArcTo闭合圆路径,确保默认像素视觉不变。
+- Square:使用四条直边形成闭合填充子路径。
+- RoundedSquare:使用四条直边和四段四分之一圆弧形成闭合子路径。
+
+每个点仍是独立子路径。不得把相邻方点连接成一个多点矩形,也不得为三种形状建立三套Render流程。
+
+## 缓存和失效
+
+Geometry缓存键包含:
+
+```text
+Glyph Bits
+有效DotSize
+有效DotSpacing
+有效DotShape
+有效DotCornerRadiusRatio
+```
+
+规则:
+
+- `DotShape`变化:清空当前实例Geometry缓存,只触发Render。
+- RoundedSquare下`DotCornerRadiusRatio`变化:清空Geometry缓存,只触发Render。
+- Circle或Square下圆角比例变化:不清Geometry缓存,但StyledProperty仍触发Render。
+- 形状和圆角比例不进入layout缓存键,不触发Measure。
+- 形状历史不得在实例缓存中累计;缓存只保留当前视觉参数对应的固定字模集合。
+- 每字模仍最多提交一个亮点Geometry和一个暗点Geometry命令。
+
+## 主题边界
+
+本轮不创建Matrix Control Token,也不在ControlTheme中为形状增加Setter。代码默认Circle保证MVP兼容;开发者通过StyledProperty、Style或ControlTheme覆盖形状和圆角比例。
+
+背景`CornerRadius`与点的`DotCornerRadiusRatio`职责不同:前者控制整个面板背景,后者只控制单个RoundedSquare点位。
+
+## 可选面板边框
+
+`MatrixDisplay`增量公开`BorderBrush`和`BorderThickness`。默认分别为`null`和`0`,因此现有视觉、测量和绘制命令保持不变。边框只有在Brush非空且至少一边有效厚度大于0时绘制;Thickness即使在Brush为空时仍参与布局,允许开发者隐藏边框而不引发布局跳动。
+
+```text
+控件Bounds
+┌──────────── Border ────────────┐
+│ ┌──────── Content viewport ─┐ │
+│ │ Padding + Matrix glyphs │ │
+│ └───────────────────────────┘ │
+└────────────────────────────────┘
+```
+
+Background先铺满外框,字符只在Border内侧视口中对齐、裁剪或ScaleDown,Border最后绘制。ScaleDown不得缩放边框DIP厚度。均匀边框使用Pen快速路径;非均匀边框使用外、内圆角Geometry排除形成单个填充环。复杂Geometry缓存只保留当前Bounds、有效Thickness和CornerRadius对应的一份,不随历史属性值增长。
+
+边框不创建Control Token,不在Matrix ControlTheme中设置默认值。开发者通过StyledProperty、Style或自定义ControlTheme主动开启。
+
+## 非目标
+
+本阶段不实现:
+
+- 亮点和暗点使用不同形状。
+- 椭圆、菱形、六边形、自定义Geometry或旋转。
+- 亮暗点独立尺寸和缩放比例。
+- Glow、阴影、扫描线、材质和高光。
+- 闪烁、呼吸、滚动字幕或其它动画。
+
+## 待决议 TODO
+
+`ActiveDotScale`和`InactiveDotScale`是候选的风格化能力,用于通过不同的点位尺寸进一步区分亮点和暗点。它不是工业LED点阵的通用默认行为,不进入当前实现计划,也不承诺一定实现。
+
+当前行为继续保持亮点和暗点尺寸相同,只通过`ActiveBrush`、`InactiveBrush`、Brush透明度和`ShowInactiveDots`区分状态。
+
+重新评估该能力前必须明确:
+
+- 是否存在仅靠Brush无法满足的真实使用场景,例如低对比度、无障碍或明确的风格化需求。
+- 扩充公共API和Geometry缓存键是否值得。
+- 产品是否接受它属于软件视觉增强,而不是对物理LED灯珠的严格模拟。
+- 若决定实现,两个属性的默认值必须都是`1.0`,确保现有视觉和布局行为不变。
+
+## 验收
+
+- Circle默认像素和MVP基线保持一致。
+- 三种形状在100%、125%、150%、200% RenderScaling下保持每个点独立连通。
+- 同样参数下视觉面积满足Circle小于RoundedSquare、RoundedSquare小于Square。
+- Shape切换和RoundedSquare圆角变化正确释放旧Geometry,不增长历史缓存。
+- Sample使用完全相同的文本、尺寸、间距和颜色展示三种形状,并展示至少两个RoundedSquare比例。
+- 可选边框覆盖默认无边框、均匀边框、非均匀边框和半透明边框;四边厚度与高DPI连通性由像素测试验证。
+
+## 实现验证记录
+
+2026-07-10完成以下自动验证:
+
+- Labs全量测试:315/315通过。
+- Matrix定向测试:154/154通过。
+- Sample Debug和Release构建:0警告、0错误。
+- Labs性能基准项目Release构建:0警告、0错误。
+- NuGet打包:同时产出net8.0和net10.0程序集,依赖仅包含AtomUI.Core与Avalonia。
+- Sample win-x64 NativeAOT发布成功;仅保留AtomUI.Core既有的3条裁剪/AOT告警,Matrix静态视觉实现未新增告警。
+
+真实窗口中的三种形状对比仍需人工视觉验收,自动像素测试不能替代该步骤。
+
+2026-07-11完成可选面板边框自动验证:
+
+- Matrix定向测试:173/173通过。
+- Labs全量测试:334/334通过。
+- Sample Debug和Release构建:0警告、0错误。
+- Labs性能基准项目Release构建:0警告、0错误。
+- NuGet继续同时产出net8.0和net10.0,依赖仅包含AtomUI.Core与Avalonia。
+- Sample win-x64 NativeAOT发布成功;仍只有AtomUI.Core既有的3条裁剪/AOT告警,边框实现未新增告警。
+
+真实窗口中的四组边框Sample仍需人工视觉验收。
+
+## 相关设计
+
+- [LED Glow 技术路线选型](glow-technical-options.md):Glow属于Matrix基础显示之上的可选增强层,当前仍处于技术选型阶段。
diff --git a/docs/controls/led/overview.md b/docs/controls/led/overview.md
new file mode 100644
index 0000000..def818a
--- /dev/null
+++ b/docs/controls/led/overview.md
@@ -0,0 +1,229 @@
+# LED 控件家族设计
+
+> 文档状态:当前架构与公共契约,更新于 2026-07-20。历史审计、原型和性能数据在导航中单独标识,不作为当前实现事实。
+
+本文记录 `AtomUI.Labs.Controls.Led` 的组件域设计。LED 是 Labs 下的实验控件家族名,不是单一控件名。
+
+## 文档导航
+
+- Segment:[实现原理](segment-implementation.md)、[性能回归矩阵](segment-performance-regression.md)。
+- Matrix:[实现原理](matrix-implementation.md)、[Marquee 最小契约](matrix-marquee-minimum-contract.md);[静态视觉系统](matrix-static-visual-system.md)和[MVP 收口审计](matrix-mvp-audit.md)是历史设计/审计记录。
+- Matrix 历史性能证据:[逐点基线](matrix-performance-baseline.md)、[几何批处理](matrix-performance-geometry-batch.md)、[动态负载](matrix-performance-dynamic-load.md)、[分配与长稳](matrix-performance-allocation-and-soak.md)。
+- 家族增强与边界:[Glow 技术选型](glow-technical-options.md)记录当前技术决议;[公共边界审计](family-common-boundary-audit.md)和[Glow 原型评估](glow-prototype-evaluation.md)是历史证据。
+
+## 定位
+
+`AtomUI.Labs.Controls.Led` 用于承载 LED 风格显示控件。它不表示硬件 LED 控制器,也不表示普通文本控件。
+
+LED 家族目标包含两条并列路线:
+
+```text
+AtomUI.Labs.Controls.Led
+ Segment 十四段数码管路线
+ Matrix 点阵屏路线
+```
+
+`Segment` 和 `Matrix` 不是超集关系,也不是升级关系。它们分别面向不同的显示模型:
+
+- `Segment` 以“段”为最小视觉单元,适合数字、英文字母、仪表读数和电子设备面板风格。
+- `Matrix` 以“点阵像素”为最小视觉单元,适合字符屏、公告屏、滚动文字和更自由的符号表达。
+
+共享基础代码直接放在 `src/AtomUI.Labs.Controls.Led/` 根目录下,当前包括 `LedCharacterNormalizer` 和 `LedDisplayLayoutMath`,不创建 `Primitives`、`Shared` 或 `Internal` 等独立目录。
+
+LED 家族主题必须采用聚合入口:
+
+```text
+LedThemesProvider.axaml
+ -> Themes/LedThemes.axaml
+ -> Segment/Themes/SegmentThemes.axaml
+ -> Matrix/Themes/MatrixThemes.axaml
+```
+
+包级 `LedThemesProvider.axaml` 只引用 `Themes/LedThemes.axaml`,不直接引用某个子控件的最底层主题文件。
+
+## 显示模型
+
+LED 家族需要先区分三种常见显示模型:
+
+| 模型 | 核心单元 | 适合内容 | 优点 | 缺点 | Labs 定位 |
+|---|---|---|---|---|---|
+| 七段 | 7 个发光段 | 数字、少量符号 | 简单、经典、计算量低 | 字母表现差,很多字符不可读 | 不作为独立第一路线,可作为 Segment 的简化能力评估 |
+| 十四段 | 14 个发光段 | 数字、A-Z、常用符号 | 保留数码管风格,能覆盖英文字母 | 字符映射没有唯一标准,实现复杂度高于七段 | `Led.Segment` 的主要方向 |
+| 点阵 | 点阵像素 | 文本、符号、滚动屏 | 表达能力强,可读性更可控 | 风格变成像素屏,需要字模系统 | `Led.Matrix` 的主要方向 |
+
+第一阶段文档约定:
+
+- `Segment` 使用十四段路线,目标是数字、英文字母和常用符号。
+- `Matrix` 使用点阵路线,目标是字符屏和点阵文本表达。
+- 二者共享输入、颜色、布局和渲染辅助思想,但不强行共享具体绘制算法。
+
+## Segment 路线
+
+`Led.Segment` 是十四段数码管路线。
+
+工程核心:
+
+```text
+Text
+ -> 字符规范化
+ -> 字符到十四段映射
+ -> 字符格布局
+ -> 绘制亮段和暗段
+```
+
+第一阶段建议支持:
+
+- 数字 `0-9`
+- 大写字母 `A-Z`
+- 小写输入统一转大写
+- 冒号 `:`
+- 小数点 `.`
+- 负号 `-`
+- 空格
+
+十四段字符映射没有唯一行业标准。Labs 应在实现文档中明确自己的映射表,并把它视为实验视觉的一部分,不承诺与某一种硬件设备完全一致。
+
+## Matrix 路线
+
+`Led.Matrix` 是点阵屏路线。
+
+工程核心:
+
+```text
+Text
+ -> 字符规范化
+ -> 字符到点阵字模
+ -> 点阵网格布局
+ -> 绘制亮点和暗点
+```
+
+Matrix 需要额外考虑:
+
+- 当前固定 `5x7` 字模规格,不开放任意行列配置。
+- 当前在相同点位外接框内支持 Circle、Square 和 RoundedSquare 静态轮廓,不改变字模和布局。
+- 点尺寸、点间距、字符间距、Padding 和内容对齐。
+- 小空间下的裁剪和显式等比缩小。
+
+这些能力不应塞入 `Segment`。只有两条路线出现真实且稳定的重复后,才能评估 LED 家族根目录中的共享布局代码。
+
+Matrix静态轮廓的完整合同见[matrix-static-visual-system.md](matrix-static-visual-system.md)。
+
+## 共享基础代码
+
+`Segment` 和 `Matrix` 可以共享 LED 家族内部基础代码,但第一版不可为了架构感过度抽象。共享代码直接放在包项目根目录;只有当真实重复稳定后,再评估是否增加 `Primitives`、`Shared` 或 `Internal` 等目录。
+
+子控件内部目录应按稳定职责组织。`Segment` 当前采用:
+
+```text
+Segment/
+ SegmentDisplay.cs
+ SegmentValueSanitizer.cs
+ Character/
+ Layout/
+ Rendering/
+ Themes/
+```
+
+`Matrix` 采用同等工程标准,但不强行复刻 `Segment` 的目录名;按点阵路线自己的稳定职责拆分。
+
+当前已经共享的内容:
+
+- `LedCharacterNormalizer`:ASCII小写转大写。
+- `LedDisplayLayoutMath`:ScaleDown比例和内容对齐偏移纯计算。
+
+已经统一行为但不提取代码的内容:
+
+- 文本布局框架思想,但不强行共用同一个 LayoutEngine。
+- 测量结果结构命名习惯,例如都包含 DesiredSize 和 Slots。
+- 内容对齐和溢出策略的行为约定,但不强行共享枚举类型。
+- Shared Token默认值接入模式,但每个ControlTheme保持独立。
+- 亮暗画刷、暗态开关、字符间距和Padding的属性语义,但不引入公共控件基类。
+
+第一版不应该共享的内容:
+
+- 十四段的段位映射。
+- `SegmentParts`。
+- `SegmentCharacterMap`。
+- `SegmentGeometryFactory`。
+- `MatrixGlyph`。
+- `MatrixFiveBySevenGlyphMap`。
+- 点阵的字模数据。
+- Matrix dot 绘制逻辑。
+- 两条路线各自的几何生成细节。
+
+原则上,第一版只共享输入、状态、选项和少量数学辅助;不共享字符映射、字模、几何生成和具体绘制。公共抽象必须来自真实重复,不能先设计一个看起来通用但掩盖路线差异的 `LedLayoutEngine`、`LedRenderer` 或 `LedGeometryFactory`。
+
+公共边界的逐项审计结论见[family-common-boundary-audit.md](family-common-boundary-audit.md)。
+
+## 渲染方案
+
+LED 家族实现前需要评估三种渲染方案:
+
+| 方案 | 做法 | 优点 | 缺点 | 建议 |
+|---|---|---|---|---|
+| 自绘 Control | 在控件 `Render(DrawingContext)` 中直接绘制段或点 | 视觉树少,性能稳定,适合大量字符;不依赖 AtomUI 成型控件 | 需要自己处理测量、命中、缩放和几何细节 | 推荐作为 Segment 和 Matrix 的默认实现方向 |
+| 模板拼元素 | 用 Avalonia `Path`、`Border` 等元素拼出每段或每点 | 样式直观,容易局部调试 | 字符多时视觉树膨胀,性能和测量复杂 | 只适合原型或极少字符场景 |
+| Canvas/子控件组合 | 每个段或点作为子元素放入 Canvas | 坐标表达直观,交互扩展容易 | 控件树复杂,布局和虚拟化成本高 | 不作为第一版默认方案 |
+
+第一版建议优先采用自绘 Control。自绘不等于放弃主题能力,各路线支持的颜色、尺寸和间距参数仍可通过 StyledProperty 与 AtomUI Shared Token 默认值接入。
+
+自绘控件必须明确处理父容器最终给出的空间:
+
+- 父容器给出更多空间时,通过内容对齐决定显示内容停靠位置。
+- 父容器给出更小空间时,默认应裁剪到控件 bounds 内,避免绘制污染相邻区域。
+- 需要完整显示时,可提供显式等比缩小策略,但不能默认偷偷缩小,否则会掩盖布局问题。
+
+## 主题与 Token
+
+LED 家族可以继续使用 AtomUI 基础设施:
+
+- `AtomUI.Core` 的 `ThemeManager`、Shared Token、资源绑定和运行时基础能力。
+- `AtomUI.Generator` 的生成能力。
+- 必要时使用 `AtomUI.Controls.Shared` 的共享契约和工具。
+
+LED 家族不得使用 AtomUI 已经成型的控件包:
+
+- `AtomUI.Controls`
+- `AtomUI.Desktop.Controls`
+- `AtomUI.Desktop.Controls.Extras`
+- `AtomUI.Desktop.Controls.DataGrid`
+- `AtomUI.Desktop.Controls.ColorPicker`
+
+第一阶段不强制定义 LED Control Token。建议先用 StyledProperty 暴露实验视觉参数,默认值通过 Shared Token 取得。等 Segment 和 Matrix 的稳定视觉语义形成后,再评估是否提取 LED 家族 Token 或路线级 Token。
+
+## 非目标
+
+当前不处理:
+
+- 真实硬件 LED 控制。
+- 中文、复杂脚本或富文本排版。
+- Segment 内建动画,以及 Matrix 单向穿屏之外的滚动、闪烁或故障动画。
+- 把 `Segment` 和 `Matrix` 合并为一个万能控件。
+- 提前固定 LED 家族公共代码目录名。
+
+## 实现验证
+
+实现阶段至少需要验证:
+
+- Labs 项目构建通过。
+- `controlgallery/AtomUILabsGallery.Desktop` 构建和 `win-x64` NativeAOT 发布通过。
+- 搜索确认 LED 家族没有引用 AtomUI 成型控件包。
+- Gallery 能展示 Segment、Matrix、Glow 和 Marquee 的基础视觉与交互。
+- `git diff --check` 通过。
+
+## 当前契约追踪
+
+| 契约 | 主要源码 | 主要自动化验证 |
+|---|---|---|
+| Segment 理想尺寸、对齐、Clip/ScaleDown | `Segment/Layout/SegmentLayoutEngine.cs`、`SegmentDisplay.cs` | `SegmentLayoutEngineTests`、`SegmentDisplayMeasureTests`、`SegmentDisplayRenderTests` |
+| Segment 数值、圆角和主题鲁棒性 | `SegmentValueSanitizer.cs`、`SegmentDisplayTheme.axaml` | `SegmentDisplayRenderTests`、`SegmentDisplayThemeTests` |
+| Matrix 字模、点形、边框和主题 | `MatrixDisplay.cs`、`Matrix/Character/`、`Matrix/Rendering/` | `MatrixDisplay*Tests`、`MatrixGlyph*Tests` |
+| Glow scoped blur 与 Effect 复用 | `Glow/LedGlowRenderer.cs`、两个 Display 的 Render 路径 | `LedGlow*Tests`、Segment/Matrix Render 与正式性能测试 |
+| Marquee 运动、静态回退与生命周期 | `Marquee/`、`MatrixDisplay.cs` | `MatrixMarqueeMotionTests`、`MatrixMarqueeLifecycleTests`、`MatrixDisplayRenderTests` |
+
+## 相关设计
+
+- [LED 家族公共边界审计](family-common-boundary-audit.md):记录 Segment 与 Matrix 之间已验证的共享边界。
+- [LED Glow 当前技术合同](glow-technical-options.md):记录正式 scoped BlurEffect 路线的公共属性、绘制和性能契约。
+- [LED Glow 原型评估](glow-prototype-evaluation.md):记录候选路线、正式控件接入和性能门禁的历史验证。
+- [LED Matrix Marquee最小契约](matrix-marquee-minimum-contract.md):记录单向穿屏公共契约、动态增强边界和后续官方运动模式的内部扩展结构。
diff --git a/docs/controls/led/segment-implementation.md b/docs/controls/led/segment-implementation.md
new file mode 100644
index 0000000..9d9392f
--- /dev/null
+++ b/docs/controls/led/segment-implementation.md
@@ -0,0 +1,628 @@
+# LED Segment 工业级实现原理
+
+> 文档状态:当前实现契约,更新于 2026-07-20。本文描述现有 `AtomUI.Labs.Controls.Led.Segment` 源码;历史性能数据另见性能回归文档。
+
+> 当前实现已将早期同形叠色Glow升级为共享Scoped Blur Glow,并补充`GlowRadius`。正式Glow契约与性能结论见[LED Glow技术路线选型](glow-technical-options.md)和[LED Glow原型评估](glow-prototype-evaluation.md)。
+
+> 当前Render不再逐段提交Geometry命令。可见Inactive段聚合为一个缓存Geometry,可见Active段聚合为另一个缓存Geometry;Active聚合同时用于一次Glow Effect和清晰本体绘制。Text变化只替换当前Active聚合,同槽位同Geometry配置继续复用Inactive聚合,不保留历史文本缓存。
+
+本文记录 `AtomUI.Labs.Controls.Led.Segment` 的目标实现原理。目标读者可以是第一次接触 LED 控件的新手,但实现标准必须按工业级自绘控件来约束。
+
+`Segment` 是十四段数码管路线。它不是字体控件,不是点阵控件,也不是硬件 LED 控制器。
+
+## 核心链路
+
+Segment 的本质是基于字符映射表和参数化几何生成器的 Avalonia 自绘控件。
+
+完整链路如下:
+
+```text
+原始 Text
+ -> 字符规范化
+ -> 字符到十四段映射
+ -> 字符归类
+ -> 布局测量
+ -> 几何生成
+ -> 绘制命令
+ -> Avalonia 底层渲染
+```
+
+辅助技术链路与视觉链路共享同一份字符映射:
+
+```text
+原始 Text
+ -> 字符规范化和不支持字符降级
+ -> 实际可显示文本
+ -> SegmentDisplayAutomationPeer
+ -> 平台辅助功能系统
+```
+
+`SegmentDisplay` 在自动化系统中按只读 `Text` 内容暴露,而不是可编辑输入框。默认自动化名称等于实际可显示文本:ASCII 小写转换为大写,不支持字符转换为空格。开发者显式设置的 `AutomationProperties.Name` 优先级更高,可为时钟、计数器或单位值提供更自然的语义名称。`Text` 动态变化时,已创建的 AutomationPeer 必须同步发出 Name 属性变化通知。
+
+每一层只能做自己的事:
+
+- 字符规范化只处理输入字符,例如小写转大写。
+- 字符映射只回答某个字符应该点亮哪些逻辑段。
+- 布局测量只计算字符格尺寸和位置。
+- 几何生成只把逻辑段转换为可绘制形状。
+- 绘制命令只把几何、画刷和顺序提交给 `DrawingContext`。
+- Avalonia 负责把绘制命令栅格化成屏幕像素。
+
+严禁把字符判断、坐标计算和 `DrawingContext` 调用混在一串 `if/else` 中。
+
+## 工程目录
+
+`Segment` 必须保持和正式控件库一致的工程入口习惯:公共控件类型留在控件根目录,内部实现按稳定职责进入子目录,主题通过 `*Themes.axaml` 聚合。
+
+```text
+src/AtomUI.Labs.Controls.Led/Segment/
+ SegmentDisplay.cs
+ SegmentOverflowMode.cs
+ SegmentValueSanitizer.cs
+ Character/
+ SegmentCharacterKind.cs
+ SegmentCharacterMap.cs
+ SegmentCharacterPattern.cs
+ SegmentParts.cs
+ Layout/
+ SegmentCharacterSlot.cs
+ SegmentDisplayLayout.cs
+ SegmentLayoutEngine.cs
+ SegmentLayoutOptions.cs
+ Rendering/
+ SegmentGeometryFactory.cs
+ SegmentGeometryItem.cs
+ SegmentGeometryOptions.cs
+ SegmentGeometrySet.cs
+ Themes/
+ SegmentThemes.axaml
+ SegmentDisplayTheme.axaml
+```
+
+主题入口链路:
+
+```text
+LedThemesProvider.axaml
+ -> Themes/LedThemes.axaml
+ -> Segment/Themes/SegmentThemes.axaml
+ -> Segment/Themes/SegmentDisplayTheme.axaml
+```
+
+`SegmentToken.cs` 当前不创建。Token 会固定主题契约,必须等视觉语义稳定后单独设计,不能作为目录对齐的附带动作。
+
+## 职责边界
+
+AtomUI Labs 需要负责:
+
+- 字符应该如何规范化。
+- 字符应该点亮哪些十四段逻辑段。
+- 每个字符格在控件中的位置和尺寸。
+- 每个逻辑段在字符格内的具体几何形状。
+- 亮段、暗段、发光层的绘制顺序。
+- 何时触发布局失效和视觉失效。
+
+Avalonia 负责:
+
+- 执行 `DrawingContext.DrawGeometry(...)`。
+- 将 `Geometry` 栅格化为像素。
+- 处理抗锯齿。
+- 处理 DPI、render scale 和底层 Skia/平台渲染管线。
+
+换句话说,Avalonia 提供画布和渲染管线,但不会知道十四段 LED 长什么样。十四段语义和几何必须由 Labs 自己定义。
+
+## 字符映射
+
+字符映射层是纯数据层,不依赖 Avalonia、主题、画刷或几何。
+
+推荐用 `[Flags]` enum 表达逻辑段:
+
+```csharp
+[Flags]
+internal enum SegmentParts
+{
+ None = 0,
+ Top = 1 << 0,
+ UpperLeft = 1 << 1,
+ UpperRight = 1 << 2,
+ MiddleLeft = 1 << 3,
+ MiddleRight = 1 << 4,
+ LowerLeft = 1 << 5,
+ LowerRight = 1 << 6,
+ Bottom = 1 << 7,
+ UpperCenter = 1 << 8,
+ LowerCenter = 1 << 9,
+ UpperLeftDiagonal = 1 << 10,
+ UpperRightDiagonal = 1 << 11,
+ LowerLeftDiagonal = 1 << 12,
+ LowerRightDiagonal = 1 << 13
+}
+```
+
+`<<` 用于给每个段分配一个独立二进制位。一个字符可以通过按位或组合多个段:
+
+```csharp
+var parts = SegmentParts.Top
+ | SegmentParts.UpperLeft
+ | SegmentParts.UpperRight;
+```
+
+字符映射应集中维护:
+
+```csharp
+internal static class SegmentCharacterMap
+{
+ public static SegmentCharacterPattern GetPattern(char value)
+ {
+ // char -> SegmentCharacterPattern
+ }
+}
+```
+
+不要在渲染代码里写:
+
+```csharp
+if (ch == 'A')
+{
+ DrawTop();
+ DrawUpperLeft();
+}
+```
+
+这种写法会把字符语义和绘制逻辑绑定死,后续无法维护。
+
+## 字符归类
+
+映射结果不应该只有 `SegmentParts`。冒号、小数点和空格不是标准十四段字符,应该明确分类。
+
+推荐模型:
+
+```csharp
+internal enum SegmentCharacterKind
+{
+ Empty,
+ Segments,
+ Colon,
+ Dot
+}
+
+internal readonly record struct SegmentCharacterPattern(
+ char Character,
+ SegmentCharacterKind Kind,
+ SegmentParts Parts);
+```
+
+示例:
+
+```text
+'8' -> Kind = Segments, Parts = Top | UpperLeft | ...
+':' -> Kind = Colon, Parts = None
+'.' -> Kind = Dot, Parts = None
+' ' -> Kind = Empty, Parts = None
+```
+
+不支持字符第一版建议按空格处理,不抛异常。显示控件不应该因为输入中出现一个不可显示字符导致 UI 崩溃。
+
+## 公共 API
+
+`SegmentDisplay` 当前公开以下 StyledProperty:
+
+| 属性 | 类型 | 代码默认值 | 作用 |
+|---|---|---:|---|
+| `Text` | `string?` | `null` | 待显示的单行文本 |
+| `CharacterHeight` | `double` | `72` | 未缩放字符理想高度,单位 DIP |
+| `CharacterAspectRatio` | `double` | `0.58` | 字符格宽高比 |
+| `CharacterSpacing` | `double` | `8` | 相邻字符格间距 |
+| `SegmentThickness` | `double` | `8` | 段厚度 |
+| `SegmentGap` | `double` | `2` | 段端点间隙 |
+| `SegmentBevelRatio` | `double` | `0.5` | 段端斜切比例 |
+| `DotScale` | `double` | `0.72` | 冒号和小数点相对段厚度的缩放 |
+| `Padding` | `Thickness` | `0` | 内容内边距 |
+| `HorizontalContentAlignment` | `HorizontalAlignment` | `Left` | 多余水平空间中的内容位置 |
+| `VerticalContentAlignment` | `VerticalAlignment` | `Top` | 多余垂直空间中的内容位置 |
+| `OverflowMode` | `SegmentOverflowMode` | `Clip` | 小空间使用裁剪或等比缩小 |
+| `Background` | `IBrush?` | `null` | 控件背景 |
+| `CornerRadius` | `CornerRadius` | `0` | 背景圆角 |
+| `ActiveBrush` | `IBrush?` | `null` | 点亮段画刷 |
+| `InactiveBrush` | `IBrush?` | `null` | 熄灭段画刷 |
+| `GlowBrush` | `IBrush?` | `null` | 可选 Glow 画刷;`null` 完全关闭 |
+| `GlowOpacity` | `double` | `0.35` | Glow 透明度,有效范围 `0..1` |
+| `GlowRadius` | `double` | `6` | scoped blur 半径,有效范围 `0..24` |
+| `ShowInactiveSegments` | `bool` | `true` | 是否绘制熄灭段 |
+
+`SegmentOverflowMode` 当前只包含 `Clip` 和 `ScaleDown`。属性内部规整不回写 StyledProperty,以保持 Binding 和属性优先级。
+
+## 布局测量
+
+映射决定“哪些段亮”,布局测量决定“每个字符放哪里、多大”。
+
+布局输入:
+
+- 规范化后的字符 pattern 列表。
+- 字符期望高度。
+- 字符宽高比。
+- 字符间距。
+- 符号宽度规则。
+- Padding。
+
+`CharacterHeight` 表示理想字符高度,同时决定未缩放字符格的绘制高度。最终 `Bounds` 不会重写 layout:空间更大时只产生对齐偏移;空间更小时由 `Clip` 裁剪,只有显式 `ScaleDown` 才按比例缩小。
+
+段厚度和段间隙不参与理想尺寸计算。它们只影响字符格内部的几何形状,所以只应触发重绘,不应触发布局测量。
+
+父容器给的最终空间可能大于或小于理想尺寸。工业级控件不能假设父容器一定尊重 `DesiredSize`:
+
+- 空间更大时,由 `HorizontalContentAlignment` 和 `VerticalContentAlignment` 决定内容在最终 bounds 内的位置。
+- 空间更小时,默认 `OverflowMode = Clip`,内容按真实尺寸绘制并裁剪到控件 bounds 内。
+- 如果开发者显式设置 `OverflowMode = ScaleDown`,内容整体等比缩小到可用 bounds 内,但不会放大超过 1 倍。
+
+这几个属性属于运行期布局/绘制策略,不是 Token。Token 决定默认视觉基因,StyledProperty 决定单个控件实例的最终行为。
+
+布局输出:
+
+```csharp
+internal readonly record struct SegmentCharacterSlot(
+ SegmentCharacterPattern Pattern,
+ Rect Bounds);
+
+internal sealed class SegmentDisplayLayout
+{
+ public Size DesiredSize { get; }
+ public IReadOnlyList Slots { get; }
+}
+```
+
+布局算法应该独立,例如:
+
+```csharp
+internal static class SegmentLayoutEngine
+{
+ public static SegmentDisplayLayout Calculate(...)
+ {
+ // patterns + size options -> slots + desired size
+ }
+}
+```
+
+`MeasureOverride` 和 `Render` 必须复用同一套布局算法。不能在 `MeasureOverride` 粗算一套,在 `Render` 又重新写另一套位置计算。
+
+## MeasureOverride、ArrangeOverride 和 Render
+
+`MeasureOverride` 的职责是告诉父容器控件理想尺寸:
+
+```text
+Text 改变
+ -> 重新规范化和测量
+ -> InvalidateMeasure()
+ -> InvalidateVisual()
+```
+
+`Render` 的职责是基于最终尺寸发出绘制命令:
+
+```text
+Render
+ -> 读取与 MeasureOverride 相同的理想 layout
+ -> 根据 OverflowMode 计算绘制缩放
+ -> 根据 HorizontalContentAlignment / VerticalContentAlignment 计算偏移
+ -> PushClip 到 Bounds
+ -> PushTransform 应用缩放和偏移
+ -> 为每个 slot 生成或读取 geometry
+ -> 根据 pattern 绘制暗段和亮段
+```
+
+Segment 不重写 `ArrangeOverride`。最终空间中的 ScaleDown 比例和内容对齐偏移由家族根目录的 `LedDisplayLayoutMath` 计算;Arrange 尺寸不进入 layout 或 geometry 缓存键。Avalonia 可能因为视觉失效重新 `Render` 而不重新 `Arrange`,因此几何生成只由当前理想 layout 和几何属性驱动。
+
+## 几何生成
+
+逻辑段不是图形。几何生成负责把逻辑段转换为 Avalonia 可绘制的 `Geometry`。
+
+推荐模型:
+
+```csharp
+internal readonly record struct SegmentGeometryItem(
+ SegmentParts Part,
+ Geometry Geometry);
+
+internal sealed class SegmentGeometrySet
+{
+ public IReadOnlyList Items { get; }
+}
+
+internal static class SegmentGeometryFactory
+{
+ public static SegmentGeometrySet Create(
+ Rect bounds,
+ SegmentGeometryOptions options)
+ {
+ // bounds + options -> 14 segment geometries
+ }
+}
+```
+
+`SegmentGeometryFactory` 不关心当前字符是 `A`、`8` 还是 `K`。它只在给定字符格中生成完整十四段骨架。字符映射层决定哪些段亮。
+
+横段和竖段建议表达为有厚度的填充形状,而不是简单 `DrawLine`。例如顶部段可以是一个斜切六边形:
+
+```text
+ /--------\
+ / \
+ \ /
+ \--------/
+```
+
+斜段可以通过起点、终点、方向向量和法线向量生成一个有宽度的多边形。
+
+当前几何骨架参数:
+
+- `SegmentThickness`:段厚度,只影响字符格内部几何,不影响测量尺寸。
+- `SegmentGap`:段间隙,只影响字符格内部几何,不影响测量尺寸。
+- `SegmentBevelRatio`:段端斜切比例,默认 `0.5`,会被 clamp 到 `0..1`。
+- `DotScale`:冒号和小数点的点位尺寸比例,默认 `0.72`,会被 clamp 到 `0..1`。
+
+这些属性都是视觉几何参数,变化时只应清理 geometry 缓存并触发重绘,不应触发 `InvalidateMeasure()`。
+
+冒号和小数点已经进入统一几何管线:
+
+```text
+Colon/Dot slot
+ -> SegmentGeometryFactory.CreateColon/CreateDot
+ -> cached Geometry
+ -> DrawGeometry
+```
+
+不要在 `Render` 中直接 `DrawEllipse` 绘制符号点位。否则符号和普通 segment 字符会走两套渲染路径,后续 Glow、高光、缓存和测试都会分裂。
+
+## C# 中的最终绘图对象
+
+Segment 的最终几何对象是 Avalonia 的 `Geometry`:
+
+```csharp
+using Avalonia.Media;
+
+Geometry geometry = ...;
+drawingContext.DrawGeometry(activeBrush, null, geometry);
+```
+
+常用具体类型包括:
+
+- `StreamGeometry`
+- `PathGeometry`
+- `GeometryGroup`
+- `EllipseGeometry`
+
+Segment 主路径建议用 `StreamGeometry` 或其它可缓存的 `Geometry` 表达段形状。冒号和小数点可以用 `EllipseGeometry`,也可以直接调用 `DrawingContext.DrawEllipse(...)`,但如果要统一绘制管线,仍可包装成几何对象。
+
+最终提交给 Avalonia 的核心组合是:
+
+```text
+Brush + Geometry
+```
+
+需要描边时才使用:
+
+```text
+Brush + Pen + Geometry
+```
+
+## 绘制顺序
+
+推荐绘制顺序:
+
+```text
+1. 可选背景
+2. 暗段
+3. 可选 Glow 层
+4. 亮段主体
+5. 可选高光层
+```
+
+基础伪代码:
+
+```csharp
+foreach (var slot in layout.Slots)
+{
+ var geometrySet = geometryFactory.Create(slot.Bounds, options);
+
+ if (showInactiveSegments)
+ {
+ foreach (var item in geometrySet.Items)
+ {
+ drawingContext.DrawGeometry(inactiveBrush, null, item.Geometry);
+ }
+ }
+
+ foreach (var item in geometrySet.Items)
+ {
+ if ((slot.Pattern.Parts & item.Part) != 0)
+ {
+ drawingContext.DrawGeometry(activeBrush, null, item.Geometry);
+ }
+ }
+}
+```
+
+暗段用于表达未点亮但仍可见的 LED 轮廓。没有暗段时,控件更像普通矢量图形,不像真实设备面板。
+
+当前 Glow 是可选的 scoped blur 层:
+
+- `GlowBrush = null` 时完全关闭,这是默认状态。
+- `GlowOpacity` 默认 `0.35`,但只有 `GlowBrush` 存在时才生效。
+- Glow 使用同一份可见 Active 聚合 `Geometry`,通过单个 `BlurEffect` 作用域绘制,不生成另一套段几何。
+- `GlowBrush`、`GlowOpacity` 和 `GlowRadius` 只影响绘制,不进入 geometry cache key。
+- `GlowBrush = null`、有效透明度为 0 或有效半径为 0 时不创建 Effect。
+
+当前第一版已经落地的绘制语义:
+
+- 背景如果存在,先绘制背景。
+- `ActiveBrush` 为 `null` 时,只绘制背景,不绘制亮段、暗段、冒号或小数点。
+- 普通十四段字符在 `ShowInactiveSegments = true` 且 `InactiveBrush` 不为空时,先绘制完整 14 个暗段,再绘制当前字符的亮段。
+- `ShowInactiveSegments = false` 时,普通十四段字符只绘制当前字符的亮段。
+- 冒号和小数点不走 14 段暗段逻辑,只绘制对应点位。
+- 冒号和小数点使用 cached `Geometry` 绘制,不再直接 `DrawEllipse`。
+- `GlowBrush` 存在且 `GlowOpacity > 0` 时,亮段和符号点位会先绘制一层 Glow,再绘制亮段主体。
+
+这些语义已经通过 `SegmentDisplayRenderTests` 固定。
+
+## 数值规整策略
+
+Segment 第一版采用“显示控件不因非法输入崩溃”的策略。所有用户输入的数值型属性在进入布局、几何或绘制前都会规整。
+
+当前内部由 `SegmentValueSanitizer` 统一处理:
+
+- `NaN` 视为最小值。
+- `Infinity` 视为最小值或被 clamp 到范围内。
+- 负数按 0 处理,带最小值的属性按最小值处理。
+- `Padding` 的四个方向分别规整为非负有限数。
+- 布局数值上限为 `1_000_000`;极端有限输入也不能产生无穷 DesiredSize、slot 或几何坐标。
+- `CornerRadius` 四角分别规整为非负有限数并受相同上限约束。
+- `CharacterAspectRatio` 最小值为 `0.1`。
+- 渲染阶段 `SegmentThickness` 最小值为 `1`,随后在 `SegmentGeometryFactory` 中按字符格尺寸继续 clamp。
+- `SegmentGap` 最小值为 `0`,随后在 `SegmentGeometryFactory` 中按字符格尺寸继续 clamp。
+- `SegmentBevelRatio` clamp 到 `0..1`。
+- `DotScale` clamp 到 `0..1`。
+- `GlowOpacity` clamp 到 `0..1`。
+
+几何工厂的边界策略:
+
+- 非正尺寸或非有限 bounds 返回包含 14 个空 `Geometry` 的 `SegmentGeometrySet`,保持结构稳定。
+- 极小字符格、过大 thickness、过大 gap 都会被 clamp,生成的几何不会超出字符格 bounds。
+
+## 属性失效
+
+属性变化必须触发正确的失效路径:
+
+| 属性类型 | 示例 | 处理 |
+|---|---|---|
+| 文本和字符布局 | `Text`、字符间距、Padding | `InvalidateMeasure()` + `InvalidateVisual()` |
+| 几何参数 | 段厚度、段间隙、斜切比例 | 清几何缓存 + `InvalidateVisual()` |
+| 颜色参数 | 亮段画刷、暗段画刷、背景画刷 | `InvalidateVisual()` |
+| 绘制开关 | 是否显示暗段、是否显示发光层 | `InvalidateVisual()` |
+| 绘制变换 | 内容对齐、溢出策略 | `InvalidateVisual()` |
+
+不要把所有属性变化都粗暴当成重新布局,也不要让几何参数变化只触发重绘。
+
+## 缓存策略
+
+第一版不做全局缓存,但 `SegmentDisplay` 内部已经维护当前实例级缓存,避免同一控件在相同 layout 和几何参数下重复生成 `StreamGeometry`。
+
+可缓存内容:
+
+- 字符规范化结果。
+- 字符到 `SegmentCharacterPattern` 的映射结果。
+- 给定字符格尺寸和几何参数下的十四段 `Geometry`。
+
+当前控件缓存分两层:
+
+- layout 缓存:`Text`、`CharacterHeight`、`CharacterAspectRatio`、`CharacterSpacing`、`Padding`。
+- geometry 缓存:slot 数量、每个 slot 的字符类型和 bounds,以及 `SegmentThickness`、`SegmentGap`、`SegmentBevelRatio`、`DotScale`。
+
+geometry 缓存不能直接包含 `Text`。数字和字母的字符内容决定哪些段点亮,但不改变同一个字符格中的十四段骨架。例如 `"12" -> "34"` 必须重新映射字符和计算 layout,却可以复用原来的 Geometry。Render 必须使用当前 layout 中的 `SegmentCharacterPattern` 选择亮段,不能把旧 pattern 和 cached Geometry 捆绑保存。
+
+以下变化属于 geometry topology 变化,必须重新生成:
+
+- slot 数量变化。
+- `Segments`、`Colon`、`Dot`、`Empty` 之间发生类型变化。
+- 任意 slot 的 bounds 变化。
+- 任意几何参数变化。
+
+颜色不应该进入几何缓存 key。颜色变化只需要重新绘制,不需要重建几何。
+
+当前失效规则:
+
+- `Text` 变化:清 layout 缓存;geometry 缓存保留为复用候选,Render 阶段按 topology 严格匹配。
+- 字符尺寸、字符间距、`Padding` 变化:清 layout 缓存和 geometry 缓存。
+- `SegmentThickness`、`SegmentGap`、`SegmentBevelRatio`、`DotScale` 变化:只清 geometry 缓存。
+- `ActiveBrush`、`InactiveBrush`、`GlowBrush`、`GlowOpacity`、`Background`、`ShowInactiveSegments` 变化:只触发重绘,不清 geometry 缓存。
+- `HorizontalContentAlignment`、`VerticalContentAlignment`、`OverflowMode` 变化:只触发重绘,不清 geometry 缓存。
+
+这意味着固定字符结构的时钟、计数器等高频文本更新只重建必要的字符映射和 layout,不重复生成十四段 Geometry;单纯颜色变化也不会重建几何。闪烁分隔符如果在 `Colon` 和空格之间切换,会改变 slot 类型和宽度,因此仍然必须重建。
+
+对齐和溢出策略不进入 geometry 缓存 key。它们只改变绘制阶段的 `Transform`,不改变字符 slot、段位映射和单个字符格内的几何形状。
+
+时钟、计数器、冒号闪烁不是 `SegmentDisplay` 的职责。它们应该通过外部 `DispatcherTimer`、ViewModel 或组合控件更新 `Text`,基础显示控件只负责显示当前字符串。
+
+## 主题边界
+
+Segment 可以使用 AtomUI 基础设施:
+
+- `AtomUI.Core` 的 ThemeManager。
+- Shared Token。
+- 资源绑定能力。
+- `AtomUI.Generator`。
+
+Segment 不得使用 AtomUI 已经成型的控件包:
+
+- `AtomUI.Controls`
+- `AtomUI.Desktop.Controls`
+- `AtomUI.Desktop.Controls.Extras`
+- `AtomUI.Desktop.Controls.DataGrid`
+- `AtomUI.Desktop.Controls.ColorPicker`
+
+渲染层不要直接查询 token。正确关系是:
+
+```text
+Shared Token
+ -> 主题 Setter 或 StyledProperty 默认值
+ -> 控件属性
+ -> Render 读取最终属性值
+```
+
+## 当前能力边界
+
+当前已经实现:
+
+- 静态十四段字符显示。
+- 数字、`A-Z`、冒号、小数点、负号、空格。
+- 小写输入转大写。
+- 自绘。
+- 可测量。
+- 可缩放。
+- 可对齐。
+- 小空间下可裁剪或显式等比缩小。
+- 可主题化。
+- 基础段形态配置。
+- 冒号和小数点统一几何缓存。
+- 共享 `LedGlowRenderer` 的 scoped `BlurEffect` Glow。
+
+当前不支持:
+
+- 普通字体模拟 LED。
+- 点阵显示。
+- 滚动字幕。
+- 内置闪烁和复杂动画。
+- Glow 材质、偏移、质量等级或自定义后端。
+- 多行文本。
+- 中文和复杂脚本。
+- 富文本。
+- 硬件 LED 控制。
+- 依赖 AtomUI 成型控件包。
+
+## 可测试性
+
+Segment 不能只靠手动看 sample。
+
+应优先测试:
+
+- 字符规范化,例如 `"abc"` 变成 `"ABC"`。
+- 字符映射,例如 `8` 包含预期段,`-` 只包含中段。
+- 不支持字符按空格处理。
+- 布局测量,例如 `"12:45"` 的 slot 数量和窄字符宽度符合预期。
+- 属性失效路径,例如 `Text` 改变触发布局和重绘,画刷改变只触发重绘。
+
+几何像素级测试可以后置,但映射和布局必须可测试。
+
+当前测试覆盖:
+
+- `LedCharacterNormalizerTests`:ASCII 小写转大写。
+- `SegmentCharacterMapTests`:数字、`A-Z`、符号、冒号、小数点、未知字符 fallback。
+- `SegmentLayoutEngineTests`:slot 数量、窄符号宽度、padding、spacing、理想尺寸不受最终空间改写、非法数值规整。
+- `SegmentGeometryFactoryTests`:14 段完整性、bounds 内几何、极端 thickness/gap、非正 bounds、非有限选项。
+- `SegmentDisplayContractTests`:StyledProperty 名称、默认值、CLR wrapper、内容对齐和溢出策略。
+- `SegmentDisplayMeasureTests`:真实控件测量、padding、非法数值、厚度和间隙不影响 DesiredSize。
+- `SegmentDisplayRenderTests`:基础 render、亮暗层、符号、空画刷、scoped Glow、圆角、极端数值、几何复用与失效、对齐、裁剪和 `ScaleDown`。
+- `SegmentDisplayThemeTests`:AXAML 主题发现、Shared Token 默认值、Dark/Compact 更新和本地值优先级。
+- `SegmentDisplayAutomationTests`:只读 Text 自动化类型、规范化后的自动化名称、显式自动化名称优先级和动态文本同步。
+- 小数逻辑尺寸测试:非整数 bounds、段厚度和间隙下,几何保持有限并位于字符格范围内;DPI 栅格化仍由 Avalonia 负责。
+- 高频更新测试:连续 2000 次固定四位数字更新必须重建 layout、复用 geometry,并在随后发生 topology 或几何参数变化时正确失效。
+
+性能回归场景和验收矩阵见 [segment-performance-regression.md](segment-performance-regression.md)。
+
+当前 Glow 的统一属性、绘制、资源和性能契约见 [glow-technical-options.md](glow-technical-options.md);候选路线与原型数据见 [glow-prototype-evaluation.md](glow-prototype-evaluation.md)。
diff --git a/docs/controls/led/segment-performance-regression.md b/docs/controls/led/segment-performance-regression.md
new file mode 100644
index 0000000..aef9af1
--- /dev/null
+++ b/docs/controls/led/segment-performance-regression.md
@@ -0,0 +1,48 @@
+# LED Segment 性能回归矩阵
+
+> 文档状态:当前回归契约,更新于 2026-07-20。机器相关性能数值仅作历史参考,确定性的缓存和重建次数才是自动化门禁。
+
+本文定义 `SegmentDisplay` 高频更新和缓存行为的长期回归边界。指标使用确定性的 layout/geometry 重建次数,不使用容易受机器负载影响的单元测试耗时阈值。
+
+## 场景资格
+
+- Gallery 同时展示超过 5 个 `SegmentDisplay` 实例。
+- 动态时钟和计数器会在一次运行期间持续更新 `Text`。
+- 高频成本位于控件自己的字符映射、layout 和 geometry 生成路径,不依赖对 Avalonia 内部成本的推测。
+
+## 性能场景
+
+| 场景 | 输入 | layout 期望 | geometry 期望 |
+|---|---|---|---|
+| 重复 Render | `Text` 和属性不变 | 不重建 | 不重建 |
+| 同 topology 文本更新 | `"12" -> "34"`、固定四位计数器 | 重建 | 复用 |
+| topology 变化 | 冒号变点号、字符数量变化、Segment 变 Empty | 重建 | 重建 |
+| 几何参数变化 | thickness、gap、bevel、dot scale | 不要求重建 layout | 重建 |
+| 绘制属性变化 | brush、glow、alignment、overflow | 不重建 | 不重建 |
+| 字符布局参数变化 | height、aspect ratio、spacing、padding | 重建 | 重建 |
+
+## 功能矩阵
+
+- [x] geometry 复用后使用当前字符 pattern 绘制,不显示旧字符。
+- [x] 冒号和小数点保持各自的 geometry。
+- [x] 空格和不支持字符保持 Empty 语义。
+- [x] Active、Inactive、Glow 和背景绘制顺序不变。
+- [x] Clip、ScaleDown 和内容对齐行为不变。
+
+## 连续更新矩阵
+
+- [x] 连续 2000 次固定四位数字更新不抛异常。
+- [x] 每次不同的数字文本都触发 layout 更新。
+- [x] 2000 次同 topology 更新不增加 geometry 重建次数。
+- [x] 高频复用后再改变 topology,geometry 正确重建。
+- [x] 高频复用后再改变几何参数,geometry 正确重建。
+
+## 生命周期与所有权
+
+- geometry 缓存属于单个 `SegmentDisplay` 实例,不跨实例共享。
+- 缓存只保留当前一组 prepared slots,不维护随文本增长的历史集合。
+- 本优化不创建订阅、binding、timer、动态视觉或跨控件引用,因此没有新增释放配对。
+
+## 性能结论口径
+
+固定四位数字每次更新的整套 geometry cache 重建次数从 `1` 降为 `0`。这是一项确定性的结构收益;在建立同口径、多轮的独立性能测量前,不声明毫秒或页面加载百分比。
diff --git a/docs/controls/overview.md b/docs/controls/overview.md
new file mode 100644
index 0000000..39ac238
--- /dev/null
+++ b/docs/controls/overview.md
@@ -0,0 +1,9 @@
+# 实验控件文档
+
+本目录记录 AtomUI Labs 中各实验控件的长期设计、实现约束和验证资料。每个控件或控件家族使用独立子目录,入口文件统一命名为 `overview.md`。
+
+文档可以先于源码进入本仓库,但必须明确区分目标设计、当前实现和历史验证结果。控件落地后,文档中的包名、公开 API、测试和 Gallery 验收应与源码同步维护。
+
+## 控件目录
+
+- [LED 控件家族](led/overview.md):已实现的 `AtomUI.Labs.Controls.Led` 包,包含十四段 Segment、5x7 Matrix、Glow 和 Marquee。
diff --git a/docs/engineering/overview.md b/docs/engineering/overview.md
index 623f670..ce40358 100644
--- a/docs/engineering/overview.md
+++ b/docs/engineering/overview.md
@@ -17,15 +17,20 @@ tests/.Tests/
controlgallery/AtomUILabsGallery/
controlgallery/AtomUILabsGallery.Desktop/
docs/architecture/
+docs/controls//
docs/engineering/
```
+长期维护的控件设计、实现约束和验证资料放在 `docs/controls//`,并以 `overview.md` 作为专题入口。文档先于源码迁入时,必须明确标注目标设计与历史参考实现的边界。
+
新增控件时,优先使用包名和项目名:
```text
AtomUI.Labs.Controls.
```
+示例:LED 控件家族遵循该规则,项目名和包名为 `AtomUI.Labs.Controls.Led`,测试项目为 `AtomUI.Labs.Controls.Led.Tests`。
+
测试项目使用:
```text
diff --git a/docs/global-engineering-guidelines.md b/docs/global-engineering-guidelines.md
index 84f82b6..3f49d4c 100644
--- a/docs/global-engineering-guidelines.md
+++ b/docs/global-engineering-guidelines.md
@@ -15,3 +15,7 @@
- 工程规范总览:[docs/engineering/overview.md](engineering/overview.md)
- 构建、打包与发布:[docs/architecture/build-and-packaging.md](architecture/build-and-packaging.md)
+
+## 按需阅读
+
+- 实验控件文档入口:[docs/controls/overview.md](controls/overview.md)
diff --git a/scripts/PackToLocal.ps1 b/scripts/PackToLocal.ps1
index 7ea757d..15559a5 100644
--- a/scripts/PackToLocal.ps1
+++ b/scripts/PackToLocal.ps1
@@ -10,6 +10,18 @@ $solutionPath = Join-Path $repoRoot "AtomUI.Labs.slnx"
$srcDir = Join-Path $repoRoot "src"
$testsDir = Join-Path $repoRoot "tests"
+function Invoke-DotNet {
+ param (
+ [Parameter(ValueFromRemainingArguments = $true)]
+ [string[]]$Arguments
+ )
+
+ & dotnet @Arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "dotnet $($Arguments -join ' ') failed with exit code $LASTEXITCODE."
+ }
+}
+
function Resolve-ProjectPath {
param (
[Parameter(Mandatory = $true)]
@@ -23,13 +35,13 @@ function Resolve-ProjectPath {
return (Get-Item -Path (Join-Path $repoRoot $ProjectPath)).FullName
}
-dotnet restore $solutionPath
-dotnet build $solutionPath --configuration $BuildType --no-restore
+Invoke-DotNet restore $solutionPath --property:Configuration=$BuildType --property:GalleryPublishAot=false
+Invoke-DotNet build $solutionPath --configuration $BuildType --no-restore --property:GalleryPublishAot=false
if (Test-Path $testsDir) {
$testProjects = Get-ChildItem -Path $testsDir -Filter "*.csproj" -Recurse -File
foreach ($testProject in $testProjects) {
- dotnet test $testProject.FullName --framework net10.0 --configuration $BuildType --no-build
+ Invoke-DotNet test $testProject.FullName --framework net10.0 --configuration $BuildType --no-build
}
}
@@ -53,6 +65,5 @@ if (-not $packableProjects -or $packableProjects.Count -eq 0) {
}
foreach ($project in $packableProjects) {
- dotnet pack $project --configuration $BuildType --no-build
+ Invoke-DotNet pack $project --configuration $BuildType --no-build
}
-
diff --git a/src/AtomUI.Labs.Controls.Led/AtomUI.Labs.Controls.Led.csproj b/src/AtomUI.Labs.Controls.Led/AtomUI.Labs.Controls.Led.csproj
new file mode 100644
index 0000000..bf6a54c
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/AtomUI.Labs.Controls.Led.csproj
@@ -0,0 +1,24 @@
+
+
+ $(AtomUITargetFrameworks)
+ AtomUI.Labs.Controls.Led
+ AtomUI Labs LED Controls
+ Experimental LED-style segment and matrix display controls for AtomUI applications.
+ avalonia;AtomUI;Labs;LED;Segment Display;Matrix Display;Desktop;Experimental
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AtomUI.Labs.Controls.Led/Glow/LedGlowRenderer.cs b/src/AtomUI.Labs.Controls.Led/Glow/LedGlowRenderer.cs
new file mode 100644
index 0000000..3b29509
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Glow/LedGlowRenderer.cs
@@ -0,0 +1,76 @@
+using Avalonia;
+using Avalonia.Media;
+
+namespace AtomUI.Labs.Controls.Led.Glow;
+
+internal sealed class LedGlowRenderer
+{
+ private BlurEffect? _effect;
+
+ public int EffectBuildCount { get; private set; }
+
+ public int EffectScopeCount { get; private set; }
+
+ public LedGlowRenderScope Push(
+ DrawingContext context,
+ IBrush? brush,
+ double opacity,
+ double radius,
+ Rect sourceBounds)
+ {
+ var effectiveOpacity = LedGlowValueSanitizer.CoerceOpacity(opacity);
+ var effectiveRadius = LedGlowValueSanitizer.CoerceRadius(radius);
+ if (brush is null
+ || effectiveOpacity <= 0
+ || effectiveRadius <= 0
+ || !IsUsable(sourceBounds))
+ {
+ return default;
+ }
+
+ if (_effect is null)
+ {
+ _effect = new BlurEffect();
+ EffectBuildCount++;
+ }
+
+ _effect.Radius = effectiveRadius;
+ EffectScopeCount++;
+ return new LedGlowRenderScope(context, _effect, effectiveOpacity, sourceBounds);
+ }
+
+ private static bool IsUsable(Rect bounds)
+ {
+ return double.IsFinite(bounds.X)
+ && double.IsFinite(bounds.Y)
+ && double.IsFinite(bounds.Width)
+ && double.IsFinite(bounds.Height)
+ && bounds.Width > 0
+ && bounds.Height > 0;
+ }
+}
+
+internal readonly ref struct LedGlowRenderScope
+{
+ private readonly IDisposable? _opacityScope;
+ private readonly IDisposable? _effectScope;
+
+ public LedGlowRenderScope(
+ DrawingContext context,
+ BlurEffect effect,
+ double opacity,
+ Rect sourceBounds)
+ {
+ _opacityScope = context.PushOpacity(opacity);
+ _effectScope = context.PushEffect(effect, sourceBounds);
+ IsActive = true;
+ }
+
+ public bool IsActive { get; }
+
+ public void Dispose()
+ {
+ _effectScope?.Dispose();
+ _opacityScope?.Dispose();
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Glow/LedGlowValueSanitizer.cs b/src/AtomUI.Labs.Controls.Led/Glow/LedGlowValueSanitizer.cs
new file mode 100644
index 0000000..53d94ad
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Glow/LedGlowValueSanitizer.cs
@@ -0,0 +1,18 @@
+namespace AtomUI.Labs.Controls.Led.Glow;
+
+internal static class LedGlowValueSanitizer
+{
+ public const double DefaultOpacity = 0.35;
+ public const double DefaultRadius = 6;
+ public const double MaximumRadius = 24;
+
+ public static double CoerceOpacity(double value)
+ {
+ return double.IsFinite(value) ? Math.Clamp(value, 0, 1) : 0;
+ }
+
+ public static double CoerceRadius(double value)
+ {
+ return double.IsFinite(value) ? Math.Clamp(value, 0, MaximumRadius) : 0;
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/LedCharacterNormalizer.cs b/src/AtomUI.Labs.Controls.Led/LedCharacterNormalizer.cs
new file mode 100644
index 0000000..1ece0b2
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/LedCharacterNormalizer.cs
@@ -0,0 +1,11 @@
+namespace AtomUI.Labs.Controls.Led;
+
+internal static class LedCharacterNormalizer
+{
+ public static char NormalizeAscii(char character)
+ {
+ return character is >= 'a' and <= 'z'
+ ? (char)(character - ('a' - 'A'))
+ : character;
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/LedDisplayLayoutMath.cs b/src/AtomUI.Labs.Controls.Led/LedDisplayLayoutMath.cs
new file mode 100644
index 0000000..4ca83a3
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/LedDisplayLayoutMath.cs
@@ -0,0 +1,53 @@
+using Avalonia;
+using Avalonia.Layout;
+
+namespace AtomUI.Labs.Controls.Led;
+
+internal static class LedDisplayLayoutMath
+{
+ public static double CalculateScaleDown(Size desiredSize, Size bounds)
+ {
+ if (!IsPositiveFinite(desiredSize.Width)
+ || !IsPositiveFinite(desiredSize.Height)
+ || !IsPositiveFinite(bounds.Width)
+ || !IsPositiveFinite(bounds.Height))
+ {
+ return 1;
+ }
+
+ var scaleX = bounds.Width / desiredSize.Width;
+ var scaleY = bounds.Height / desiredSize.Height;
+ return Math.Min(1, Math.Min(scaleX, scaleY));
+ }
+
+ public static Vector CalculateAlignmentOffset(
+ Size desiredSize,
+ Size bounds,
+ double scale,
+ HorizontalAlignment horizontalAlignment,
+ VerticalAlignment verticalAlignment)
+ {
+ var horizontalExtra = Math.Max(0, bounds.Width - desiredSize.Width * scale);
+ var verticalExtra = Math.Max(0, bounds.Height - desiredSize.Height * scale);
+ var x = horizontalAlignment switch
+ {
+ HorizontalAlignment.Center => horizontalExtra / 2,
+ HorizontalAlignment.Right => horizontalExtra,
+ HorizontalAlignment.Stretch => horizontalExtra / 2,
+ _ => 0
+ };
+ var y = verticalAlignment switch
+ {
+ VerticalAlignment.Center => verticalExtra / 2,
+ VerticalAlignment.Bottom => verticalExtra,
+ VerticalAlignment.Stretch => verticalExtra / 2,
+ _ => 0
+ };
+ return new Vector(x, y);
+ }
+
+ private static bool IsPositiveFinite(double value)
+ {
+ return double.IsFinite(value) && value > 0;
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/LedThemesProvider.axaml b/src/AtomUI.Labs.Controls.Led/LedThemesProvider.axaml
new file mode 100644
index 0000000..cbef7e3
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/LedThemesProvider.axaml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/src/AtomUI.Labs.Controls.Led/LedThemesProvider.cs b/src/AtomUI.Labs.Controls.Led/LedThemesProvider.cs
new file mode 100644
index 0000000..cb07586
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/LedThemesProvider.cs
@@ -0,0 +1,12 @@
+using AtomUI.Theme;
+using Avalonia.Markup.Xaml;
+
+namespace AtomUI.Labs.Controls.Led;
+
+internal class LedThemesProvider : ControlThemesProvider
+{
+ public LedThemesProvider()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Marquee/LedMarqueeController.cs b/src/AtomUI.Labs.Controls.Led/Marquee/LedMarqueeController.cs
new file mode 100644
index 0000000..a019f1d
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Marquee/LedMarqueeController.cs
@@ -0,0 +1,110 @@
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Controls;
+using Avalonia.Styling;
+
+namespace AtomUI.Labs.Controls.Led.Marquee;
+
+internal sealed class LedMarqueeController : IDisposable
+{
+ private static readonly TimeSpan MaximumCycleDuration = TimeSpan.FromDays(365);
+ private readonly Control _owner;
+ private readonly StyledProperty _progressProperty;
+ private MarqueePlaybackKey _playbackKey;
+ private Style? _animationStyle;
+ private bool _hasPlaybackKey;
+
+ public LedMarqueeController(Control owner, StyledProperty progressProperty)
+ {
+ _owner = owner;
+ _progressProperty = progressProperty;
+ }
+
+ public bool IsRunning => _animationStyle is not null;
+
+ public void Update(double viewportWidth, double contentWidth, double speed, TimeSpan repeatDelay)
+ {
+ var effectiveSpeed = LedMarqueeValueSanitizer.CoerceSpeed(speed);
+ if (!double.IsFinite(viewportWidth)
+ || !double.IsFinite(contentWidth)
+ || viewportWidth <= 0
+ || contentWidth <= 0
+ || effectiveSpeed <= 0)
+ {
+ Stop();
+ return;
+ }
+
+ var effectiveDelay = LedMarqueeValueSanitizer.CoerceRepeatDelay(repeatDelay);
+ var key = new MarqueePlaybackKey(viewportWidth, contentWidth, effectiveSpeed, effectiveDelay);
+ if (_hasPlaybackKey && _playbackKey == key && _animationStyle is not null)
+ {
+ return;
+ }
+
+ Stop();
+ _playbackKey = key;
+ _hasPlaybackKey = true;
+
+ var movementSeconds = (viewportWidth + contentWidth) / effectiveSpeed;
+ var totalSeconds = Math.Min(
+ movementSeconds + effectiveDelay.TotalSeconds,
+ MaximumCycleDuration.TotalSeconds);
+ if (!double.IsFinite(totalSeconds) || totalSeconds <= 0)
+ {
+ return;
+ }
+
+ var movementCue = Math.Clamp(movementSeconds / totalSeconds, 0, 1);
+ _animationStyle = new Style
+ {
+ Animations =
+ {
+ new Animation
+ {
+ Duration = TimeSpan.FromSeconds(totalSeconds),
+ IterationCount = IterationCount.Infinite,
+ FillMode = FillMode.Both,
+ Children =
+ {
+ CreateKeyFrame(0, 0),
+ CreateKeyFrame(movementCue, 1),
+ CreateKeyFrame(1, 1)
+ }
+ }
+ }
+ };
+ _owner.Styles.Add(_animationStyle);
+ }
+
+ public void Stop()
+ {
+ if (_animationStyle is not null)
+ {
+ _owner.Styles.Remove(_animationStyle);
+ _animationStyle = null;
+ }
+
+ _owner.SetCurrentValue(_progressProperty, 0);
+ }
+
+ public void Dispose()
+ {
+ Stop();
+ }
+
+ private KeyFrame CreateKeyFrame(double cue, double progress)
+ {
+ return new KeyFrame
+ {
+ Cue = new Cue(cue),
+ Setters = { new Setter(_progressProperty, progress) }
+ };
+ }
+
+ private readonly record struct MarqueePlaybackKey(
+ double ViewportWidth,
+ double ContentWidth,
+ double Speed,
+ TimeSpan RepeatDelay);
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Marquee/LedMarqueeValueSanitizer.cs b/src/AtomUI.Labs.Controls.Led/Marquee/LedMarqueeValueSanitizer.cs
new file mode 100644
index 0000000..8c43c86
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Marquee/LedMarqueeValueSanitizer.cs
@@ -0,0 +1,29 @@
+namespace AtomUI.Labs.Controls.Led.Marquee;
+
+internal static class LedMarqueeValueSanitizer
+{
+ public const double DefaultSpeed = 48;
+ public const double MaximumSpeed = 10_000;
+ public static readonly TimeSpan DefaultRepeatDelay = TimeSpan.FromMilliseconds(500);
+ public static readonly TimeSpan MaximumRepeatDelay = TimeSpan.FromMinutes(1);
+
+ public static double CoerceSpeed(double value)
+ {
+ if (double.IsNaN(value) || value <= 0)
+ {
+ return 0;
+ }
+
+ return double.IsPositiveInfinity(value) ? MaximumSpeed : Math.Min(value, MaximumSpeed);
+ }
+
+ public static TimeSpan CoerceRepeatDelay(TimeSpan value)
+ {
+ if (value <= TimeSpan.Zero)
+ {
+ return TimeSpan.Zero;
+ }
+
+ return value > MaximumRepeatDelay ? MaximumRepeatDelay : value;
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Marquee/LeftThroughMarqueeMotion.cs b/src/AtomUI.Labs.Controls.Led/Marquee/LeftThroughMarqueeMotion.cs
new file mode 100644
index 0000000..cabf258
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Marquee/LeftThroughMarqueeMotion.cs
@@ -0,0 +1,18 @@
+namespace AtomUI.Labs.Controls.Led.Marquee;
+
+internal static class LeftThroughMarqueeMotion
+{
+ public static MarqueeRenderPlan Calculate(in MarqueeMotionContext context)
+ {
+ var progress = double.IsNaN(context.Progress) ? 0 : Math.Clamp(context.Progress, 0, 1);
+ var viewportWidth = CoerceDimension(context.ViewportWidth);
+ var contentWidth = CoerceDimension(context.ContentWidth);
+ var distance = viewportWidth + contentWidth;
+ return new MarqueeRenderPlan(1, viewportWidth - distance * progress);
+ }
+
+ private static double CoerceDimension(double value)
+ {
+ return double.IsFinite(value) && value > 0 ? value : 0;
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Marquee/MarqueeMotionContext.cs b/src/AtomUI.Labs.Controls.Led/Marquee/MarqueeMotionContext.cs
new file mode 100644
index 0000000..f185385
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Marquee/MarqueeMotionContext.cs
@@ -0,0 +1,6 @@
+namespace AtomUI.Labs.Controls.Led.Marquee;
+
+internal readonly record struct MarqueeMotionContext(
+ double ViewportWidth,
+ double ContentWidth,
+ double Progress);
diff --git a/src/AtomUI.Labs.Controls.Led/Marquee/MarqueeRenderPlan.cs b/src/AtomUI.Labs.Controls.Led/Marquee/MarqueeRenderPlan.cs
new file mode 100644
index 0000000..27d31a5
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Marquee/MarqueeRenderPlan.cs
@@ -0,0 +1,17 @@
+namespace AtomUI.Labs.Controls.Led.Marquee;
+
+internal readonly record struct MarqueeRenderPlan(
+ int PlacementCount,
+ double FirstX,
+ double SecondX = 0)
+{
+ public double GetX(int index)
+ {
+ return index switch
+ {
+ 0 when PlacementCount > 0 => FirstX,
+ 1 when PlacementCount > 1 => SecondX,
+ _ => throw new ArgumentOutOfRangeException(nameof(index))
+ };
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixCharacterMap.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixCharacterMap.cs
new file mode 100644
index 0000000..9071b20
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixCharacterMap.cs
@@ -0,0 +1,70 @@
+using AtomUI.Labs.Controls.Led;
+using System.Text;
+
+namespace AtomUI.Labs.Controls.Led.Matrix.Character;
+
+internal static class MatrixCharacterMap
+{
+ public static MatrixCharacterPattern GetPattern(char character)
+ {
+ var normalized = LedCharacterNormalizer.NormalizeAscii(character);
+ if (MatrixFiveBySevenGlyphMap.TryGetGlyph(normalized, out var glyph))
+ {
+ return new MatrixCharacterPattern(normalized, glyph);
+ }
+
+ MatrixFiveBySevenGlyphMap.TryGetGlyph('?', out var fallbackGlyph);
+ return new MatrixCharacterPattern('?', fallbackGlyph);
+ }
+
+ public static MatrixCharacterPattern GetPattern(Rune rune)
+ {
+ return rune.Value <= char.MaxValue
+ ? GetPattern((char)rune.Value)
+ : GetPattern('?');
+ }
+
+ public static int GetPatternCount(string? text)
+ {
+ if (string.IsNullOrEmpty(text))
+ {
+ return 0;
+ }
+
+ var count = 0;
+ foreach (var _ in text.EnumerateRunes())
+ {
+ count++;
+ }
+
+ return count;
+ }
+
+ public static string GetDisplayText(string? text)
+ {
+ if (string.IsNullOrEmpty(text))
+ {
+ return string.Empty;
+ }
+
+ StringBuilder? normalized = null;
+ var consumedLength = 0;
+ foreach (var rune in text.EnumerateRunes())
+ {
+ var displayCharacter = GetPattern(rune).Character;
+ if (normalized is null
+ && rune.Utf16SequenceLength == 1
+ && displayCharacter == text[consumedLength])
+ {
+ consumedLength++;
+ continue;
+ }
+
+ normalized ??= new StringBuilder(text.Length).Append(text, 0, consumedLength);
+ normalized.Append(displayCharacter);
+ consumedLength += rune.Utf16SequenceLength;
+ }
+
+ return normalized?.ToString() ?? text;
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixCharacterPattern.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixCharacterPattern.cs
new file mode 100644
index 0000000..6f642a8
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixCharacterPattern.cs
@@ -0,0 +1,5 @@
+namespace AtomUI.Labs.Controls.Led.Matrix.Character;
+
+internal readonly record struct MatrixCharacterPattern(
+ char Character,
+ MatrixGlyph Glyph);
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixFiveBySevenGlyphMap.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixFiveBySevenGlyphMap.cs
new file mode 100644
index 0000000..e885734
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixFiveBySevenGlyphMap.cs
@@ -0,0 +1,108 @@
+namespace AtomUI.Labs.Controls.Led.Matrix.Character;
+
+internal static class MatrixFiveBySevenGlyphMap
+{
+ public const int Width = MatrixGlyph.Width;
+ public const int Height = MatrixGlyph.Height;
+ public const string SupportedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ?-.:_+=/";
+
+ private static readonly MatrixGlyph[] s_glyphs = CreateGlyphs();
+
+ public static bool TryGetGlyph(char character, out MatrixGlyph glyph)
+ {
+ if (character < s_glyphs.Length)
+ {
+ glyph = s_glyphs[character];
+ return character == ' ' || glyph.Bits != 0;
+ }
+
+ glyph = default;
+ return false;
+ }
+
+ private static MatrixGlyph[] CreateGlyphs()
+ {
+ var glyphs = new MatrixGlyph[128];
+
+ void Add(char character, byte r0, byte r1, byte r2, byte r3, byte r4, byte r5, byte r6)
+ {
+ glyphs[character] = FromRows(r0, r1, r2, r3, r4, r5, r6);
+ }
+
+ Add('A', 0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001);
+ Add('B', 0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110);
+ Add('C', 0b01111, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b01111);
+ Add('D', 0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110);
+ Add('E', 0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111);
+ Add('F', 0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000);
+ Add('G', 0b01111, 0b10000, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110);
+ Add('H', 0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001);
+ Add('I', 0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110);
+ Add('J', 0b00111, 0b00010, 0b00010, 0b00010, 0b10010, 0b10010, 0b01100);
+ Add('K', 0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001);
+ Add('L', 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111);
+ Add('M', 0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001);
+ Add('N', 0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001);
+ Add('O', 0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110);
+ Add('P', 0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000);
+ Add('Q', 0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101);
+ Add('R', 0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001);
+ Add('S', 0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110);
+ Add('T', 0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100);
+ Add('U', 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110);
+ Add('V', 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100);
+ Add('W', 0b10001, 0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b01010);
+ Add('X', 0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001);
+ Add('Y', 0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100);
+ Add('Z', 0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111);
+
+ Add('0', 0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110);
+ Add('1', 0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110);
+ Add('2', 0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111);
+ Add('3', 0b11110, 0b00001, 0b00001, 0b01110, 0b00001, 0b00001, 0b11110);
+ Add('4', 0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010);
+ Add('5', 0b11111, 0b10000, 0b10000, 0b11110, 0b00001, 0b00001, 0b11110);
+ Add('6', 0b01110, 0b10000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110);
+ Add('7', 0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000);
+ Add('8', 0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110);
+ Add('9', 0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00001, 0b01110);
+
+ glyphs[' '] = default;
+ Add('?', 0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b00000, 0b00100);
+ Add('-', 0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000);
+ Add('.', 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00110, 0b00110);
+ Add(':', 0b00000, 0b00110, 0b00110, 0b00000, 0b00110, 0b00110, 0b00000);
+ Add('_', 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b11111);
+ Add('+', 0b00000, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0b00000);
+ Add('=', 0b00000, 0b00000, 0b11111, 0b00000, 0b11111, 0b00000, 0b00000);
+ Add('/', 0b00001, 0b00010, 0b00010, 0b00100, 0b01000, 0b01000, 0b10000);
+
+ return glyphs;
+ }
+
+ private static MatrixGlyph FromRows(byte r0, byte r1, byte r2, byte r3, byte r4, byte r5, byte r6)
+ {
+ var bits = PackRow(r0, 0)
+ | PackRow(r1, 1)
+ | PackRow(r2, 2)
+ | PackRow(r3, 3)
+ | PackRow(r4, 4)
+ | PackRow(r5, 5)
+ | PackRow(r6, 6);
+ return new MatrixGlyph(bits);
+ }
+
+ private static ulong PackRow(byte rowBits, int row)
+ {
+ ulong bits = 0;
+ for (var column = 0; column < Width; column++)
+ {
+ if ((rowBits & (1 << (Width - 1 - column))) != 0)
+ {
+ bits |= 1UL << (row * Width + column);
+ }
+ }
+
+ return bits;
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixGlyph.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixGlyph.cs
new file mode 100644
index 0000000..07d5ac3
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixGlyph.cs
@@ -0,0 +1,18 @@
+namespace AtomUI.Labs.Controls.Led.Matrix.Character;
+
+internal readonly record struct MatrixGlyph(ulong Bits)
+{
+ public const int Width = 5;
+ public const int Height = 7;
+
+ public bool IsActive(int row, int column)
+ {
+ if ((uint)row >= Height || (uint)column >= Width)
+ {
+ return false;
+ }
+
+ var bitIndex = row * Width + column;
+ return ((Bits >> bitIndex) & 1UL) != 0;
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixDisplayLayout.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixDisplayLayout.cs
new file mode 100644
index 0000000..a1633d4
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixDisplayLayout.cs
@@ -0,0 +1,19 @@
+using Avalonia;
+
+namespace AtomUI.Labs.Controls.Led.Matrix.Layout;
+
+internal sealed class MatrixDisplayLayout
+{
+ public MatrixDisplayLayout(Size desiredSize, Size glyphSize, IReadOnlyList slots)
+ {
+ DesiredSize = desiredSize;
+ GlyphSize = glyphSize;
+ Slots = slots;
+ }
+
+ public Size DesiredSize { get; }
+
+ public Size GlyphSize { get; }
+
+ public IReadOnlyList Slots { get; }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixGlyphSlot.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixGlyphSlot.cs
new file mode 100644
index 0000000..3e54b4d
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixGlyphSlot.cs
@@ -0,0 +1,8 @@
+using AtomUI.Labs.Controls.Led.Matrix.Character;
+using Avalonia;
+
+namespace AtomUI.Labs.Controls.Led.Matrix.Layout;
+
+internal readonly record struct MatrixGlyphSlot(
+ MatrixCharacterPattern Pattern,
+ Point Origin);
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixLayoutEngine.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixLayoutEngine.cs
new file mode 100644
index 0000000..0cdc426
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixLayoutEngine.cs
@@ -0,0 +1,52 @@
+using AtomUI.Labs.Controls.Led.Matrix.Character;
+using Avalonia;
+using System.Text;
+
+namespace AtomUI.Labs.Controls.Led.Matrix.Layout;
+
+internal static class MatrixLayoutEngine
+{
+ public static MatrixDisplayLayout Calculate(string? text, MatrixLayoutOptions options)
+ {
+ var dotSize = MatrixValueSanitizer.CoerceAtLeast(options.DotSize, 1);
+ var dotSpacing = MatrixValueSanitizer.CoerceNonNegative(options.DotSpacing);
+ var characterSpacing = MatrixValueSanitizer.CoerceNonNegative(options.CharacterSpacing);
+ var padding = MatrixValueSanitizer.CoerceThickness(options.Padding);
+ var glyphSize = new Size(
+ MatrixFiveBySevenGlyphMap.Width * dotSize
+ + (MatrixFiveBySevenGlyphMap.Width - 1) * dotSpacing,
+ MatrixFiveBySevenGlyphMap.Height * dotSize
+ + (MatrixFiveBySevenGlyphMap.Height - 1) * dotSpacing);
+
+ if (string.IsNullOrEmpty(text))
+ {
+ return new MatrixDisplayLayout(
+ new Size(padding.Left + padding.Right, padding.Top + padding.Bottom),
+ glyphSize,
+ Array.Empty());
+ }
+
+ var slots = new MatrixGlyphSlot[MatrixCharacterMap.GetPatternCount(text)];
+ var x = padding.Left;
+ var slotIndex = 0;
+
+ foreach (var rune in text.EnumerateRunes())
+ {
+ slots[slotIndex] = new MatrixGlyphSlot(
+ MatrixCharacterMap.GetPattern(rune),
+ new Point(x, padding.Top));
+ x += glyphSize.Width;
+ if (slotIndex < slots.Length - 1)
+ {
+ x += characterSpacing;
+ }
+
+ slotIndex++;
+ }
+
+ return new MatrixDisplayLayout(
+ new Size(x + padding.Right, glyphSize.Height + padding.Top + padding.Bottom),
+ glyphSize,
+ slots);
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixLayoutOptions.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixLayoutOptions.cs
new file mode 100644
index 0000000..fcfc202
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixLayoutOptions.cs
@@ -0,0 +1,9 @@
+using Avalonia;
+
+namespace AtomUI.Labs.Controls.Led.Matrix.Layout;
+
+internal readonly record struct MatrixLayoutOptions(
+ double DotSize,
+ double DotSpacing,
+ double CharacterSpacing,
+ Thickness Padding);
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/MatrixDisplay.cs b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixDisplay.cs
new file mode 100644
index 0000000..07d03b0
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixDisplay.cs
@@ -0,0 +1,991 @@
+using AtomUI.Labs.Controls.Led;
+using AtomUI.Labs.Controls.Led.Glow;
+using AtomUI.Labs.Controls.Led.Marquee;
+using AtomUI.Labs.Controls.Led.Matrix.Character;
+using AtomUI.Labs.Controls.Led.Matrix.Layout;
+using AtomUI.Labs.Controls.Led.Matrix.Rendering;
+using Avalonia;
+using Avalonia.Automation.Peers;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.VisualTree;
+using AvaloniaMatrix = Avalonia.Matrix;
+
+namespace AtomUI.Labs.Controls.Led.Matrix;
+
+public class MatrixDisplay : Control
+{
+ #region 公共属性定义
+
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ public static readonly StyledProperty DotSizeProperty =
+ AvaloniaProperty.Register(nameof(DotSize), 6);
+
+ public static readonly StyledProperty DotSpacingProperty =
+ AvaloniaProperty.Register(nameof(DotSpacing), 2);
+
+ public static readonly StyledProperty DotShapeProperty =
+ AvaloniaProperty.Register(nameof(DotShape));
+
+ public static readonly StyledProperty DotCornerRadiusRatioProperty =
+ AvaloniaProperty.Register(
+ nameof(DotCornerRadiusRatio),
+ MatrixDotShapeResolver.DefaultCornerRadiusRatio);
+
+ public static readonly StyledProperty CharacterSpacingProperty =
+ AvaloniaProperty.Register(nameof(CharacterSpacing), 8);
+
+ public static readonly StyledProperty PaddingProperty =
+ AvaloniaProperty.Register(nameof(Padding));
+
+ public static readonly StyledProperty HorizontalContentAlignmentProperty =
+ AvaloniaProperty.Register(nameof(HorizontalContentAlignment), HorizontalAlignment.Left);
+
+ public static readonly StyledProperty VerticalContentAlignmentProperty =
+ AvaloniaProperty.Register(nameof(VerticalContentAlignment), VerticalAlignment.Top);
+
+ public static readonly StyledProperty OverflowModeProperty =
+ AvaloniaProperty.Register(nameof(OverflowMode));
+
+ public static readonly StyledProperty BackgroundProperty =
+ AvaloniaProperty.Register(nameof(Background));
+
+ public static readonly StyledProperty BorderBrushProperty =
+ AvaloniaProperty.Register(nameof(BorderBrush));
+
+ public static readonly StyledProperty BorderThicknessProperty =
+ AvaloniaProperty.Register(nameof(BorderThickness));
+
+ public static readonly StyledProperty CornerRadiusProperty =
+ AvaloniaProperty.Register(nameof(CornerRadius));
+
+ public static readonly StyledProperty ActiveBrushProperty =
+ AvaloniaProperty.Register(nameof(ActiveBrush));
+
+ public static readonly StyledProperty InactiveBrushProperty =
+ AvaloniaProperty.Register(nameof(InactiveBrush));
+
+ public static readonly StyledProperty GlowBrushProperty =
+ AvaloniaProperty.Register(nameof(GlowBrush));
+
+ public static readonly StyledProperty GlowOpacityProperty =
+ AvaloniaProperty.Register(nameof(GlowOpacity), LedGlowValueSanitizer.DefaultOpacity);
+
+ public static readonly StyledProperty GlowRadiusProperty =
+ AvaloniaProperty.Register(nameof(GlowRadius), LedGlowValueSanitizer.DefaultRadius);
+
+ public static readonly StyledProperty ShowInactiveDotsProperty =
+ AvaloniaProperty.Register(nameof(ShowInactiveDots), true);
+
+ public static readonly StyledProperty IsMarqueeEnabledProperty =
+ AvaloniaProperty.Register(nameof(IsMarqueeEnabled));
+
+ public static readonly StyledProperty MarqueeSpeedProperty =
+ AvaloniaProperty.Register(nameof(MarqueeSpeed), LedMarqueeValueSanitizer.DefaultSpeed);
+
+ public static readonly StyledProperty MarqueeRepeatDelayProperty =
+ AvaloniaProperty.Register(
+ nameof(MarqueeRepeatDelay),
+ LedMarqueeValueSanitizer.DefaultRepeatDelay);
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public double DotSize
+ {
+ get => GetValue(DotSizeProperty);
+ set => SetValue(DotSizeProperty, value);
+ }
+
+ public double DotSpacing
+ {
+ get => GetValue(DotSpacingProperty);
+ set => SetValue(DotSpacingProperty, value);
+ }
+
+ public MatrixDotShape DotShape
+ {
+ get => GetValue(DotShapeProperty);
+ set => SetValue(DotShapeProperty, value);
+ }
+
+ public double DotCornerRadiusRatio
+ {
+ get => GetValue(DotCornerRadiusRatioProperty);
+ set => SetValue(DotCornerRadiusRatioProperty, value);
+ }
+
+ public double CharacterSpacing
+ {
+ get => GetValue(CharacterSpacingProperty);
+ set => SetValue(CharacterSpacingProperty, value);
+ }
+
+ public Thickness Padding
+ {
+ get => GetValue(PaddingProperty);
+ set => SetValue(PaddingProperty, value);
+ }
+
+ public HorizontalAlignment HorizontalContentAlignment
+ {
+ get => GetValue(HorizontalContentAlignmentProperty);
+ set => SetValue(HorizontalContentAlignmentProperty, value);
+ }
+
+ public VerticalAlignment VerticalContentAlignment
+ {
+ get => GetValue(VerticalContentAlignmentProperty);
+ set => SetValue(VerticalContentAlignmentProperty, value);
+ }
+
+ public MatrixOverflowMode OverflowMode
+ {
+ get => GetValue(OverflowModeProperty);
+ set => SetValue(OverflowModeProperty, value);
+ }
+
+ public IBrush? Background
+ {
+ get => GetValue(BackgroundProperty);
+ set => SetValue(BackgroundProperty, value);
+ }
+
+ public IBrush? BorderBrush
+ {
+ get => GetValue(BorderBrushProperty);
+ set => SetValue(BorderBrushProperty, value);
+ }
+
+ public Thickness BorderThickness
+ {
+ get => GetValue(BorderThicknessProperty);
+ set => SetValue(BorderThicknessProperty, value);
+ }
+
+ public CornerRadius CornerRadius
+ {
+ get => GetValue(CornerRadiusProperty);
+ set => SetValue(CornerRadiusProperty, value);
+ }
+
+ public IBrush? ActiveBrush
+ {
+ get => GetValue(ActiveBrushProperty);
+ set => SetValue(ActiveBrushProperty, value);
+ }
+
+ public IBrush? InactiveBrush
+ {
+ get => GetValue(InactiveBrushProperty);
+ set => SetValue(InactiveBrushProperty, value);
+ }
+
+ public IBrush? GlowBrush
+ {
+ get => GetValue(GlowBrushProperty);
+ set => SetValue(GlowBrushProperty, value);
+ }
+
+ public double GlowOpacity
+ {
+ get => GetValue(GlowOpacityProperty);
+ set => SetValue(GlowOpacityProperty, value);
+ }
+
+ public double GlowRadius
+ {
+ get => GetValue(GlowRadiusProperty);
+ set => SetValue(GlowRadiusProperty, value);
+ }
+
+ public bool ShowInactiveDots
+ {
+ get => GetValue(ShowInactiveDotsProperty);
+ set => SetValue(ShowInactiveDotsProperty, value);
+ }
+
+ public bool IsMarqueeEnabled
+ {
+ get => GetValue(IsMarqueeEnabledProperty);
+ set => SetValue(IsMarqueeEnabledProperty, value);
+ }
+
+ public double MarqueeSpeed
+ {
+ get => GetValue(MarqueeSpeedProperty);
+ set => SetValue(MarqueeSpeedProperty, value);
+ }
+
+ public TimeSpan MarqueeRepeatDelay
+ {
+ get => GetValue(MarqueeRepeatDelayProperty);
+ set => SetValue(MarqueeRepeatDelayProperty, value);
+ }
+
+ #endregion
+
+ #region 内部属性定义
+
+ internal int LayoutCacheVersion { get; private set; }
+
+ internal int GeometryBuildCount { get; private set; }
+
+ internal int GeometryCacheCount => _geometryCache.Count;
+
+ internal IEnumerable GeometryCacheValues => _geometryCache.Values;
+
+ internal int BorderGeometryBuildCount { get; private set; }
+
+ internal Geometry? BorderGeometryCache => _borderGeometryCache;
+
+ internal int GlowEffectBuildCount => _glowRenderer?.EffectBuildCount ?? 0;
+
+ internal int GlowEffectScopeCount => _glowRenderer?.EffectScopeCount ?? 0;
+
+ internal static readonly StyledProperty MarqueeProgressProperty =
+ AvaloniaProperty.Register("MarqueeProgress");
+
+ internal double MarqueeProgress
+ {
+ get => GetValue(MarqueeProgressProperty);
+ set => SetValue(MarqueeProgressProperty, value);
+ }
+
+ internal bool IsMarqueeAnimationRunning => _marqueeController?.IsRunning == true;
+
+ internal object? MarqueeController => _marqueeController;
+
+ internal int MarqueeVisibilitySubscriptionCount => _visibilityAncestors.Count;
+
+ #endregion
+
+ private bool _hasLayoutCache;
+ private LedGlowRenderer? _glowRenderer;
+ private LedMarqueeController? _marqueeController;
+ private Size _arrangedSize;
+ private bool _isAttachedToVisualTree;
+ private readonly List _visibilityAncestors = new();
+ private MatrixLayoutCacheKey _layoutCacheKey;
+ private MatrixDisplayLayout? _layoutCache;
+ private readonly Dictionary _geometryCache = new();
+ private bool _hasBorderGeometryCache;
+ private MatrixPanelBorderGeometryCacheKey _borderGeometryCacheKey;
+ private Geometry? _borderGeometryCache;
+ private IBrush? _borderPenBrush;
+ private double _borderPenThickness = double.NaN;
+ private Pen? _borderPen;
+
+ static MatrixDisplay()
+ {
+ AffectsMeasure(
+ TextProperty,
+ DotSizeProperty,
+ DotSpacingProperty,
+ CharacterSpacingProperty,
+ PaddingProperty,
+ BorderThicknessProperty);
+ AffectsRender(
+ HorizontalContentAlignmentProperty,
+ VerticalContentAlignmentProperty,
+ OverflowModeProperty,
+ DotShapeProperty,
+ DotCornerRadiusRatioProperty,
+ BackgroundProperty,
+ BorderBrushProperty,
+ BorderThicknessProperty,
+ CornerRadiusProperty,
+ ActiveBrushProperty,
+ InactiveBrushProperty,
+ GlowBrushProperty,
+ GlowOpacityProperty,
+ GlowRadiusProperty,
+ ShowInactiveDotsProperty,
+ IsMarqueeEnabledProperty,
+ MarqueeSpeedProperty,
+ MarqueeRepeatDelayProperty,
+ MarqueeProgressProperty);
+ }
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ var desiredSize = GetLayout().DesiredSize;
+ var borderThickness = GetEffectiveBorderThickness();
+ return new Size(
+ desiredSize.Width + borderThickness.Left + borderThickness.Right,
+ desiredSize.Height + borderThickness.Top + borderThickness.Bottom);
+ }
+
+ protected override AutomationPeer OnCreateAutomationPeer()
+ {
+ return new MatrixDisplayAutomationPeer(this);
+ }
+
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ var arrangedSize = base.ArrangeOverride(finalSize);
+ _arrangedSize = arrangedSize;
+ UpdateMarqueeAnimation();
+ return arrangedSize;
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ _isAttachedToVisualTree = true;
+ UpdateAncestorVisibilitySubscriptions();
+ UpdateMarqueeAnimation();
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ _isAttachedToVisualTree = false;
+ UnsubscribeFromAncestorVisibility();
+ ReleaseMarqueeController();
+ base.OnDetachedFromVisualTree(e);
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ base.Render(context);
+ RenderBackground(context);
+ RenderContent(context);
+ RenderBorder(context);
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == TextProperty)
+ {
+ ClearLayoutCache();
+ if (ControlAutomationPeer.FromElement(this) is MatrixDisplayAutomationPeer automationPeer)
+ {
+ automationPeer.NotifyTextChanged(change.GetOldValue(), change.GetNewValue());
+ }
+ }
+ else if (change.Property == DotSizeProperty
+ || change.Property == DotSpacingProperty
+ || change.Property == CharacterSpacingProperty
+ || change.Property == PaddingProperty)
+ {
+ ClearLayoutCache();
+ }
+
+ if (change.Property == DotSizeProperty || change.Property == DotSpacingProperty)
+ {
+ _geometryCache.Clear();
+ }
+ else if (change.Property == DotShapeProperty)
+ {
+ _geometryCache.Clear();
+ }
+ else if (change.Property == DotCornerRadiusRatioProperty
+ && MatrixDotShapeResolver.CoerceShape(DotShape) == MatrixDotShape.RoundedSquare)
+ {
+ _geometryCache.Clear();
+ }
+
+ if (change.Property == BorderThicknessProperty || change.Property == CornerRadiusProperty)
+ {
+ ClearBorderRenderCache();
+ }
+ else if (change.Property == BorderBrushProperty)
+ {
+ ClearBorderPenCache();
+ if (BorderBrush is null)
+ {
+ ClearBorderGeometryCache();
+ }
+ }
+
+ if (change.Property == GlowBrushProperty && GlowBrush is null)
+ {
+ _glowRenderer = null;
+ }
+
+ if (change.Property == IsMarqueeEnabledProperty)
+ {
+ UpdateAncestorVisibilitySubscriptions();
+ }
+
+ if (change.Property == TextProperty
+ || change.Property == DotSizeProperty
+ || change.Property == DotSpacingProperty
+ || change.Property == CharacterSpacingProperty
+ || change.Property == PaddingProperty
+ || change.Property == BorderThicknessProperty
+ || change.Property == IsMarqueeEnabledProperty
+ || change.Property == MarqueeSpeedProperty
+ || change.Property == MarqueeRepeatDelayProperty
+ || change.Property == IsVisibleProperty)
+ {
+ UpdateMarqueeAnimation();
+ }
+ }
+
+ private void RenderContent(DrawingContext context)
+ {
+ var activeBrush = ActiveBrush;
+ if (activeBrush is null)
+ {
+ return;
+ }
+
+ var layout = GetLayout();
+ var options = GetLayoutOptions();
+ var contentViewport = GetContentViewport();
+ var glow = GetGlowRenderOptions();
+ if (IsEffectiveMarqueeEnabled() && layout.Slots.Count > 0)
+ {
+ RenderMarqueeContent(context, layout, options, contentViewport, activeBrush, glow);
+ return;
+ }
+
+ var scale = OverflowMode == MatrixOverflowMode.ScaleDown
+ ? LedDisplayLayoutMath.CalculateScaleDown(layout.DesiredSize, contentViewport.Size)
+ : 1;
+ if (scale <= 0 || contentViewport.Width <= 0 || contentViewport.Height <= 0)
+ {
+ return;
+ }
+
+ var alignmentOffset = LedDisplayLayoutMath.CalculateAlignmentOffset(
+ layout.DesiredSize,
+ contentViewport.Size,
+ scale,
+ HorizontalContentAlignment,
+ VerticalContentAlignment);
+ var offset = new Vector(
+ contentViewport.X + alignmentOffset.X,
+ contentViewport.Y + alignmentOffset.Y);
+ var visibleBounds = CalculateVisibleBounds(contentViewport, scale, offset);
+ var effectiveGlowRadius = glow.EffectiveRadius;
+ if (effectiveGlowRadius > 0)
+ {
+ visibleBounds = visibleBounds.Inflate(effectiveGlowRadius);
+ }
+ using (context.PushClip(contentViewport))
+ using (PushLayoutTransform(context, scale, offset))
+ {
+ RenderVisibleGlyphs(context, layout, visibleBounds, options, activeBrush, glow);
+ }
+ }
+
+ private void RenderMarqueeContent(
+ DrawingContext context,
+ MatrixDisplayLayout layout,
+ MatrixLayoutOptions options,
+ Rect contentViewport,
+ IBrush activeBrush,
+ MatrixGlowRenderOptions glow)
+ {
+ if (contentViewport.Width <= 0 || contentViewport.Height <= 0)
+ {
+ return;
+ }
+
+ var verticalOffset = LedDisplayLayoutMath.CalculateAlignmentOffset(
+ layout.DesiredSize,
+ contentViewport.Size,
+ 1,
+ HorizontalAlignment.Left,
+ VerticalContentAlignment).Y;
+ var plan = LeftThroughMarqueeMotion.Calculate(new MarqueeMotionContext(
+ contentViewport.Width,
+ layout.DesiredSize.Width,
+ MarqueeProgress));
+
+ using (context.PushClip(contentViewport))
+ {
+ for (var i = 0; i < plan.PlacementCount; i++)
+ {
+ var offset = new Vector(
+ contentViewport.X + plan.GetX(i),
+ contentViewport.Y + verticalOffset);
+ var visibleBounds = CalculateVisibleBounds(contentViewport, 1, offset);
+ var effectiveGlowRadius = glow.EffectiveRadius;
+ if (effectiveGlowRadius > 0)
+ {
+ visibleBounds = visibleBounds.Inflate(effectiveGlowRadius);
+ }
+
+ using (PushLayoutTransform(context, 1, offset))
+ {
+ RenderVisibleGlyphs(context, layout, visibleBounds, options, activeBrush, glow);
+ }
+ }
+ }
+ }
+
+ private void UpdateMarqueeAnimation()
+ {
+ if (!_isAttachedToVisualTree
+ || !IsEffectivelyVisible
+ || !IsEffectiveMarqueeEnabled()
+ || string.IsNullOrEmpty(Text))
+ {
+ ReleaseMarqueeController();
+ return;
+ }
+
+ var size = _arrangedSize.Width > 0 && _arrangedSize.Height > 0 ? _arrangedSize : Bounds.Size;
+ var viewport = GetContentViewport(size);
+ var layout = GetLayout();
+ if (viewport.Width <= 0 || viewport.Height <= 0 || layout.Slots.Count == 0)
+ {
+ ReleaseMarqueeController();
+ return;
+ }
+
+ _marqueeController ??= new LedMarqueeController(this, MarqueeProgressProperty);
+ _marqueeController.Update(viewport.Width, layout.DesiredSize.Width, MarqueeSpeed, MarqueeRepeatDelay);
+ if (!_marqueeController.IsRunning)
+ {
+ ReleaseMarqueeController();
+ }
+ }
+
+ private void ReleaseMarqueeController()
+ {
+ _marqueeController?.Dispose();
+ _marqueeController = null;
+ }
+
+ private bool IsEffectiveMarqueeEnabled()
+ {
+ return IsMarqueeEnabled && LedMarqueeValueSanitizer.CoerceSpeed(MarqueeSpeed) > 0;
+ }
+
+ private void UpdateAncestorVisibilitySubscriptions()
+ {
+ if (_isAttachedToVisualTree && IsMarqueeEnabled)
+ {
+ SubscribeToAncestorVisibility();
+ }
+ else
+ {
+ UnsubscribeFromAncestorVisibility();
+ }
+ }
+
+ private void SubscribeToAncestorVisibility()
+ {
+ UnsubscribeFromAncestorVisibility();
+ foreach (var ancestor in this.GetVisualAncestors())
+ {
+ ancestor.PropertyChanged += HandleAncestorPropertyChanged;
+ _visibilityAncestors.Add(ancestor);
+ }
+ }
+
+ private void UnsubscribeFromAncestorVisibility()
+ {
+ foreach (var ancestor in _visibilityAncestors)
+ {
+ ancestor.PropertyChanged -= HandleAncestorPropertyChanged;
+ }
+
+ _visibilityAncestors.Clear();
+ }
+
+ private void HandleAncestorPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == IsVisibleProperty)
+ {
+ UpdateMarqueeAnimation();
+ }
+ }
+
+ private MatrixDisplayLayout GetLayout()
+ {
+ var key = new MatrixLayoutCacheKey(Text, GetLayoutOptions());
+ if (_hasLayoutCache && _layoutCacheKey == key && _layoutCache is not null)
+ {
+ return _layoutCache;
+ }
+
+ var layout = MatrixLayoutEngine.Calculate(key.Text, key.Options);
+ _layoutCacheKey = key;
+ _layoutCache = layout;
+ _hasLayoutCache = true;
+ LayoutCacheVersion++;
+ return layout;
+ }
+
+ private MatrixLayoutOptions GetLayoutOptions()
+ {
+ return new MatrixLayoutOptions(
+ MatrixValueSanitizer.CoerceAtLeast(DotSize, 1),
+ MatrixValueSanitizer.CoerceNonNegative(DotSpacing),
+ MatrixValueSanitizer.CoerceNonNegative(CharacterSpacing),
+ MatrixValueSanitizer.CoerceThickness(Padding));
+ }
+
+ private void RenderBackground(DrawingContext context)
+ {
+ var background = Background;
+ if (background is null)
+ {
+ return;
+ }
+
+ context.DrawRectangle(
+ background,
+ null,
+ new RoundedRect(
+ new Rect(0, 0, Bounds.Width, Bounds.Height),
+ MatrixValueSanitizer.CoerceCornerRadius(CornerRadius)));
+ }
+
+ private void RenderBorder(DrawingContext context)
+ {
+ var borderBrush = BorderBrush;
+ var borderThickness = GetEffectiveBorderThickness();
+ if (borderBrush is null
+ || Bounds.Width <= 0
+ || Bounds.Height <= 0
+ || !HasVisibleBorder(borderThickness))
+ {
+ return;
+ }
+
+ var cornerRadius = MatrixValueSanitizer.CoerceCornerRadius(CornerRadius);
+ if (borderThickness.IsUniform
+ && borderThickness.Top * 2 < Bounds.Width
+ && borderThickness.Top * 2 < Bounds.Height)
+ {
+ ClearBorderGeometryCache();
+ RenderUniformBorder(context, borderBrush, borderThickness.Top, cornerRadius);
+ return;
+ }
+
+ ClearBorderPenCache();
+ var geometry = GetBorderGeometry(borderThickness, cornerRadius);
+ context.DrawGeometry(borderBrush, null, geometry);
+ }
+
+ private void RenderUniformBorder(
+ DrawingContext context,
+ IBrush borderBrush,
+ double borderThickness,
+ CornerRadius cornerRadius)
+ {
+ if (!ReferenceEquals(_borderPenBrush, borderBrush) || _borderPenThickness != borderThickness)
+ {
+ _borderPenBrush = borderBrush;
+ _borderPenThickness = borderThickness;
+ _borderPen = new Pen(borderBrush, borderThickness);
+ }
+
+ var halfThickness = borderThickness / 2;
+ var rect = new Rect(Bounds.Size).Deflate(halfThickness);
+ var centerRadius = new CornerRadius(
+ Math.Max(0, cornerRadius.TopLeft - halfThickness),
+ Math.Max(0, cornerRadius.TopRight - halfThickness),
+ Math.Max(0, cornerRadius.BottomRight - halfThickness),
+ Math.Max(0, cornerRadius.BottomLeft - halfThickness));
+ context.DrawRectangle(null, _borderPen, new RoundedRect(rect, centerRadius));
+ }
+
+ private Geometry GetBorderGeometry(Thickness borderThickness, CornerRadius cornerRadius)
+ {
+ var key = new MatrixPanelBorderGeometryCacheKey(Bounds.Size, borderThickness, cornerRadius);
+ if (_hasBorderGeometryCache && _borderGeometryCacheKey == key && _borderGeometryCache is not null)
+ {
+ return _borderGeometryCache;
+ }
+
+ _borderGeometryCache = MatrixPanelBorderGeometryFactory.Create(
+ Bounds.Size,
+ borderThickness,
+ cornerRadius);
+ _borderGeometryCacheKey = key;
+ _hasBorderGeometryCache = true;
+ BorderGeometryBuildCount++;
+ return _borderGeometryCache;
+ }
+
+ private static IDisposable PushLayoutTransform(DrawingContext context, double scale, Vector offset)
+ {
+ return context.PushTransform(AvaloniaMatrix.CreateScale(scale, scale) * AvaloniaMatrix.CreateTranslation(offset.X, offset.Y));
+ }
+
+ private static Rect CalculateVisibleBounds(Rect viewport, double scale, Vector offset)
+ {
+ return new Rect(
+ (viewport.Left - offset.X) / scale,
+ (viewport.Top - offset.Y) / scale,
+ viewport.Width / scale,
+ viewport.Height / scale);
+ }
+
+ private MatrixGlowRenderOptions GetGlowRenderOptions()
+ {
+ return new MatrixGlowRenderOptions(
+ GlowBrush,
+ LedGlowValueSanitizer.CoerceOpacity(GlowOpacity),
+ LedGlowValueSanitizer.CoerceRadius(GlowRadius));
+ }
+
+ private LedGlowRenderScope PushGlow(
+ DrawingContext context,
+ Rect activeBounds,
+ in MatrixGlowRenderOptions glow)
+ {
+ if (!glow.IsActive)
+ {
+ return default;
+ }
+
+ _glowRenderer ??= new LedGlowRenderer();
+ return _glowRenderer.Push(context, glow.Brush, glow.Opacity, glow.Radius, activeBounds);
+ }
+
+ private void RenderVisibleGlyphs(
+ DrawingContext context,
+ MatrixDisplayLayout layout,
+ Rect visibleBounds,
+ MatrixLayoutOptions options,
+ IBrush activeBrush,
+ in MatrixGlowRenderOptions glow)
+ {
+ if (layout.Slots.Count == 0)
+ {
+ return;
+ }
+
+ var firstSlot = layout.Slots[0];
+ if (firstSlot.Origin.Y >= visibleBounds.Bottom
+ || firstSlot.Origin.Y + layout.GlyphSize.Height <= visibleBounds.Top)
+ {
+ return;
+ }
+
+ var firstVisibleIndex = FindFirstVisibleSlot(layout, visibleBounds.Left);
+ var lastVisibleIndex = FindLastVisibleSlot(layout, visibleBounds.Right, firstVisibleIndex);
+ RenderInactiveGlyphs(context, layout, options, firstVisibleIndex, lastVisibleIndex);
+
+ if (TryCalculateActiveBounds(layout, options, firstVisibleIndex, lastVisibleIndex, out var activeBounds))
+ {
+ using (var glowScope = PushGlow(context, activeBounds, glow))
+ {
+ if (glowScope.IsActive)
+ {
+ RenderActiveGlyphs(context, layout, options, firstVisibleIndex, lastVisibleIndex, glow.Brush!);
+ }
+ }
+ }
+
+ RenderActiveGlyphs(context, layout, options, firstVisibleIndex, lastVisibleIndex, activeBrush);
+ }
+
+ private readonly record struct MatrixGlowRenderOptions(IBrush? Brush, double Opacity, double Radius)
+ {
+ public bool IsActive => Brush is not null && Opacity > 0 && Radius > 0;
+
+ public double EffectiveRadius => IsActive ? Radius : 0;
+ }
+
+ private static int FindLastVisibleSlot(MatrixDisplayLayout layout, double visibleRight, int firstVisibleIndex)
+ {
+ var index = firstVisibleIndex;
+ while (index < layout.Slots.Count && layout.Slots[index].Origin.X < visibleRight)
+ {
+ index++;
+ }
+
+ return index;
+ }
+
+ private void RenderInactiveGlyphs(
+ DrawingContext context,
+ MatrixDisplayLayout layout,
+ MatrixLayoutOptions options,
+ int start,
+ int end)
+ {
+ var inactiveBrush = InactiveBrush;
+ if (!ShowInactiveDots || inactiveBrush is null)
+ {
+ return;
+ }
+
+ for (var i = start; i < end; i++)
+ {
+ var slot = layout.Slots[i];
+ var geometry = GetGlyphGeometry(slot.Pattern.Glyph, options);
+ if (geometry.InactiveDotCount == 0)
+ {
+ continue;
+ }
+
+ using (context.PushTransform(AvaloniaMatrix.CreateTranslation(slot.Origin.X, slot.Origin.Y)))
+ {
+ context.DrawGeometry(inactiveBrush, null, geometry.InactiveGeometry);
+ }
+ }
+ }
+
+ private void RenderActiveGlyphs(
+ DrawingContext context,
+ MatrixDisplayLayout layout,
+ MatrixLayoutOptions options,
+ int start,
+ int end,
+ IBrush brush)
+ {
+ for (var i = start; i < end; i++)
+ {
+ var slot = layout.Slots[i];
+ var geometry = GetGlyphGeometry(slot.Pattern.Glyph, options);
+ if (geometry.ActiveDotCount == 0)
+ {
+ continue;
+ }
+
+ using (context.PushTransform(AvaloniaMatrix.CreateTranslation(slot.Origin.X, slot.Origin.Y)))
+ {
+ context.DrawGeometry(brush, null, geometry.ActiveGeometry);
+ }
+ }
+ }
+
+ private bool TryCalculateActiveBounds(
+ MatrixDisplayLayout layout,
+ MatrixLayoutOptions options,
+ int start,
+ int end,
+ out Rect bounds)
+ {
+ bounds = default;
+ var hasBounds = false;
+ for (var i = start; i < end; i++)
+ {
+ var slot = layout.Slots[i];
+ var geometry = GetGlyphGeometry(slot.Pattern.Glyph, options);
+ if (geometry.ActiveDotCount == 0)
+ {
+ continue;
+ }
+
+ var translated = geometry.ActiveGeometry.Bounds.Translate(new Vector(slot.Origin.X, slot.Origin.Y));
+ bounds = hasBounds ? bounds.Union(translated) : translated;
+ hasBounds = true;
+ }
+
+ return hasBounds;
+ }
+
+ private static int FindFirstVisibleSlot(MatrixDisplayLayout layout, double visibleLeft)
+ {
+ var low = 0;
+ var high = layout.Slots.Count;
+ while (low < high)
+ {
+ var middle = low + (high - low) / 2;
+ if (layout.Slots[middle].Origin.X + layout.GlyphSize.Width <= visibleLeft)
+ {
+ low = middle + 1;
+ }
+ else
+ {
+ high = middle;
+ }
+ }
+
+ return low;
+ }
+
+ private MatrixGlyphGeometry GetGlyphGeometry(MatrixGlyph glyph, MatrixLayoutOptions options)
+ {
+ var dotShape = MatrixDotShapeResolver.CoerceShape(DotShape);
+ var dotCornerRadiusRatio = MatrixDotShapeResolver.GetEffectiveCornerRadiusRatio(
+ dotShape,
+ DotCornerRadiusRatio);
+ var key = new MatrixGlyphGeometryCacheKey(
+ glyph.Bits,
+ options.DotSize,
+ options.DotSpacing,
+ dotShape,
+ dotCornerRadiusRatio);
+ if (_geometryCache.TryGetValue(key, out var geometry))
+ {
+ return geometry;
+ }
+
+ geometry = MatrixGlyphGeometryFactory.Create(
+ glyph,
+ options.DotSize,
+ options.DotSpacing,
+ dotShape,
+ dotCornerRadiusRatio);
+ _geometryCache.Add(key, geometry);
+ GeometryBuildCount++;
+ return geometry;
+ }
+
+ private void ClearLayoutCache()
+ {
+ _hasLayoutCache = false;
+ _layoutCache = null;
+ }
+
+ private Thickness GetEffectiveBorderThickness()
+ {
+ return MatrixValueSanitizer.CoerceThickness(BorderThickness);
+ }
+
+ private Rect GetContentViewport()
+ {
+ return GetContentViewport(Bounds.Size);
+ }
+
+ private Rect GetContentViewport(Size size)
+ {
+ var borderThickness = GetEffectiveBorderThickness();
+ return new Rect(
+ borderThickness.Left,
+ borderThickness.Top,
+ Math.Max(0, size.Width - borderThickness.Left - borderThickness.Right),
+ Math.Max(0, size.Height - borderThickness.Top - borderThickness.Bottom));
+ }
+
+ private static bool HasVisibleBorder(Thickness thickness)
+ {
+ return thickness.Left > 0
+ || thickness.Top > 0
+ || thickness.Right > 0
+ || thickness.Bottom > 0;
+ }
+
+ private void ClearBorderRenderCache()
+ {
+ ClearBorderGeometryCache();
+ ClearBorderPenCache();
+ }
+
+ private void ClearBorderPenCache()
+ {
+ _borderPen = null;
+ _borderPenBrush = null;
+ _borderPenThickness = double.NaN;
+ }
+
+ private void ClearBorderGeometryCache()
+ {
+ _hasBorderGeometryCache = false;
+ _borderGeometryCache = null;
+ }
+
+ private readonly record struct MatrixLayoutCacheKey(
+ string? Text,
+ MatrixLayoutOptions Options);
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/MatrixDisplayAutomationPeer.cs b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixDisplayAutomationPeer.cs
new file mode 100644
index 0000000..c674087
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixDisplayAutomationPeer.cs
@@ -0,0 +1,55 @@
+using AtomUI.Labs.Controls.Led.Matrix.Character;
+using Avalonia.Automation;
+using Avalonia.Automation.Peers;
+
+namespace AtomUI.Labs.Controls.Led.Matrix;
+
+internal sealed class MatrixDisplayAutomationPeer : ControlAutomationPeer
+{
+ private readonly MatrixDisplay _owner;
+
+ public MatrixDisplayAutomationPeer(MatrixDisplay owner) : base(owner)
+ {
+ _owner = owner;
+ }
+
+ protected override string GetClassNameCore()
+ {
+ return nameof(MatrixDisplay);
+ }
+
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ {
+ return AutomationControlType.Text;
+ }
+
+ protected override string? GetNameCore()
+ {
+ var configuredName = AutomationProperties.GetName(_owner);
+ if (configuredName is not null)
+ {
+ return configuredName;
+ }
+
+ var inheritedName = base.GetNameCore();
+ return string.IsNullOrEmpty(inheritedName)
+ ? MatrixCharacterMap.GetDisplayText(_owner.Text)
+ : inheritedName;
+ }
+
+ internal void NotifyTextChanged(string? oldText, string? newText)
+ {
+ if (AutomationProperties.GetName(_owner) is not null
+ || !string.IsNullOrEmpty(base.GetNameCore()))
+ {
+ return;
+ }
+
+ var oldName = MatrixCharacterMap.GetDisplayText(oldText);
+ var newName = MatrixCharacterMap.GetDisplayText(newText);
+ if (oldName != newName)
+ {
+ RaisePropertyChangedEvent(AutomationElementIdentifiers.NameProperty, oldName, newName);
+ }
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/MatrixDotShape.cs b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixDotShape.cs
new file mode 100644
index 0000000..16f3543
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixDotShape.cs
@@ -0,0 +1,8 @@
+namespace AtomUI.Labs.Controls.Led.Matrix;
+
+public enum MatrixDotShape
+{
+ Circle,
+ Square,
+ RoundedSquare
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/MatrixOverflowMode.cs b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixOverflowMode.cs
new file mode 100644
index 0000000..01b6763
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixOverflowMode.cs
@@ -0,0 +1,7 @@
+namespace AtomUI.Labs.Controls.Led.Matrix;
+
+public enum MatrixOverflowMode
+{
+ Clip,
+ ScaleDown
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/MatrixValueSanitizer.cs b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixValueSanitizer.cs
new file mode 100644
index 0000000..e196293
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixValueSanitizer.cs
@@ -0,0 +1,46 @@
+using Avalonia;
+
+namespace AtomUI.Labs.Controls.Led.Matrix;
+
+internal static class MatrixValueSanitizer
+{
+ public const double MaximumLayoutValue = 1_000_000;
+
+ public static double CoerceNonNegative(double value)
+ {
+ return IsFinite(value) ? Math.Clamp(value, 0, MaximumLayoutValue) : 0;
+ }
+
+ public static double CoerceAtLeast(double value, double minimum)
+ {
+ return IsFinite(value) ? Math.Clamp(value, minimum, MaximumLayoutValue) : minimum;
+ }
+
+ public static double CoerceRange(double value, double minimum, double maximum)
+ {
+ return IsFinite(value) ? Math.Clamp(value, minimum, maximum) : minimum;
+ }
+
+ public static Thickness CoerceThickness(Thickness thickness)
+ {
+ return new Thickness(
+ CoerceNonNegative(thickness.Left),
+ CoerceNonNegative(thickness.Top),
+ CoerceNonNegative(thickness.Right),
+ CoerceNonNegative(thickness.Bottom));
+ }
+
+ public static CornerRadius CoerceCornerRadius(CornerRadius cornerRadius)
+ {
+ return new CornerRadius(
+ CoerceNonNegative(cornerRadius.TopLeft),
+ CoerceNonNegative(cornerRadius.TopRight),
+ CoerceNonNegative(cornerRadius.BottomRight),
+ CoerceNonNegative(cornerRadius.BottomLeft));
+ }
+
+ public static bool IsFinite(double value)
+ {
+ return !double.IsNaN(value) && !double.IsInfinity(value);
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixDotShapeResolver.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixDotShapeResolver.cs
new file mode 100644
index 0000000..7891dbf
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixDotShapeResolver.cs
@@ -0,0 +1,21 @@
+namespace AtomUI.Labs.Controls.Led.Matrix.Rendering;
+
+internal static class MatrixDotShapeResolver
+{
+ public const double DefaultCornerRadiusRatio = 0.25;
+ public const double MaximumCornerRadiusRatio = 0.5;
+
+ public static MatrixDotShape CoerceShape(MatrixDotShape shape)
+ {
+ return shape is MatrixDotShape.Circle or MatrixDotShape.Square or MatrixDotShape.RoundedSquare
+ ? shape
+ : MatrixDotShape.Circle;
+ }
+
+ public static double GetEffectiveCornerRadiusRatio(MatrixDotShape shape, double cornerRadiusRatio)
+ {
+ return CoerceShape(shape) == MatrixDotShape.RoundedSquare
+ ? MatrixValueSanitizer.CoerceRange(cornerRadiusRatio, 0, MaximumCornerRadiusRatio)
+ : 0;
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometry.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometry.cs
new file mode 100644
index 0000000..4439dd4
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometry.cs
@@ -0,0 +1,26 @@
+using Avalonia.Media;
+
+namespace AtomUI.Labs.Controls.Led.Matrix.Rendering;
+
+internal sealed class MatrixGlyphGeometry
+{
+ public MatrixGlyphGeometry(
+ Geometry activeGeometry,
+ Geometry inactiveGeometry,
+ int activeDotCount,
+ int inactiveDotCount)
+ {
+ ActiveGeometry = activeGeometry;
+ InactiveGeometry = inactiveGeometry;
+ ActiveDotCount = activeDotCount;
+ InactiveDotCount = inactiveDotCount;
+ }
+
+ public Geometry ActiveGeometry { get; }
+
+ public Geometry InactiveGeometry { get; }
+
+ public int ActiveDotCount { get; }
+
+ public int InactiveDotCount { get; }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometryCacheKey.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometryCacheKey.cs
new file mode 100644
index 0000000..8dd2ff3
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometryCacheKey.cs
@@ -0,0 +1,8 @@
+namespace AtomUI.Labs.Controls.Led.Matrix.Rendering;
+
+internal readonly record struct MatrixGlyphGeometryCacheKey(
+ ulong Bits,
+ double DotSize,
+ double DotSpacing,
+ MatrixDotShape DotShape,
+ double DotCornerRadiusRatio);
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometryFactory.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometryFactory.cs
new file mode 100644
index 0000000..8fcd6ec
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometryFactory.cs
@@ -0,0 +1,147 @@
+using AtomUI.Labs.Controls.Led.Matrix.Character;
+using Avalonia;
+using Avalonia.Media;
+
+namespace AtomUI.Labs.Controls.Led.Matrix.Rendering;
+
+internal static class MatrixGlyphGeometryFactory
+{
+ public static MatrixGlyphGeometry Create(
+ MatrixGlyph glyph,
+ double dotSize,
+ double dotSpacing,
+ MatrixDotShape dotShape,
+ double dotCornerRadiusRatio)
+ {
+ dotSize = MatrixValueSanitizer.CoerceAtLeast(dotSize, 1);
+ dotSpacing = MatrixValueSanitizer.CoerceNonNegative(dotSpacing);
+ dotShape = MatrixDotShapeResolver.CoerceShape(dotShape);
+ dotCornerRadiusRatio = MatrixDotShapeResolver.GetEffectiveCornerRadiusRatio(
+ dotShape,
+ dotCornerRadiusRatio);
+
+ var activeGeometry = new StreamGeometry();
+ var inactiveGeometry = new StreamGeometry();
+ var activeDotCount = 0;
+ var inactiveDotCount = 0;
+ var step = dotSize + dotSpacing;
+
+ using (var activeContext = activeGeometry.Open())
+ using (var inactiveContext = inactiveGeometry.Open())
+ {
+ for (var row = 0; row < MatrixGlyph.Height; row++)
+ {
+ for (var column = 0; column < MatrixGlyph.Width; column++)
+ {
+ var origin = new Point(column * step, row * step);
+ if (glyph.IsActive(row, column))
+ {
+ AppendDot(
+ activeContext,
+ origin,
+ dotSize,
+ dotShape,
+ dotCornerRadiusRatio);
+ activeDotCount++;
+ }
+ else
+ {
+ AppendDot(
+ inactiveContext,
+ origin,
+ dotSize,
+ dotShape,
+ dotCornerRadiusRatio);
+ inactiveDotCount++;
+ }
+ }
+ }
+ }
+
+ return new MatrixGlyphGeometry(
+ activeGeometry,
+ inactiveGeometry,
+ activeDotCount,
+ inactiveDotCount);
+ }
+
+ private static void AppendDot(
+ StreamGeometryContext context,
+ Point origin,
+ double dotSize,
+ MatrixDotShape dotShape,
+ double dotCornerRadiusRatio)
+ {
+ switch (dotShape)
+ {
+ case MatrixDotShape.Square:
+ AppendSquare(context, origin, dotSize);
+ break;
+ case MatrixDotShape.RoundedSquare:
+ AppendRoundedSquare(context, origin, dotSize, dotSize * dotCornerRadiusRatio);
+ break;
+ default:
+ var radius = dotSize / 2;
+ AppendCircle(
+ context,
+ new Point(origin.X + radius, origin.Y + radius),
+ radius);
+ break;
+ }
+ }
+
+ private static void AppendCircle(StreamGeometryContext context, Point center, double radius)
+ {
+ var left = new Point(center.X - radius, center.Y);
+ var right = new Point(center.X + radius, center.Y);
+ var size = new Size(radius, radius);
+
+ context.BeginFigure(left, true);
+ context.ArcTo(right, size, 0, false, SweepDirection.Clockwise, true);
+ context.ArcTo(left, size, 0, false, SweepDirection.Clockwise, true);
+ context.EndFigure(true);
+ }
+
+ private static void AppendSquare(StreamGeometryContext context, Point origin, double dotSize)
+ {
+ var topRight = new Point(origin.X + dotSize, origin.Y);
+ var bottomRight = new Point(origin.X + dotSize, origin.Y + dotSize);
+ var bottomLeft = new Point(origin.X, origin.Y + dotSize);
+
+ context.BeginFigure(origin, true);
+ context.LineTo(topRight, true);
+ context.LineTo(bottomRight, true);
+ context.LineTo(bottomLeft, true);
+ context.EndFigure(true);
+ }
+
+ private static void AppendRoundedSquare(
+ StreamGeometryContext context,
+ Point origin,
+ double dotSize,
+ double radius)
+ {
+ if (radius <= 0)
+ {
+ AppendSquare(context, origin, dotSize);
+ return;
+ }
+
+ var left = origin.X;
+ var top = origin.Y;
+ var right = left + dotSize;
+ var bottom = top + dotSize;
+ var arcSize = new Size(radius, radius);
+
+ context.BeginFigure(new Point(left + radius, top), true);
+ context.LineTo(new Point(right - radius, top), true);
+ context.ArcTo(new Point(right, top + radius), arcSize, 0, false, SweepDirection.Clockwise, true);
+ context.LineTo(new Point(right, bottom - radius), true);
+ context.ArcTo(new Point(right - radius, bottom), arcSize, 0, false, SweepDirection.Clockwise, true);
+ context.LineTo(new Point(left + radius, bottom), true);
+ context.ArcTo(new Point(left, bottom - radius), arcSize, 0, false, SweepDirection.Clockwise, true);
+ context.LineTo(new Point(left, top + radius), true);
+ context.ArcTo(new Point(left + radius, top), arcSize, 0, false, SweepDirection.Clockwise, true);
+ context.EndFigure(true);
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixPanelBorderGeometryFactory.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixPanelBorderGeometryFactory.cs
new file mode 100644
index 0000000..152d59f
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixPanelBorderGeometryFactory.cs
@@ -0,0 +1,154 @@
+using Avalonia;
+using Avalonia.Media;
+
+namespace AtomUI.Labs.Controls.Led.Matrix.Rendering;
+
+internal readonly record struct MatrixPanelBorderGeometryCacheKey(
+ Size Size,
+ Thickness BorderThickness,
+ CornerRadius CornerRadius);
+
+internal static class MatrixPanelBorderGeometryFactory
+{
+ public static Geometry Create(Size size, Thickness borderThickness, CornerRadius cornerRadius)
+ {
+ var bounds = new Rect(size);
+ var outer = CreateRoundedRectangle(
+ bounds,
+ new CornerRadii(cornerRadius));
+ var innerBounds = new Rect(
+ borderThickness.Left,
+ borderThickness.Top,
+ Math.Max(0, size.Width - borderThickness.Left - borderThickness.Right),
+ Math.Max(0, size.Height - borderThickness.Top - borderThickness.Bottom));
+ if (innerBounds.Width <= 0 || innerBounds.Height <= 0)
+ {
+ return outer;
+ }
+
+ var inner = CreateRoundedRectangle(
+ innerBounds,
+ CornerRadii.CreateInner(cornerRadius, borderThickness));
+ return new CombinedGeometry(GeometryCombineMode.Exclude, outer, inner);
+ }
+
+ private static StreamGeometry CreateRoundedRectangle(Rect bounds, CornerRadii radii)
+ {
+ radii = radii.Normalize(bounds.Size);
+ var geometry = new StreamGeometry();
+ using var context = geometry.Open();
+
+ context.BeginFigure(new Point(bounds.Left + radii.TopLeftX, bounds.Top), true);
+ context.LineTo(new Point(bounds.Right - radii.TopRightX, bounds.Top), true);
+ AppendCorner(
+ context,
+ new Point(bounds.Right, bounds.Top + radii.TopRightY),
+ radii.TopRightX,
+ radii.TopRightY);
+ context.LineTo(new Point(bounds.Right, bounds.Bottom - radii.BottomRightY), true);
+ AppendCorner(
+ context,
+ new Point(bounds.Right - radii.BottomRightX, bounds.Bottom),
+ radii.BottomRightX,
+ radii.BottomRightY);
+ context.LineTo(new Point(bounds.Left + radii.BottomLeftX, bounds.Bottom), true);
+ AppendCorner(
+ context,
+ new Point(bounds.Left, bounds.Bottom - radii.BottomLeftY),
+ radii.BottomLeftX,
+ radii.BottomLeftY);
+ context.LineTo(new Point(bounds.Left, bounds.Top + radii.TopLeftY), true);
+ AppendCorner(
+ context,
+ new Point(bounds.Left + radii.TopLeftX, bounds.Top),
+ radii.TopLeftX,
+ radii.TopLeftY);
+ context.EndFigure(true);
+ return geometry;
+ }
+
+ private static void AppendCorner(
+ StreamGeometryContext context,
+ Point endPoint,
+ double radiusX,
+ double radiusY)
+ {
+ if (radiusX <= 0 || radiusY <= 0)
+ {
+ context.LineTo(endPoint, true);
+ return;
+ }
+
+ context.ArcTo(
+ endPoint,
+ new Size(radiusX, radiusY),
+ 0,
+ false,
+ SweepDirection.Clockwise,
+ true);
+ }
+
+ private readonly record struct CornerRadii(
+ double TopLeftX,
+ double TopLeftY,
+ double TopRightX,
+ double TopRightY,
+ double BottomRightX,
+ double BottomRightY,
+ double BottomLeftX,
+ double BottomLeftY)
+ {
+ public CornerRadii(CornerRadius cornerRadius)
+ : this(
+ cornerRadius.TopLeft,
+ cornerRadius.TopLeft,
+ cornerRadius.TopRight,
+ cornerRadius.TopRight,
+ cornerRadius.BottomRight,
+ cornerRadius.BottomRight,
+ cornerRadius.BottomLeft,
+ cornerRadius.BottomLeft)
+ {
+ }
+
+ public static CornerRadii CreateInner(CornerRadius cornerRadius, Thickness thickness)
+ {
+ return new CornerRadii(
+ Math.Max(0, cornerRadius.TopLeft - thickness.Left),
+ Math.Max(0, cornerRadius.TopLeft - thickness.Top),
+ Math.Max(0, cornerRadius.TopRight - thickness.Right),
+ Math.Max(0, cornerRadius.TopRight - thickness.Top),
+ Math.Max(0, cornerRadius.BottomRight - thickness.Right),
+ Math.Max(0, cornerRadius.BottomRight - thickness.Bottom),
+ Math.Max(0, cornerRadius.BottomLeft - thickness.Left),
+ Math.Max(0, cornerRadius.BottomLeft - thickness.Bottom));
+ }
+
+ public CornerRadii Normalize(Size size)
+ {
+ var scale = 1d;
+ scale = LimitScale(scale, size.Width, TopLeftX + TopRightX);
+ scale = LimitScale(scale, size.Width, BottomLeftX + BottomRightX);
+ scale = LimitScale(scale, size.Height, TopLeftY + BottomLeftY);
+ scale = LimitScale(scale, size.Height, TopRightY + BottomRightY);
+ return scale >= 1
+ ? this
+ : new CornerRadii(
+ TopLeftX * scale,
+ TopLeftY * scale,
+ TopRightX * scale,
+ TopRightY * scale,
+ BottomRightX * scale,
+ BottomRightY * scale,
+ BottomLeftX * scale,
+ BottomLeftY * scale);
+ }
+
+ private static double LimitScale(double current, double available, double required)
+ {
+ return required > available && required > 0
+ ? Math.Min(current, available / required)
+ : current;
+ }
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/Themes/MatrixDisplayTheme.axaml b/src/AtomUI.Labs.Controls.Led/Matrix/Themes/MatrixDisplayTheme.axaml
new file mode 100644
index 0000000..b127cca
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/Themes/MatrixDisplayTheme.axaml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/AtomUI.Labs.Controls.Led/Matrix/Themes/MatrixThemes.axaml b/src/AtomUI.Labs.Controls.Led/Matrix/Themes/MatrixThemes.axaml
new file mode 100644
index 0000000..57f4459
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Matrix/Themes/MatrixThemes.axaml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/src/AtomUI.Labs.Controls.Led/Properties/AssemblyInfo.cs b/src/AtomUI.Labs.Controls.Led/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..3af59f6
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Properties/AssemblyInfo.cs
@@ -0,0 +1,6 @@
+using Avalonia.Metadata;
+
+[assembly: XmlnsPrefix("https://atomui.net/labs", "atom.labs")]
+[assembly: XmlnsDefinition("https://atomui.net/labs", "AtomUI.Labs.Controls.Led")]
+[assembly: XmlnsDefinition("https://atomui.net/labs", "AtomUI.Labs.Controls.Led.Segment")]
+[assembly: XmlnsDefinition("https://atomui.net/labs", "AtomUI.Labs.Controls.Led.Matrix")]
diff --git a/src/AtomUI.Labs.Controls.Led/README.nuget.md b/src/AtomUI.Labs.Controls.Led/README.nuget.md
new file mode 100644
index 0000000..c3c3c31
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/README.nuget.md
@@ -0,0 +1,38 @@
+## AtomUI Labs LED
+
+`AtomUI.Labs.Controls.Led` provides experimental LED-style display controls for AtomUI applications:
+
+- `SegmentDisplay`: fourteen-segment text display;
+- `MatrixDisplay`: fixed 5x7 dot-matrix text display with optional marquee and glow.
+
+### Install
+
+```bash
+dotnet add package AtomUI.Labs.Controls.Led
+```
+
+Use a package version that matches the AtomUI packages in the application.
+
+### Application setup
+
+```csharp
+this.UseAtomUI(builder => builder.UseLed());
+```
+
+### AXAML
+
+```xml
+
+
+
+
+
+
+```
+
+The package depends directly on AtomUI.Core and Avalonia. It does not require AtomUI.Desktop.Controls.
+
+### Status and license
+
+The controls are experimental and their public APIs may evolve. The package follows the AtomUI.Labs repository license.
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterKind.cs b/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterKind.cs
new file mode 100644
index 0000000..5ad2883
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterKind.cs
@@ -0,0 +1,9 @@
+namespace AtomUI.Labs.Controls.Led.Segment.Character;
+
+internal enum SegmentCharacterKind
+{
+ Empty,
+ Segments,
+ Colon,
+ Dot
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterMap.cs b/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterMap.cs
new file mode 100644
index 0000000..f77b503
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterMap.cs
@@ -0,0 +1,125 @@
+using AtomUI.Labs.Controls.Led;
+
+namespace AtomUI.Labs.Controls.Led.Segment.Character;
+
+internal static class SegmentCharacterMap
+{
+ public static string GetDisplayText(string? text)
+ {
+ if (string.IsNullOrEmpty(text))
+ {
+ return string.Empty;
+ }
+
+ char[]? normalized = null;
+ for (var i = 0; i < text.Length; i++)
+ {
+ var displayCharacter = GetPattern(text[i]).Character;
+ if (displayCharacter == text[i])
+ {
+ continue;
+ }
+
+ normalized ??= text.ToCharArray();
+ normalized[i] = displayCharacter;
+ }
+
+ return normalized is null ? text : new string(normalized);
+ }
+
+ public static SegmentCharacterPattern GetPattern(char character)
+ {
+ var normalized = LedCharacterNormalizer.NormalizeAscii(character);
+ if (normalized == ' ')
+ {
+ return new SegmentCharacterPattern(normalized, SegmentCharacterKind.Empty, SegmentParts.None);
+ }
+
+ if (normalized == ':')
+ {
+ return new SegmentCharacterPattern(normalized, SegmentCharacterKind.Colon, SegmentParts.None);
+ }
+
+ if (normalized == '.')
+ {
+ return new SegmentCharacterPattern(normalized, SegmentCharacterKind.Dot, SegmentParts.None);
+ }
+
+ var parts = normalized switch
+ {
+ '0' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.UpperRight | SegmentParts.LowerLeft |
+ SegmentParts.LowerRight | SegmentParts.Bottom,
+ '1' => SegmentParts.UpperRight | SegmentParts.LowerRight,
+ '2' => SegmentParts.Top | SegmentParts.UpperRight | SegmentParts.MiddleLeft |
+ SegmentParts.MiddleRight | SegmentParts.LowerLeft | SegmentParts.Bottom,
+ '3' => SegmentParts.Top | SegmentParts.UpperRight | SegmentParts.MiddleLeft |
+ SegmentParts.MiddleRight | SegmentParts.LowerRight | SegmentParts.Bottom,
+ '4' => SegmentParts.UpperLeft | SegmentParts.UpperRight | SegmentParts.MiddleLeft |
+ SegmentParts.MiddleRight | SegmentParts.LowerRight,
+ '5' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.MiddleLeft |
+ SegmentParts.MiddleRight | SegmentParts.LowerRight | SegmentParts.Bottom,
+ '6' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.MiddleLeft |
+ SegmentParts.MiddleRight | SegmentParts.LowerLeft | SegmentParts.LowerRight | SegmentParts.Bottom,
+ '7' => SegmentParts.Top | SegmentParts.UpperRight | SegmentParts.LowerRight,
+ '8' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.UpperRight |
+ SegmentParts.MiddleLeft | SegmentParts.MiddleRight | SegmentParts.LowerLeft |
+ SegmentParts.LowerRight | SegmentParts.Bottom,
+ '9' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.UpperRight |
+ SegmentParts.MiddleLeft | SegmentParts.MiddleRight | SegmentParts.LowerRight | SegmentParts.Bottom,
+ 'A' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.UpperRight |
+ SegmentParts.MiddleLeft | SegmentParts.MiddleRight | SegmentParts.LowerLeft | SegmentParts.LowerRight,
+ 'B' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.UpperRight |
+ SegmentParts.MiddleLeft | SegmentParts.MiddleRight | SegmentParts.LowerLeft |
+ SegmentParts.LowerRight | SegmentParts.Bottom | SegmentParts.LowerCenter,
+ 'C' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.LowerLeft | SegmentParts.Bottom,
+ 'D' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.UpperRight |
+ SegmentParts.LowerLeft | SegmentParts.LowerRight | SegmentParts.Bottom | SegmentParts.UpperCenter |
+ SegmentParts.LowerCenter,
+ 'E' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.MiddleLeft |
+ SegmentParts.MiddleRight | SegmentParts.LowerLeft | SegmentParts.Bottom,
+ 'F' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.MiddleLeft |
+ SegmentParts.MiddleRight | SegmentParts.LowerLeft,
+ 'G' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.LowerLeft | SegmentParts.Bottom |
+ SegmentParts.LowerRight | SegmentParts.MiddleRight,
+ 'H' => SegmentParts.UpperLeft | SegmentParts.UpperRight | SegmentParts.MiddleLeft |
+ SegmentParts.MiddleRight | SegmentParts.LowerLeft | SegmentParts.LowerRight,
+ 'I' => SegmentParts.Top | SegmentParts.Bottom | SegmentParts.UpperCenter | SegmentParts.LowerCenter,
+ 'J' => SegmentParts.UpperRight | SegmentParts.LowerRight | SegmentParts.LowerLeft | SegmentParts.Bottom,
+ 'K' => SegmentParts.UpperLeft | SegmentParts.LowerLeft | SegmentParts.UpperRightDiagonal |
+ SegmentParts.LowerRightDiagonal,
+ 'L' => SegmentParts.UpperLeft | SegmentParts.LowerLeft | SegmentParts.Bottom,
+ 'M' => SegmentParts.UpperLeft | SegmentParts.LowerLeft | SegmentParts.UpperRight |
+ SegmentParts.LowerRight | SegmentParts.UpperLeftDiagonal | SegmentParts.UpperRightDiagonal,
+ 'N' => SegmentParts.UpperLeft | SegmentParts.LowerLeft | SegmentParts.UpperRight |
+ SegmentParts.LowerRight | SegmentParts.UpperLeftDiagonal | SegmentParts.LowerRightDiagonal,
+ 'O' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.UpperRight |
+ SegmentParts.LowerLeft | SegmentParts.LowerRight | SegmentParts.Bottom,
+ 'P' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.UpperRight |
+ SegmentParts.MiddleLeft | SegmentParts.MiddleRight | SegmentParts.LowerLeft,
+ 'Q' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.UpperRight |
+ SegmentParts.LowerLeft | SegmentParts.LowerRight | SegmentParts.Bottom | SegmentParts.LowerRightDiagonal,
+ 'R' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.UpperRight |
+ SegmentParts.MiddleLeft | SegmentParts.MiddleRight | SegmentParts.LowerLeft | SegmentParts.LowerRightDiagonal,
+ 'S' => SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.MiddleLeft |
+ SegmentParts.MiddleRight | SegmentParts.LowerRight | SegmentParts.Bottom,
+ 'T' => SegmentParts.Top | SegmentParts.UpperCenter | SegmentParts.LowerCenter,
+ 'U' => SegmentParts.UpperLeft | SegmentParts.UpperRight | SegmentParts.LowerLeft |
+ SegmentParts.LowerRight | SegmentParts.Bottom,
+ 'V' => SegmentParts.UpperLeft | SegmentParts.UpperRight | SegmentParts.LowerLeftDiagonal |
+ SegmentParts.LowerRightDiagonal,
+ 'W' => SegmentParts.UpperLeft | SegmentParts.LowerLeft | SegmentParts.UpperRight |
+ SegmentParts.LowerRight | SegmentParts.LowerLeftDiagonal | SegmentParts.LowerRightDiagonal,
+ 'X' => SegmentParts.UpperLeftDiagonal | SegmentParts.UpperRightDiagonal |
+ SegmentParts.LowerLeftDiagonal | SegmentParts.LowerRightDiagonal,
+ 'Y' => SegmentParts.UpperLeftDiagonal | SegmentParts.UpperRightDiagonal | SegmentParts.LowerCenter,
+ 'Z' => SegmentParts.Top | SegmentParts.UpperRightDiagonal | SegmentParts.LowerLeftDiagonal | SegmentParts.Bottom,
+ '-' => SegmentParts.MiddleLeft | SegmentParts.MiddleRight,
+ '_' => SegmentParts.Bottom,
+ _ => SegmentParts.None
+ };
+
+ return parts == SegmentParts.None
+ ? new SegmentCharacterPattern(' ', SegmentCharacterKind.Empty, SegmentParts.None)
+ : new SegmentCharacterPattern(normalized, SegmentCharacterKind.Segments, parts);
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterPattern.cs b/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterPattern.cs
new file mode 100644
index 0000000..f2ac42e
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterPattern.cs
@@ -0,0 +1,6 @@
+namespace AtomUI.Labs.Controls.Led.Segment.Character;
+
+internal readonly record struct SegmentCharacterPattern(
+ char Character,
+ SegmentCharacterKind Kind,
+ SegmentParts Parts);
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentParts.cs b/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentParts.cs
new file mode 100644
index 0000000..cb46e58
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentParts.cs
@@ -0,0 +1,21 @@
+namespace AtomUI.Labs.Controls.Led.Segment.Character;
+
+[Flags]
+internal enum SegmentParts
+{
+ None = 0,
+ Top = 1 << 0,
+ UpperLeft = 1 << 1,
+ UpperRight = 1 << 2,
+ MiddleLeft = 1 << 3,
+ MiddleRight = 1 << 4,
+ LowerLeft = 1 << 5,
+ LowerRight = 1 << 6,
+ Bottom = 1 << 7,
+ UpperCenter = 1 << 8,
+ LowerCenter = 1 << 9,
+ UpperLeftDiagonal = 1 << 10,
+ UpperRightDiagonal = 1 << 11,
+ LowerLeftDiagonal = 1 << 12,
+ LowerRightDiagonal = 1 << 13
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentCharacterSlot.cs b/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentCharacterSlot.cs
new file mode 100644
index 0000000..8055e98
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentCharacterSlot.cs
@@ -0,0 +1,8 @@
+using Avalonia;
+using AtomUI.Labs.Controls.Led.Segment.Character;
+
+namespace AtomUI.Labs.Controls.Led.Segment.Layout;
+
+internal readonly record struct SegmentCharacterSlot(
+ SegmentCharacterPattern Pattern,
+ Rect Bounds);
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentDisplayLayout.cs b/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentDisplayLayout.cs
new file mode 100644
index 0000000..9445821
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentDisplayLayout.cs
@@ -0,0 +1,16 @@
+using Avalonia;
+
+namespace AtomUI.Labs.Controls.Led.Segment.Layout;
+
+internal sealed class SegmentDisplayLayout
+{
+ public SegmentDisplayLayout(Size desiredSize, IReadOnlyList slots)
+ {
+ DesiredSize = desiredSize;
+ Slots = slots;
+ }
+
+ public Size DesiredSize { get; }
+
+ public IReadOnlyList Slots { get; }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentLayoutEngine.cs b/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentLayoutEngine.cs
new file mode 100644
index 0000000..a278fd5
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentLayoutEngine.cs
@@ -0,0 +1,75 @@
+using Avalonia;
+using AtomUI.Labs.Controls.Led.Segment;
+using AtomUI.Labs.Controls.Led.Segment.Character;
+
+namespace AtomUI.Labs.Controls.Led.Segment.Layout;
+
+internal static class SegmentLayoutEngine
+{
+ private const double NarrowSymbolWidthRatio = 0.32;
+
+ public static SegmentDisplayLayout Calculate(
+ string? text,
+ SegmentLayoutOptions options)
+ {
+ var patterns = BuildPatterns(text);
+ if (patterns.Count == 0)
+ {
+ var emptyPadding = SegmentValueSanitizer.CoerceThickness(options.Padding);
+ return new SegmentDisplayLayout(
+ new Size(emptyPadding.Left + emptyPadding.Right, emptyPadding.Top + emptyPadding.Bottom),
+ Array.Empty());
+ }
+
+ var padding = SegmentValueSanitizer.CoerceThickness(options.Padding);
+ var characterHeight = SegmentValueSanitizer.CoerceNonNegative(options.CharacterHeight);
+ var characterWidth = SegmentValueSanitizer.CoerceNonNegative(
+ characterHeight * SegmentValueSanitizer.CoerceAtLeast(options.CharacterAspectRatio, 0.1));
+ var spacing = SegmentValueSanitizer.CoerceNonNegative(options.CharacterSpacing);
+ var x = padding.Left;
+ var slots = new List(patterns.Count);
+
+ for (var i = 0; i < patterns.Count; i++)
+ {
+ var pattern = patterns[i];
+ var width = GetCharacterWidth(pattern, characterWidth);
+ var bounds = new Rect(x, padding.Top, width, characterHeight);
+ slots.Add(new SegmentCharacterSlot(pattern, bounds));
+
+ x += width;
+ if (i < patterns.Count - 1)
+ {
+ x += spacing;
+ }
+ }
+
+ var desiredSize = new Size(
+ x + padding.Right,
+ characterHeight + padding.Top + padding.Bottom);
+
+ return new SegmentDisplayLayout(desiredSize, slots);
+ }
+
+ private static List BuildPatterns(string? text)
+ {
+ if (string.IsNullOrEmpty(text))
+ {
+ return new List();
+ }
+
+ var patterns = new List(text.Length);
+ foreach (var character in text)
+ {
+ patterns.Add(SegmentCharacterMap.GetPattern(character));
+ }
+
+ return patterns;
+ }
+
+ private static double GetCharacterWidth(SegmentCharacterPattern pattern, double defaultWidth)
+ {
+ return pattern.Kind is SegmentCharacterKind.Colon or SegmentCharacterKind.Dot
+ ? defaultWidth * NarrowSymbolWidthRatio
+ : defaultWidth;
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentLayoutOptions.cs b/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentLayoutOptions.cs
new file mode 100644
index 0000000..b976a6d
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentLayoutOptions.cs
@@ -0,0 +1,9 @@
+using Avalonia;
+
+namespace AtomUI.Labs.Controls.Led.Segment.Layout;
+
+internal readonly record struct SegmentLayoutOptions(
+ double CharacterHeight,
+ double CharacterAspectRatio,
+ double CharacterSpacing,
+ Thickness Padding);
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryFactory.cs b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryFactory.cs
new file mode 100644
index 0000000..4790a09
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryFactory.cs
@@ -0,0 +1,225 @@
+using Avalonia;
+using AtomUI.Labs.Controls.Led.Segment;
+using AtomUI.Labs.Controls.Led.Segment.Character;
+using Avalonia.Media;
+
+namespace AtomUI.Labs.Controls.Led.Segment.Rendering;
+
+internal static class SegmentGeometryFactory
+{
+ public static SegmentGeometrySet Create(Rect bounds, SegmentGeometryOptions options)
+ {
+ if (!IsUsableBounds(bounds))
+ {
+ return CreateEmptySet();
+ }
+
+ var maxThickness = Math.Min(bounds.Width, bounds.Height) / 4;
+ var thickness = SegmentValueSanitizer.CoerceRange(options.Thickness, 0, maxThickness);
+ if (thickness <= 0)
+ {
+ return CreateEmptySet();
+ }
+
+ var maxGap = Math.Max(0, Math.Min(bounds.Width - thickness, bounds.Height - thickness) / 4);
+ var gap = SegmentValueSanitizer.CoerceRange(options.Gap, 0, maxGap);
+ var bevel = thickness * SegmentValueSanitizer.CoerceRange(options.BevelRatio, 0, 1);
+ var centerX = bounds.X + bounds.Width / 2;
+ var centerY = bounds.Y + bounds.Height / 2;
+ var left = bounds.X;
+ var right = bounds.Right;
+ var top = bounds.Y;
+ var bottom = bounds.Bottom;
+
+ var items = new[]
+ {
+ new SegmentGeometryItem(
+ SegmentParts.Top,
+ CreateHorizontalSegment(left + bevel + gap, right - bevel - gap, top, thickness, bevel)),
+ new SegmentGeometryItem(
+ SegmentParts.MiddleLeft,
+ CreateHorizontalSegment(left + bevel + gap, centerX - gap, centerY - thickness / 2, thickness, bevel)),
+ new SegmentGeometryItem(
+ SegmentParts.MiddleRight,
+ CreateHorizontalSegment(centerX + gap, right - bevel - gap, centerY - thickness / 2, thickness, bevel)),
+ new SegmentGeometryItem(
+ SegmentParts.Bottom,
+ CreateHorizontalSegment(left + bevel + gap, right - bevel - gap, bottom - thickness, thickness, bevel)),
+ new SegmentGeometryItem(
+ SegmentParts.UpperLeft,
+ CreateVerticalSegment(left, top + bevel + gap, centerY - gap, thickness, bevel)),
+ new SegmentGeometryItem(
+ SegmentParts.LowerLeft,
+ CreateVerticalSegment(left, centerY + gap, bottom - bevel - gap, thickness, bevel)),
+ new SegmentGeometryItem(
+ SegmentParts.UpperRight,
+ CreateVerticalSegment(right - thickness, top + bevel + gap, centerY - gap, thickness, bevel)),
+ new SegmentGeometryItem(
+ SegmentParts.LowerRight,
+ CreateVerticalSegment(right - thickness, centerY + gap, bottom - bevel - gap, thickness, bevel)),
+ new SegmentGeometryItem(
+ SegmentParts.UpperCenter,
+ CreateVerticalSegment(centerX - thickness / 2, top + bevel + gap, centerY - gap, thickness, bevel)),
+ new SegmentGeometryItem(
+ SegmentParts.LowerCenter,
+ CreateVerticalSegment(centerX - thickness / 2, centerY + gap, bottom - bevel - gap, thickness, bevel)),
+ new SegmentGeometryItem(
+ SegmentParts.UpperLeftDiagonal,
+ CreateDiagonalSegment(new Point(left + thickness + gap, top + thickness + gap),
+ new Point(centerX - gap, centerY - gap),
+ thickness)),
+ new SegmentGeometryItem(
+ SegmentParts.UpperRightDiagonal,
+ CreateDiagonalSegment(new Point(right - thickness - gap, top + thickness + gap),
+ new Point(centerX + gap, centerY - gap),
+ thickness)),
+ new SegmentGeometryItem(
+ SegmentParts.LowerLeftDiagonal,
+ CreateDiagonalSegment(new Point(left + thickness + gap, bottom - thickness - gap),
+ new Point(centerX - gap, centerY + gap),
+ thickness)),
+ new SegmentGeometryItem(
+ SegmentParts.LowerRightDiagonal,
+ CreateDiagonalSegment(new Point(right - thickness - gap, bottom - thickness - gap),
+ new Point(centerX + gap, centerY + gap),
+ thickness))
+ };
+
+ return new SegmentGeometrySet(items);
+ }
+
+ public static IReadOnlyList CreateColon(Rect bounds, SegmentGeometryOptions options)
+ {
+ return new[]
+ {
+ CreateDot(
+ new Rect(bounds.X, bounds.Y + bounds.Height * 0.25, bounds.Width, bounds.Height * 0.2),
+ options,
+ true),
+ CreateDot(
+ new Rect(bounds.X, bounds.Y + bounds.Height * 0.58, bounds.Width, bounds.Height * 0.2),
+ options,
+ true)
+ };
+ }
+
+ public static Geometry CreateDot(Rect bounds, SegmentGeometryOptions options, bool centerVertically)
+ {
+ if (!IsUsableBounds(bounds))
+ {
+ return new StreamGeometry();
+ }
+
+ var size = Math.Min(bounds.Width, bounds.Height) * SegmentValueSanitizer.CoerceRange(options.DotScale, 0, 1);
+ if (size <= 0)
+ {
+ return new StreamGeometry();
+ }
+
+ var x = bounds.X + (bounds.Width - size) / 2;
+ var y = centerVertically
+ ? bounds.Y + (bounds.Height - size) / 2
+ : bounds.Bottom - size;
+ return new EllipseGeometry(new Rect(x, y, size, size));
+ }
+
+ private static bool IsUsableBounds(Rect bounds)
+ {
+ return SegmentValueSanitizer.IsFinite(bounds.X)
+ && SegmentValueSanitizer.IsFinite(bounds.Y)
+ && SegmentValueSanitizer.IsFinite(bounds.Width)
+ && SegmentValueSanitizer.IsFinite(bounds.Height)
+ && bounds.Width > 0
+ && bounds.Height > 0;
+ }
+
+ private static SegmentGeometrySet CreateEmptySet()
+ {
+ var items = new[]
+ {
+ new SegmentGeometryItem(SegmentParts.Top, new StreamGeometry()),
+ new SegmentGeometryItem(SegmentParts.MiddleLeft, new StreamGeometry()),
+ new SegmentGeometryItem(SegmentParts.MiddleRight, new StreamGeometry()),
+ new SegmentGeometryItem(SegmentParts.Bottom, new StreamGeometry()),
+ new SegmentGeometryItem(SegmentParts.UpperLeft, new StreamGeometry()),
+ new SegmentGeometryItem(SegmentParts.LowerLeft, new StreamGeometry()),
+ new SegmentGeometryItem(SegmentParts.UpperRight, new StreamGeometry()),
+ new SegmentGeometryItem(SegmentParts.LowerRight, new StreamGeometry()),
+ new SegmentGeometryItem(SegmentParts.UpperCenter, new StreamGeometry()),
+ new SegmentGeometryItem(SegmentParts.LowerCenter, new StreamGeometry()),
+ new SegmentGeometryItem(SegmentParts.UpperLeftDiagonal, new StreamGeometry()),
+ new SegmentGeometryItem(SegmentParts.UpperRightDiagonal, new StreamGeometry()),
+ new SegmentGeometryItem(SegmentParts.LowerLeftDiagonal, new StreamGeometry()),
+ new SegmentGeometryItem(SegmentParts.LowerRightDiagonal, new StreamGeometry())
+ };
+
+ return new SegmentGeometrySet(items);
+ }
+
+ private static Geometry CreateHorizontalSegment(double x1, double x2, double y, double thickness, double bevel)
+ {
+ var midY = y + thickness / 2;
+ return CreatePolygon(
+ new Point(x1 + bevel, y),
+ new Point(x2 - bevel, y),
+ new Point(x2, midY),
+ new Point(x2 - bevel, y + thickness),
+ new Point(x1 + bevel, y + thickness),
+ new Point(x1, midY));
+ }
+
+ private static Geometry CreateVerticalSegment(double x, double y1, double y2, double thickness, double bevel)
+ {
+ var midX = x + thickness / 2;
+ return CreatePolygon(
+ new Point(midX, y1),
+ new Point(x + thickness, y1 + bevel),
+ new Point(x + thickness, y2 - bevel),
+ new Point(midX, y2),
+ new Point(x, y2 - bevel),
+ new Point(x, y1 + bevel));
+ }
+
+ private static Geometry CreateDiagonalSegment(Point start, Point end, double thickness)
+ {
+ var dx = end.X - start.X;
+ var dy = end.Y - start.Y;
+ var length = Math.Sqrt(dx * dx + dy * dy);
+ if (length <= 0)
+ {
+ return new StreamGeometry();
+ }
+
+ var nx = -dy / length;
+ var ny = dx / length;
+ var half = thickness / 2;
+ var offset = new Point(nx * half, ny * half);
+
+ return CreatePolygon(
+ new Point(start.X + offset.X, start.Y + offset.Y),
+ new Point(end.X + offset.X, end.Y + offset.Y),
+ new Point(end.X - offset.X, end.Y - offset.Y),
+ new Point(start.X - offset.X, start.Y - offset.Y));
+ }
+
+ private static Geometry CreatePolygon(params Point[] points)
+ {
+ if (points.Length == 0)
+ {
+ return new StreamGeometry();
+ }
+
+ var geometry = new StreamGeometry();
+ using (var context = geometry.Open())
+ {
+ context.BeginFigure(points[0], true);
+ for (var i = 1; i < points.Length; i++)
+ {
+ context.LineTo(points[i]);
+ }
+ context.EndFigure(true);
+ }
+
+ return geometry;
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryItem.cs b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryItem.cs
new file mode 100644
index 0000000..9faaf70
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryItem.cs
@@ -0,0 +1,8 @@
+using AtomUI.Labs.Controls.Led.Segment.Character;
+using Avalonia.Media;
+
+namespace AtomUI.Labs.Controls.Led.Segment.Rendering;
+
+internal readonly record struct SegmentGeometryItem(
+ SegmentParts Part,
+ Geometry Geometry);
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryOptions.cs b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryOptions.cs
new file mode 100644
index 0000000..3f63900
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryOptions.cs
@@ -0,0 +1,7 @@
+namespace AtomUI.Labs.Controls.Led.Segment.Rendering;
+
+internal readonly record struct SegmentGeometryOptions(
+ double Thickness,
+ double Gap,
+ double BevelRatio = 0.5,
+ double DotScale = 0.72);
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometrySet.cs b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometrySet.cs
new file mode 100644
index 0000000..f36c759
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometrySet.cs
@@ -0,0 +1,11 @@
+namespace AtomUI.Labs.Controls.Led.Segment.Rendering;
+
+internal sealed class SegmentGeometrySet
+{
+ public SegmentGeometrySet(IReadOnlyList items)
+ {
+ Items = items;
+ }
+
+ public IReadOnlyList Items { get; }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentVisibleGeometry.cs b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentVisibleGeometry.cs
new file mode 100644
index 0000000..58a97d1
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentVisibleGeometry.cs
@@ -0,0 +1,7 @@
+using Avalonia.Media;
+
+namespace AtomUI.Labs.Controls.Led.Segment.Rendering;
+
+internal sealed record SegmentVisibleGeometry(
+ Geometry? ActiveGeometry,
+ Geometry? InactiveGeometry);
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/SegmentDisplay.cs b/src/AtomUI.Labs.Controls.Led/Segment/SegmentDisplay.cs
new file mode 100644
index 0000000..2acb8b9
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/SegmentDisplay.cs
@@ -0,0 +1,676 @@
+using Avalonia;
+using Avalonia.Automation.Peers;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using AtomUI.Labs.Controls.Led;
+using AtomUI.Labs.Controls.Led.Glow;
+using AtomUI.Labs.Controls.Led.Segment.Character;
+using AtomUI.Labs.Controls.Led.Segment.Layout;
+using AtomUI.Labs.Controls.Led.Segment.Rendering;
+using Avalonia.Media;
+using AvaloniaMatrix = Avalonia.Matrix;
+
+namespace AtomUI.Labs.Controls.Led.Segment;
+
+public class SegmentDisplay : Control
+{
+ #region 公共属性定义
+
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ public static readonly StyledProperty CharacterHeightProperty =
+ AvaloniaProperty.Register(nameof(CharacterHeight), 72);
+
+ public static readonly StyledProperty CharacterAspectRatioProperty =
+ AvaloniaProperty.Register(nameof(CharacterAspectRatio), 0.58);
+
+ public static readonly StyledProperty CharacterSpacingProperty =
+ AvaloniaProperty.Register(nameof(CharacterSpacing), 8);
+
+ public static readonly StyledProperty SegmentThicknessProperty =
+ AvaloniaProperty.Register(nameof(SegmentThickness), 8);
+
+ public static readonly StyledProperty SegmentGapProperty =
+ AvaloniaProperty.Register(nameof(SegmentGap), 2);
+
+ public static readonly StyledProperty SegmentBevelRatioProperty =
+ AvaloniaProperty.Register(nameof(SegmentBevelRatio), 0.5);
+
+ public static readonly StyledProperty DotScaleProperty =
+ AvaloniaProperty.Register(nameof(DotScale), 0.72);
+
+ public static readonly StyledProperty PaddingProperty =
+ AvaloniaProperty.Register(nameof(Padding));
+
+ public static readonly StyledProperty HorizontalContentAlignmentProperty =
+ AvaloniaProperty.Register(nameof(HorizontalContentAlignment), HorizontalAlignment.Left);
+
+ public static readonly StyledProperty VerticalContentAlignmentProperty =
+ AvaloniaProperty.Register(nameof(VerticalContentAlignment), VerticalAlignment.Top);
+
+ public static readonly StyledProperty OverflowModeProperty =
+ AvaloniaProperty.Register(nameof(OverflowMode));
+
+ public static readonly StyledProperty BackgroundProperty =
+ AvaloniaProperty.Register(nameof(Background));
+
+ public static readonly StyledProperty CornerRadiusProperty =
+ AvaloniaProperty.Register(nameof(CornerRadius));
+
+ public static readonly StyledProperty ActiveBrushProperty =
+ AvaloniaProperty.Register(nameof(ActiveBrush));
+
+ public static readonly StyledProperty InactiveBrushProperty =
+ AvaloniaProperty.Register(nameof(InactiveBrush));
+
+ public static readonly StyledProperty GlowBrushProperty =
+ AvaloniaProperty.Register(nameof(GlowBrush));
+
+ public static readonly StyledProperty GlowOpacityProperty =
+ AvaloniaProperty.Register(nameof(GlowOpacity), LedGlowValueSanitizer.DefaultOpacity);
+
+ public static readonly StyledProperty GlowRadiusProperty =
+ AvaloniaProperty.Register(nameof(GlowRadius), LedGlowValueSanitizer.DefaultRadius);
+
+ public static readonly StyledProperty ShowInactiveSegmentsProperty =
+ AvaloniaProperty.Register(nameof(ShowInactiveSegments), true);
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public double CharacterHeight
+ {
+ get => GetValue(CharacterHeightProperty);
+ set => SetValue(CharacterHeightProperty, value);
+ }
+
+ public double CharacterAspectRatio
+ {
+ get => GetValue(CharacterAspectRatioProperty);
+ set => SetValue(CharacterAspectRatioProperty, value);
+ }
+
+ public double CharacterSpacing
+ {
+ get => GetValue(CharacterSpacingProperty);
+ set => SetValue(CharacterSpacingProperty, value);
+ }
+
+ public double SegmentThickness
+ {
+ get => GetValue(SegmentThicknessProperty);
+ set => SetValue(SegmentThicknessProperty, value);
+ }
+
+ public double SegmentGap
+ {
+ get => GetValue(SegmentGapProperty);
+ set => SetValue(SegmentGapProperty, value);
+ }
+
+ public double SegmentBevelRatio
+ {
+ get => GetValue(SegmentBevelRatioProperty);
+ set => SetValue(SegmentBevelRatioProperty, value);
+ }
+
+ public double DotScale
+ {
+ get => GetValue(DotScaleProperty);
+ set => SetValue(DotScaleProperty, value);
+ }
+
+ public Thickness Padding
+ {
+ get => GetValue(PaddingProperty);
+ set => SetValue(PaddingProperty, value);
+ }
+
+ public HorizontalAlignment HorizontalContentAlignment
+ {
+ get => GetValue(HorizontalContentAlignmentProperty);
+ set => SetValue(HorizontalContentAlignmentProperty, value);
+ }
+
+ public VerticalAlignment VerticalContentAlignment
+ {
+ get => GetValue(VerticalContentAlignmentProperty);
+ set => SetValue(VerticalContentAlignmentProperty, value);
+ }
+
+ public SegmentOverflowMode OverflowMode
+ {
+ get => GetValue(OverflowModeProperty);
+ set => SetValue(OverflowModeProperty, value);
+ }
+
+ public IBrush? Background
+ {
+ get => GetValue(BackgroundProperty);
+ set => SetValue(BackgroundProperty, value);
+ }
+
+ public CornerRadius CornerRadius
+ {
+ get => GetValue(CornerRadiusProperty);
+ set => SetValue(CornerRadiusProperty, value);
+ }
+
+ public IBrush? ActiveBrush
+ {
+ get => GetValue(ActiveBrushProperty);
+ set => SetValue(ActiveBrushProperty, value);
+ }
+
+ public IBrush? InactiveBrush
+ {
+ get => GetValue(InactiveBrushProperty);
+ set => SetValue(InactiveBrushProperty, value);
+ }
+
+ public IBrush? GlowBrush
+ {
+ get => GetValue(GlowBrushProperty);
+ set => SetValue(GlowBrushProperty, value);
+ }
+
+ public double GlowOpacity
+ {
+ get => GetValue(GlowOpacityProperty);
+ set => SetValue(GlowOpacityProperty, value);
+ }
+
+ public double GlowRadius
+ {
+ get => GetValue(GlowRadiusProperty);
+ set => SetValue(GlowRadiusProperty, value);
+ }
+
+ public bool ShowInactiveSegments
+ {
+ get => GetValue(ShowInactiveSegmentsProperty);
+ set => SetValue(ShowInactiveSegmentsProperty, value);
+ }
+
+ #endregion
+
+ #region 内部属性定义
+
+ internal int LayoutCacheVersion { get; private set; }
+
+ internal int GeometryCacheVersion { get; private set; }
+
+ internal int VisibleGeometryBuildCount { get; private set; }
+
+ internal Geometry? VisibleActiveGeometry => _visibleGeometryCache?.ActiveGeometry;
+
+ internal Geometry? VisibleInactiveGeometry => _visibleInactiveGeometryCache;
+
+ internal int GlowEffectBuildCount => _glowRenderer?.EffectBuildCount ?? 0;
+
+ internal int GlowEffectScopeCount => _glowRenderer?.EffectScopeCount ?? 0;
+
+ #endregion
+
+ private bool _hasLayoutCache;
+ private LedGlowRenderer? _glowRenderer;
+ private SegmentLayoutCacheKey _layoutCacheKey;
+ private SegmentDisplayLayout? _layoutCache;
+
+ private bool _hasGeometryCache;
+ private SegmentGeometryOptions _geometryCacheOptions;
+ private IReadOnlyList? _geometryCache;
+
+ private bool _hasVisibleGeometryCache;
+ private SegmentVisibleGeometryCacheKey _visibleGeometryCacheKey;
+ private SegmentVisibleGeometry? _visibleGeometryCache;
+ private bool _hasVisibleInactiveGeometryCache;
+ private SegmentVisibleInactiveGeometryCacheKey _visibleInactiveGeometryCacheKey;
+ private Geometry? _visibleInactiveGeometryCache;
+
+ static SegmentDisplay()
+ {
+ AffectsMeasure(
+ TextProperty,
+ CharacterHeightProperty,
+ CharacterAspectRatioProperty,
+ CharacterSpacingProperty,
+ PaddingProperty);
+ AffectsRender(
+ SegmentThicknessProperty,
+ SegmentGapProperty,
+ SegmentBevelRatioProperty,
+ DotScaleProperty,
+ HorizontalContentAlignmentProperty,
+ VerticalContentAlignmentProperty,
+ OverflowModeProperty,
+ BackgroundProperty,
+ CornerRadiusProperty,
+ ActiveBrushProperty,
+ InactiveBrushProperty,
+ GlowBrushProperty,
+ GlowOpacityProperty,
+ GlowRadiusProperty,
+ ShowInactiveSegmentsProperty);
+ }
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ var layout = GetLayout();
+ return layout.DesiredSize;
+ }
+
+ protected override AutomationPeer OnCreateAutomationPeer()
+ {
+ return new SegmentDisplayAutomationPeer(this);
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ base.Render(context);
+ RenderBackground(context);
+
+ var activeBrush = ActiveBrush;
+ if (activeBrush is null)
+ {
+ return;
+ }
+
+ var layout = GetPreparedLayout();
+ var preparedSlots = GetPreparedSlots(layout);
+ var scale = OverflowMode == SegmentOverflowMode.ScaleDown
+ ? LedDisplayLayoutMath.CalculateScaleDown(layout.DesiredSize, Bounds.Size)
+ : 1;
+ if (scale <= 0)
+ {
+ return;
+ }
+
+ var offset = LedDisplayLayoutMath.CalculateAlignmentOffset(
+ layout.DesiredSize,
+ Bounds.Size,
+ scale,
+ HorizontalContentAlignment,
+ VerticalContentAlignment);
+ var visibleBounds = new Rect(
+ -offset.X / scale,
+ -offset.Y / scale,
+ Bounds.Width / scale,
+ Bounds.Height / scale);
+ var glowBrush = GlowBrush;
+ var glowOpacity = LedGlowValueSanitizer.CoerceOpacity(GlowOpacity);
+ var glowRadius = LedGlowValueSanitizer.CoerceRadius(GlowRadius);
+ var hasGlow = glowBrush is not null && glowOpacity > 0 && glowRadius > 0;
+ if (hasGlow)
+ {
+ visibleBounds = visibleBounds.Inflate(glowRadius);
+ }
+
+ var firstVisibleIndex = FindFirstVisibleSlot(layout, visibleBounds.Left);
+ var lastVisibleIndex = FindLastVisibleSlot(layout, visibleBounds.Right, firstVisibleIndex);
+ using (context.PushClip(new Rect(Bounds.Size)))
+ using (context.PushTransform(
+ AvaloniaMatrix.CreateScale(scale, scale)
+ * AvaloniaMatrix.CreateTranslation(offset.X, offset.Y)))
+ {
+ var visibleGeometry = GetVisibleGeometry(
+ layout,
+ preparedSlots,
+ firstVisibleIndex,
+ lastVisibleIndex);
+ if (ShowInactiveSegments
+ && InactiveBrush is { } inactiveBrush
+ && visibleGeometry.InactiveGeometry is { } inactiveGeometry)
+ {
+ context.DrawGeometry(inactiveBrush, null, inactiveGeometry);
+ }
+
+ if (visibleGeometry.ActiveGeometry is { } activeGeometry)
+ {
+ using (var glowScope = PushGlow(
+ context,
+ activeGeometry.Bounds,
+ glowBrush,
+ glowOpacity,
+ glowRadius))
+ {
+ if (glowScope.IsActive)
+ {
+ context.DrawGeometry(glowBrush, null, activeGeometry);
+ }
+ }
+
+ context.DrawGeometry(activeBrush, null, activeGeometry);
+ }
+ }
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == TextProperty)
+ {
+ ClearLayoutCache();
+ if (ControlAutomationPeer.FromElement(this) is SegmentDisplayAutomationPeer automationPeer)
+ {
+ automationPeer.NotifyTextChanged(change.GetOldValue(), change.GetNewValue());
+ }
+ }
+ else if (change.Property == CharacterHeightProperty
+ || change.Property == CharacterAspectRatioProperty
+ || change.Property == CharacterSpacingProperty
+ || change.Property == PaddingProperty)
+ {
+ ClearLayoutCache();
+ ClearGeometryCache();
+ }
+ else if (change.Property == SegmentThicknessProperty
+ || change.Property == SegmentGapProperty
+ || change.Property == SegmentBevelRatioProperty
+ || change.Property == DotScaleProperty)
+ {
+ ClearGeometryCache();
+ }
+
+ if (change.Property == GlowBrushProperty && GlowBrush is null)
+ {
+ _glowRenderer = null;
+ }
+ }
+
+ private SegmentDisplayLayout GetLayout()
+ {
+ var key = new SegmentLayoutCacheKey(Text, GetLayoutOptions());
+ if (_hasLayoutCache && _layoutCacheKey == key && _layoutCache is not null)
+ {
+ return _layoutCache;
+ }
+
+ var layout = SegmentLayoutEngine.Calculate(key.Text, key.Options);
+ _layoutCacheKey = key;
+ _layoutCache = layout;
+ _hasLayoutCache = true;
+ LayoutCacheVersion++;
+ return layout;
+ }
+
+ private SegmentDisplayLayout GetPreparedLayout()
+ {
+ return GetLayout();
+ }
+
+ private IReadOnlyList GetPreparedSlots(SegmentDisplayLayout layout)
+ {
+ var geometryOptions = GetGeometryOptions();
+ if (_hasGeometryCache
+ && _geometryCacheOptions == geometryOptions
+ && _geometryCache is not null
+ && GeometryCacheMatchesLayout(_geometryCache, layout))
+ {
+ return _geometryCache;
+ }
+
+ var preparedSlots = new SegmentPreparedSlot[layout.Slots.Count];
+ for (var i = 0; i < layout.Slots.Count; i++)
+ {
+ var slot = layout.Slots[i];
+ var geometrySet = slot.Pattern.Kind == SegmentCharacterKind.Segments
+ ? SegmentGeometryFactory.Create(slot.Bounds, geometryOptions)
+ : null;
+ var symbolGeometries = slot.Pattern.Kind switch
+ {
+ SegmentCharacterKind.Colon => SegmentGeometryFactory.CreateColon(slot.Bounds, geometryOptions),
+ SegmentCharacterKind.Dot => new[] { SegmentGeometryFactory.CreateDot(slot.Bounds, geometryOptions, false) },
+ _ => null
+ };
+ preparedSlots[i] = new SegmentPreparedSlot(slot.Pattern.Kind, slot.Bounds, geometrySet, symbolGeometries);
+ }
+
+ _geometryCacheOptions = geometryOptions;
+ _geometryCache = preparedSlots;
+ _hasGeometryCache = true;
+ GeometryCacheVersion++;
+ return preparedSlots;
+ }
+
+ private void RenderBackground(DrawingContext context)
+ {
+ var background = Background;
+ if (background is null)
+ {
+ return;
+ }
+
+ context.DrawRectangle(
+ background,
+ null,
+ new RoundedRect(
+ new Rect(0, 0, Bounds.Width, Bounds.Height),
+ SegmentValueSanitizer.CoerceCornerRadius(CornerRadius)));
+ }
+
+ private SegmentLayoutOptions GetLayoutOptions()
+ {
+ return new SegmentLayoutOptions(
+ SegmentValueSanitizer.CoerceNonNegative(CharacterHeight),
+ SegmentValueSanitizer.CoerceAtLeast(CharacterAspectRatio, 0.1),
+ SegmentValueSanitizer.CoerceNonNegative(CharacterSpacing),
+ SegmentValueSanitizer.CoerceThickness(Padding));
+ }
+
+ private SegmentGeometryOptions GetGeometryOptions()
+ {
+ return new SegmentGeometryOptions(
+ SegmentValueSanitizer.CoerceAtLeast(SegmentThickness, 1),
+ SegmentValueSanitizer.CoerceNonNegative(SegmentGap),
+ SegmentValueSanitizer.CoerceRange(SegmentBevelRatio, 0, 1),
+ SegmentValueSanitizer.CoerceRange(DotScale, 0, 1));
+ }
+
+ private void ClearLayoutCache()
+ {
+ _hasLayoutCache = false;
+ _layoutCache = null;
+ }
+
+ private void ClearGeometryCache()
+ {
+ _hasGeometryCache = false;
+ _geometryCache = null;
+ ClearVisibleGeometryCache();
+ }
+
+ private static bool GeometryCacheMatchesLayout(
+ IReadOnlyList preparedSlots,
+ SegmentDisplayLayout layout)
+ {
+ if (preparedSlots.Count != layout.Slots.Count)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < preparedSlots.Count; i++)
+ {
+ var preparedSlot = preparedSlots[i];
+ var layoutSlot = layout.Slots[i];
+ if (preparedSlot.Kind != layoutSlot.Pattern.Kind
+ || preparedSlot.Bounds != layoutSlot.Bounds)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private SegmentVisibleGeometry GetVisibleGeometry(
+ SegmentDisplayLayout layout,
+ IReadOnlyList preparedSlots,
+ int start,
+ int end)
+ {
+ var key = new SegmentVisibleGeometryCacheKey(
+ LayoutCacheVersion,
+ GeometryCacheVersion,
+ start,
+ end);
+ if (_hasVisibleGeometryCache
+ && _visibleGeometryCacheKey == key
+ && _visibleGeometryCache is not null)
+ {
+ return _visibleGeometryCache;
+ }
+
+ var active = new GeometryGroup { FillRule = FillRule.NonZero };
+ var inactive = GetVisibleInactiveGeometry(preparedSlots, start, end);
+ for (var i = start; i < end; i++)
+ {
+ var pattern = layout.Slots[i].Pattern;
+ var slot = preparedSlots[i];
+ if (pattern.Kind == SegmentCharacterKind.Segments && slot.GeometrySet is not null)
+ {
+ foreach (var item in slot.GeometrySet.Items)
+ {
+ if ((pattern.Parts & item.Part) != 0)
+ {
+ active.Children.Add(item.Geometry);
+ }
+ }
+ }
+ else if (slot.SymbolGeometries is not null)
+ {
+ foreach (var geometry in slot.SymbolGeometries)
+ {
+ active.Children.Add(geometry);
+ }
+ }
+ }
+
+ _visibleGeometryCache = new SegmentVisibleGeometry(
+ active.Children.Count > 0 ? active : null,
+ inactive);
+ _visibleGeometryCacheKey = key;
+ _hasVisibleGeometryCache = true;
+ VisibleGeometryBuildCount++;
+ return _visibleGeometryCache;
+ }
+
+ private Geometry? GetVisibleInactiveGeometry(
+ IReadOnlyList preparedSlots,
+ int start,
+ int end)
+ {
+ var key = new SegmentVisibleInactiveGeometryCacheKey(
+ GeometryCacheVersion,
+ start,
+ end);
+ if (_hasVisibleInactiveGeometryCache && _visibleInactiveGeometryCacheKey == key)
+ {
+ return _visibleInactiveGeometryCache;
+ }
+
+ var inactive = new GeometryGroup { FillRule = FillRule.NonZero };
+ for (var i = start; i < end; i++)
+ {
+ var geometrySet = preparedSlots[i].GeometrySet;
+ if (geometrySet is null)
+ {
+ continue;
+ }
+
+ foreach (var item in geometrySet.Items)
+ {
+ inactive.Children.Add(item.Geometry);
+ }
+ }
+
+ _visibleInactiveGeometryCache = inactive.Children.Count > 0 ? inactive : null;
+ _visibleInactiveGeometryCacheKey = key;
+ _hasVisibleInactiveGeometryCache = true;
+ return _visibleInactiveGeometryCache;
+ }
+
+ private void ClearVisibleGeometryCache()
+ {
+ _hasVisibleGeometryCache = false;
+ _visibleGeometryCache = null;
+ _hasVisibleInactiveGeometryCache = false;
+ _visibleInactiveGeometryCache = null;
+ }
+
+ private LedGlowRenderScope PushGlow(
+ DrawingContext context,
+ Rect activeBounds,
+ IBrush? glowBrush,
+ double opacity,
+ double radius)
+ {
+ if (glowBrush is null || opacity <= 0 || radius <= 0)
+ {
+ return default;
+ }
+
+ _glowRenderer ??= new LedGlowRenderer();
+ return _glowRenderer.Push(context, glowBrush, opacity, radius, activeBounds);
+ }
+
+ private static int FindFirstVisibleSlot(SegmentDisplayLayout layout, double visibleLeft)
+ {
+ var low = 0;
+ var high = layout.Slots.Count;
+ while (low < high)
+ {
+ var middle = low + (high - low) / 2;
+ if (layout.Slots[middle].Bounds.Right <= visibleLeft)
+ {
+ low = middle + 1;
+ }
+ else
+ {
+ high = middle;
+ }
+ }
+
+ return low;
+ }
+
+ private static int FindLastVisibleSlot(
+ SegmentDisplayLayout layout,
+ double visibleRight,
+ int firstVisibleIndex)
+ {
+ var index = firstVisibleIndex;
+ while (index < layout.Slots.Count && layout.Slots[index].Bounds.Left < visibleRight)
+ {
+ index++;
+ }
+
+ return index;
+ }
+
+ private readonly record struct SegmentLayoutCacheKey(
+ string? Text,
+ SegmentLayoutOptions Options);
+
+ private readonly record struct SegmentPreparedSlot(
+ SegmentCharacterKind Kind,
+ Rect Bounds,
+ SegmentGeometrySet? GeometrySet,
+ IReadOnlyList? SymbolGeometries);
+
+ private readonly record struct SegmentVisibleGeometryCacheKey(
+ int LayoutVersion,
+ int GeometryVersion,
+ int FirstVisibleIndex,
+ int LastVisibleIndex);
+
+ private readonly record struct SegmentVisibleInactiveGeometryCacheKey(
+ int GeometryVersion,
+ int FirstVisibleIndex,
+ int LastVisibleIndex);
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/SegmentDisplayAutomationPeer.cs b/src/AtomUI.Labs.Controls.Led/Segment/SegmentDisplayAutomationPeer.cs
new file mode 100644
index 0000000..08ddb82
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/SegmentDisplayAutomationPeer.cs
@@ -0,0 +1,55 @@
+using Avalonia.Automation;
+using Avalonia.Automation.Peers;
+using AtomUI.Labs.Controls.Led.Segment.Character;
+
+namespace AtomUI.Labs.Controls.Led.Segment;
+
+internal sealed class SegmentDisplayAutomationPeer : ControlAutomationPeer
+{
+ private readonly SegmentDisplay _owner;
+
+ public SegmentDisplayAutomationPeer(SegmentDisplay owner) : base(owner)
+ {
+ _owner = owner;
+ }
+
+ protected override string GetClassNameCore()
+ {
+ return nameof(SegmentDisplay);
+ }
+
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ {
+ return AutomationControlType.Text;
+ }
+
+ protected override string? GetNameCore()
+ {
+ var configuredName = AutomationProperties.GetName(_owner);
+ if (configuredName is not null)
+ {
+ return configuredName;
+ }
+
+ var inheritedName = base.GetNameCore();
+ return string.IsNullOrEmpty(inheritedName)
+ ? SegmentCharacterMap.GetDisplayText(_owner.Text)
+ : inheritedName;
+ }
+
+ internal void NotifyTextChanged(string? oldText, string? newText)
+ {
+ if (AutomationProperties.GetName(_owner) is not null
+ || !string.IsNullOrEmpty(base.GetNameCore()))
+ {
+ return;
+ }
+
+ var oldName = SegmentCharacterMap.GetDisplayText(oldText);
+ var newName = SegmentCharacterMap.GetDisplayText(newText);
+ if (oldName != newName)
+ {
+ RaisePropertyChangedEvent(AutomationElementIdentifiers.NameProperty, oldName, newName);
+ }
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/SegmentOverflowMode.cs b/src/AtomUI.Labs.Controls.Led/Segment/SegmentOverflowMode.cs
new file mode 100644
index 0000000..c18b6b5
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/SegmentOverflowMode.cs
@@ -0,0 +1,7 @@
+namespace AtomUI.Labs.Controls.Led.Segment;
+
+public enum SegmentOverflowMode
+{
+ Clip,
+ ScaleDown
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/SegmentValueSanitizer.cs b/src/AtomUI.Labs.Controls.Led/Segment/SegmentValueSanitizer.cs
new file mode 100644
index 0000000..edf0abb
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/SegmentValueSanitizer.cs
@@ -0,0 +1,51 @@
+using Avalonia;
+
+namespace AtomUI.Labs.Controls.Led.Segment;
+
+internal static class SegmentValueSanitizer
+{
+ public const double MaximumLayoutValue = 1_000_000;
+
+ public static double CoerceNonNegative(double value)
+ {
+ return IsFinite(value) ? Math.Clamp(value, 0, MaximumLayoutValue) : 0;
+ }
+
+ public static double CoerceAtLeast(double value, double minimum)
+ {
+ return IsFinite(value) ? Math.Clamp(value, minimum, MaximumLayoutValue) : minimum;
+ }
+
+ public static double CoerceRange(double value, double minimum, double maximum)
+ {
+ if (!IsFinite(value))
+ {
+ return minimum;
+ }
+
+ return Math.Clamp(value, minimum, maximum);
+ }
+
+ public static Thickness CoerceThickness(Thickness thickness)
+ {
+ return new Thickness(
+ CoerceNonNegative(thickness.Left),
+ CoerceNonNegative(thickness.Top),
+ CoerceNonNegative(thickness.Right),
+ CoerceNonNegative(thickness.Bottom));
+ }
+
+ public static CornerRadius CoerceCornerRadius(CornerRadius cornerRadius)
+ {
+ return new CornerRadius(
+ CoerceNonNegative(cornerRadius.TopLeft),
+ CoerceNonNegative(cornerRadius.TopRight),
+ CoerceNonNegative(cornerRadius.BottomRight),
+ CoerceNonNegative(cornerRadius.BottomLeft));
+ }
+
+ public static bool IsFinite(double value)
+ {
+ return !double.IsNaN(value) && !double.IsInfinity(value);
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/Themes/SegmentDisplayTheme.axaml b/src/AtomUI.Labs.Controls.Led/Segment/Themes/SegmentDisplayTheme.axaml
new file mode 100644
index 0000000..d5b156b
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/Themes/SegmentDisplayTheme.axaml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/AtomUI.Labs.Controls.Led/Segment/Themes/SegmentThemes.axaml b/src/AtomUI.Labs.Controls.Led/Segment/Themes/SegmentThemes.axaml
new file mode 100644
index 0000000..396b21e
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Segment/Themes/SegmentThemes.axaml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/src/AtomUI.Labs.Controls.Led/ThemeManagerBuilderExtensions.cs b/src/AtomUI.Labs.Controls.Led/ThemeManagerBuilderExtensions.cs
new file mode 100644
index 0000000..dca8515
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/ThemeManagerBuilderExtensions.cs
@@ -0,0 +1,12 @@
+using AtomUI.Theme;
+
+namespace AtomUI.Labs.Controls.Led;
+
+public static class LedThemeManagerBuilderExtensions
+{
+ public static IThemeManagerBuilder UseLed(this IThemeManagerBuilder themeManagerBuilder)
+ {
+ themeManagerBuilder.AddControlThemesProvider(new LedThemesProvider());
+ return themeManagerBuilder;
+ }
+}
diff --git a/src/AtomUI.Labs.Controls.Led/Themes/LedThemes.axaml b/src/AtomUI.Labs.Controls.Led/Themes/LedThemes.axaml
new file mode 100644
index 0000000..4e4e1db
--- /dev/null
+++ b/src/AtomUI.Labs.Controls.Led/Themes/LedThemes.axaml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/AtomUI.Labs.Controls.Led.Tests.csproj b/tests/AtomUI.Labs.Controls.Led.Tests/AtomUI.Labs.Controls.Led.Tests.csproj
new file mode 100644
index 0000000..d35ab51
--- /dev/null
+++ b/tests/AtomUI.Labs.Controls.Led.Tests/AtomUI.Labs.Controls.Led.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+ $(AtomUIDevelopTargetFramework)
+ false
+ AtomUI.Labs.Controls.Led.Tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/AvaloniaTestApp.cs b/tests/AtomUI.Labs.Controls.Led.Tests/AvaloniaTestApp.cs
new file mode 100644
index 0000000..756ced4
--- /dev/null
+++ b/tests/AtomUI.Labs.Controls.Led.Tests/AvaloniaTestApp.cs
@@ -0,0 +1,58 @@
+using System.Threading;
+using AtomUI;
+using AtomUI.Desktop.Controls;
+using AtomUI.Labs.Controls.Led;
+using Avalonia;
+using Avalonia.Headless;
+using Xunit;
+
+[assembly: AvaloniaTestApplication(typeof(AtomUI.Labs.Controls.Led.Tests.TestAppBuilder))]
+[assembly: CollectionBehavior(DisableTestParallelization = true)]
+
+namespace AtomUI.Labs.Controls.Led.Tests;
+
+internal static class AvaloniaTestApp
+{
+ private static readonly object s_lock = new();
+ private static int _initialized;
+
+ public static void EnsureInitialized()
+ {
+ if (Volatile.Read(ref _initialized) == 1)
+ {
+ return;
+ }
+
+ lock (s_lock)
+ {
+ if (_initialized == 1)
+ {
+ return;
+ }
+
+ TestAppBuilder.BuildAvaloniaApp().SetupWithoutStarting();
+ Volatile.Write(ref _initialized, 1);
+ }
+ }
+}
+
+public static class TestAppBuilder
+{
+ public static AppBuilder BuildAvaloniaApp()
+ {
+ return AppBuilder.Configure()
+ .UseHeadless(new AvaloniaHeadlessPlatformOptions
+ {
+ UseHeadlessDrawing = false
+ })
+ .UseSkia();
+ }
+}
+
+internal sealed class TestApplication : Application
+{
+ public override void Initialize()
+ {
+ this.UseAtomUI(builder => builder.UseDesktopControls().UseLed());
+ }
+}
diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/Gallery/LedGlowWorkbenchLifecycleTests.cs b/tests/AtomUI.Labs.Controls.Led.Tests/Gallery/LedGlowWorkbenchLifecycleTests.cs
new file mode 100644
index 0000000..c399a4b
--- /dev/null
+++ b/tests/AtomUI.Labs.Controls.Led.Tests/Gallery/LedGlowWorkbenchLifecycleTests.cs
@@ -0,0 +1,144 @@
+using AtomUILabsGallery.ShowCases.Led;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Threading;
+using Shouldly;
+using Xunit;
+
+namespace AtomUI.Labs.Controls.Led.Tests.Gallery;
+
+public class LedGlowWorkbenchLifecycleTests
+{
+ static LedGlowWorkbenchLifecycleTests()
+ {
+ AvaloniaTestApp.EnsureInitialized();
+ }
+
+ [Fact]
+ public void DetachAndReattach_ShouldCancelAndRestartConfiguredAnimation()
+ {
+ var workbench = new LedGlowWorkbench { AnimationModeIndex = 1 };
+
+ ShowAndClose(workbench);
+ workbench.HasActiveAnimation.ShouldBeFalse();
+
+ ShowAndClose(workbench);
+ workbench.HasActiveAnimation.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void BrushEditors_ShouldUpdateBothPreviews_AndPreserveLastValidColor()
+ {
+ var workbench = new LedGlowWorkbench();
+
+ workbench.BrushPresetIndex = 1;
+ workbench.BrushHexText.ShouldBe("#FF3030");
+ GetColor(workbench.MatrixGlowBrush).ShouldBe(Color.FromRgb(255, 48, 48));
+ GetColor(workbench.SegmentGlowBrush).ShouldBe(Color.FromRgb(255, 48, 48));
+
+ workbench.BrushHexText = "#8044CC88";
+ workbench.BrushPresetIndex.ShouldBe(-1);
+ workbench.HasBrushValidationError.ShouldBeFalse();
+ GetColor(workbench.MatrixGlowBrush).ShouldBe(Color.FromArgb(128, 68, 204, 136));
+ GetColor(workbench.SegmentGlowBrush).ShouldBe(Color.FromArgb(128, 68, 204, 136));
+
+ workbench.BrushHexText = "#abcdef";
+ workbench.HasBrushValidationError.ShouldBeFalse();
+ GetColor(workbench.MatrixGlowBrush).ShouldBe(Color.FromRgb(171, 205, 239));
+ GetColor(workbench.SegmentGlowBrush).ShouldBe(Color.FromRgb(171, 205, 239));
+
+ workbench.BrushHexText = "#12";
+ workbench.HasBrushValidationError.ShouldBeTrue();
+ GetColor(workbench.MatrixGlowBrush).ShouldBe(Color.FromRgb(171, 205, 239));
+ GetColor(workbench.SegmentGlowBrush).ShouldBe(Color.FromRgb(171, 205, 239));
+ }
+
+ [Fact]
+ public void DisabledState_ShouldRetainEditedConfigurationUntilReenabled()
+ {
+ var workbench = new LedGlowWorkbench
+ {
+ GlowEnabled = false,
+ BrushHexText = "#336699",
+ GlowOpacity = 0.72,
+ GlowRadius = 13
+ };
+
+ workbench.MatrixGlowBrush.ShouldBeNull();
+ workbench.SegmentGlowBrush.ShouldBeNull();
+ workbench.MatrixGlowOpacity.ShouldBe(0.72);
+ workbench.SegmentGlowOpacity.ShouldBe(0.72);
+ workbench.MatrixGlowRadius.ShouldBe(13);
+ workbench.SegmentGlowRadius.ShouldBe(13);
+
+ workbench.GlowEnabled = true;
+
+ GetColor(workbench.MatrixGlowBrush).ShouldBe(Color.FromRgb(51, 102, 153));
+ GetColor(workbench.SegmentGlowBrush).ShouldBe(Color.FromRgb(51, 102, 153));
+ workbench.MatrixGlowOpacity.ShouldBe(0.72);
+ workbench.SegmentGlowOpacity.ShouldBe(0.72);
+ workbench.MatrixGlowRadius.ShouldBe(13);
+ workbench.SegmentGlowRadius.ShouldBe(13);
+ }
+
+ [Fact]
+ public void ModeAndStateChanges_ShouldRestartOrCancelAnimationImmediately()
+ {
+ var workbench = new LedGlowWorkbench();
+ var window = new Window { Width = 800, Height = 600, Content = workbench };
+ try
+ {
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ workbench.HasActiveAnimation.ShouldBeFalse();
+
+ workbench.AnimationModeIndex = 1;
+ Dispatcher.UIThread.RunJobs();
+ workbench.HasActiveAnimation.ShouldBeTrue();
+
+ workbench.AnimationModeIndex = 2;
+ Dispatcher.UIThread.RunJobs();
+ workbench.HasActiveAnimation.ShouldBeTrue();
+
+ workbench.GlowEnabled = false;
+ Dispatcher.UIThread.RunJobs();
+ workbench.HasActiveAnimation.ShouldBeFalse();
+
+ workbench.GlowEnabled = true;
+ Dispatcher.UIThread.RunJobs();
+ workbench.HasActiveAnimation.ShouldBeTrue();
+
+ workbench.AnimationModeIndex = 0;
+ Dispatcher.UIThread.RunJobs();
+ workbench.HasActiveAnimation.ShouldBeFalse();
+ }
+ finally
+ {
+ window.Content = null;
+ window.Close();
+ Dispatcher.UIThread.RunJobs();
+ }
+ }
+
+ private static void ShowAndClose(LedGlowWorkbench workbench)
+ {
+ var window = new Window { Width = 800, Height = 600, Content = workbench };
+ try
+ {
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ workbench.HasActiveAnimation.ShouldBeTrue();
+ }
+ finally
+ {
+ window.Content = null;
+ window.Close();
+ Dispatcher.UIThread.RunJobs();
+ }
+ }
+
+ private static Color GetColor(IBrush? brush)
+ {
+ return brush.ShouldBeOfType().Color;
+ }
+}
diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/Glow/LedGlowPixelTests.cs b/tests/AtomUI.Labs.Controls.Led.Tests/Glow/LedGlowPixelTests.cs
new file mode 100644
index 0000000..31f50ed
--- /dev/null
+++ b/tests/AtomUI.Labs.Controls.Led.Tests/Glow/LedGlowPixelTests.cs
@@ -0,0 +1,186 @@
+using System.Runtime.InteropServices;
+using AtomUI.Labs.Controls.Led.Matrix;
+using AtomUI.Labs.Controls.Led.Segment;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+using Shouldly;
+using Xunit;
+
+namespace AtomUI.Labs.Controls.Led.Tests.Glow;
+
+public class LedGlowPixelTests
+{
+ static LedGlowPixelTests()
+ {
+ AvaloniaTestApp.EnsureInitialized();
+ }
+
+ [Theory]
+ [InlineData(1, 6)]
+ [InlineData(1, 24)]
+ [InlineData(2, 6)]
+ [InlineData(2, 24)]
+ public void MatrixGlow_ShouldAddPixelsWithoutChangingBorder(double renderScaling, double radius)
+ {
+ var withoutGlow = CreateMatrix(null, radius);
+ var baseline = Capture(withoutGlow, renderScaling);
+ var withGlow = CreateMatrix(Brushes.Cyan, radius);
+ var glowing = Capture(withGlow, renderScaling);
+
+ CountDifferentPixels(baseline, glowing).ShouldBeGreaterThan(0);
+ CountWhitePixels(glowing).ShouldBe(CountWhitePixels(baseline));
+ glowing.GetPixel(2, 2, renderScaling).ShouldBe(baseline.GetPixel(2, 2, renderScaling));
+ withGlow.GlowEffectBuildCount.ShouldBe(1);
+ withGlow.GlowEffectScopeCount.ShouldBeGreaterThan(0);
+ }
+
+ [Theory]
+ [InlineData(1, 6)]
+ [InlineData(1, 24)]
+ [InlineData(2, 6)]
+ [InlineData(2, 24)]
+ public void SegmentGlow_ShouldAddPixelsAndKeepActiveCoreSharp(double renderScaling, double radius)
+ {
+ var baseline = Capture(CreateSegment(null, radius), renderScaling);
+ var withGlow = CreateSegment(Brushes.Cyan, radius);
+ var glowing = Capture(withGlow, renderScaling);
+
+ CountDifferentPixels(baseline, glowing).ShouldBeGreaterThan(0);
+ CountWhitePixels(glowing).ShouldBe(CountWhitePixels(baseline));
+ withGlow.GlowEffectBuildCount.ShouldBe(1);
+ withGlow.GlowEffectScopeCount.ShouldBeGreaterThan(0);
+ }
+
+ private static MatrixDisplay CreateMatrix(IBrush? glowBrush, double radius)
+ {
+ return new MatrixDisplay
+ {
+ Width = 240,
+ Height = 120,
+ Text = "88",
+ DotSize = 7,
+ DotSpacing = 3,
+ Padding = new Thickness(16),
+ Background = Brushes.Black,
+ BorderBrush = Brushes.Yellow,
+ BorderThickness = new Thickness(4),
+ ActiveBrush = Brushes.White,
+ InactiveBrush = null,
+ ShowInactiveDots = false,
+ GlowBrush = glowBrush,
+ GlowOpacity = 0.65,
+ GlowRadius = radius,
+ HorizontalAlignment = HorizontalAlignment.Left,
+ VerticalAlignment = VerticalAlignment.Top
+ };
+ }
+
+ private static SegmentDisplay CreateSegment(IBrush? glowBrush, double radius)
+ {
+ return new SegmentDisplay
+ {
+ Width = 300,
+ Height = 120,
+ Text = "12:45",
+ CharacterHeight = 72,
+ Padding = new Thickness(16),
+ Background = Brushes.Black,
+ ActiveBrush = Brushes.White,
+ InactiveBrush = null,
+ ShowInactiveSegments = false,
+ GlowBrush = glowBrush,
+ GlowOpacity = 0.65,
+ GlowRadius = radius,
+ HorizontalAlignment = HorizontalAlignment.Left,
+ VerticalAlignment = VerticalAlignment.Top
+ };
+ }
+
+ private static PixelFrame Capture(Control control, double renderScaling)
+ {
+ var window = new Window
+ {
+ Width = 340,
+ Height = 160,
+ Background = Brushes.Black,
+ Content = control
+ };
+
+ try
+ {
+ window.Show();
+ window.SetRenderScaling(renderScaling);
+ Dispatcher.UIThread.RunJobs();
+ using var frame = window.CaptureRenderedFrame();
+ frame.ShouldNotBeNull();
+ using var framebuffer = frame!.Lock();
+ framebuffer.Format.BitsPerPixel.ShouldBe(32);
+ var bytes = new byte[framebuffer.RowBytes * framebuffer.Size.Height];
+ Marshal.Copy(framebuffer.Address, bytes, 0, bytes.Length);
+ return new PixelFrame(bytes, framebuffer.Size.Width, framebuffer.Size.Height, framebuffer.RowBytes);
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+
+ private static int CountDifferentPixels(PixelFrame left, PixelFrame right)
+ {
+ left.Width.ShouldBe(right.Width);
+ left.Height.ShouldBe(right.Height);
+ var count = 0;
+ for (var y = 0; y < left.Height; y++)
+ {
+ for (var x = 0; x < left.Width; x++)
+ {
+ if (left.GetPixel(x, y) != right.GetPixel(x, y))
+ {
+ count++;
+ }
+ }
+ }
+
+ return count;
+ }
+
+ private static int CountWhitePixels(PixelFrame frame)
+ {
+ var count = 0;
+ for (var y = 0; y < frame.Height; y++)
+ {
+ for (var x = 0; x < frame.Width; x++)
+ {
+ var pixel = frame.GetPixel(x, y);
+ if (pixel.B >= 250 && pixel.G >= 250 && pixel.R >= 250)
+ {
+ count++;
+ }
+ }
+ }
+
+ return count;
+ }
+
+ private readonly record struct PixelFrame(byte[] Bytes, int Width, int Height, int RowBytes)
+ {
+ public Pixel GetPixel(int x, int y)
+ {
+ var offset = y * RowBytes + x * 4;
+ return new Pixel(Bytes[offset], Bytes[offset + 1], Bytes[offset + 2], Bytes[offset + 3]);
+ }
+
+ public Pixel GetPixel(int logicalX, int logicalY, double renderScaling)
+ {
+ return GetPixel(
+ Math.Clamp((int)(logicalX * renderScaling), 0, Width - 1),
+ Math.Clamp((int)(logicalY * renderScaling), 0, Height - 1));
+ }
+ }
+
+ private readonly record struct Pixel(byte B, byte G, byte R, byte A);
+}
diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/Glow/LedGlowRendererTests.cs b/tests/AtomUI.Labs.Controls.Led.Tests/Glow/LedGlowRendererTests.cs
new file mode 100644
index 0000000..49c8418
--- /dev/null
+++ b/tests/AtomUI.Labs.Controls.Led.Tests/Glow/LedGlowRendererTests.cs
@@ -0,0 +1,194 @@
+using AtomUI.Labs.Controls.Led.Glow;
+using AtomUI.Labs.Controls.Led.Matrix;
+using AtomUI.Labs.Controls.Led.Segment;
+using Avalonia;
+using Avalonia.Media;
+using Shouldly;
+using Xunit;
+
+namespace AtomUI.Labs.Controls.Led.Tests.Glow;
+
+public class LedGlowRendererTests
+{
+ static LedGlowRendererTests()
+ {
+ AvaloniaTestApp.EnsureInitialized();
+ }
+
+ [Theory]
+ [InlineData(double.NaN, 0)]
+ [InlineData(double.NegativeInfinity, 0)]
+ [InlineData(double.PositiveInfinity, 0)]
+ [InlineData(-1, 0)]
+ [InlineData(0, 0)]
+ [InlineData(0.35, 0.35)]
+ [InlineData(1, 1)]
+ [InlineData(2, 1)]
+ public void CoerceOpacity_ShouldFollowSharedContract(double value, double expected)
+ {
+ LedGlowValueSanitizer.CoerceOpacity(value).ShouldBe(expected);
+ }
+
+ [Theory]
+ [InlineData(double.NaN, 0)]
+ [InlineData(double.NegativeInfinity, 0)]
+ [InlineData(double.PositiveInfinity, 0)]
+ [InlineData(-1, 0)]
+ [InlineData(0, 0)]
+ [InlineData(0.5, 0.5)]
+ [InlineData(6, 6)]
+ [InlineData(12, 12)]
+ [InlineData(24, 24)]
+ [InlineData(25, 24)]
+ [InlineData(double.MaxValue, 24)]
+ public void CoerceRadius_ShouldFollowSharedContract(double value, double expected)
+ {
+ LedGlowValueSanitizer.CoerceRadius(value).ShouldBe(expected);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void GlowDisabled_ShouldNotBuildOrSubmitEffect(bool matrix)
+ {
+ if (matrix)
+ {
+ var display = CreateMatrix();
+ Render(display);
+ display.GlowEffectBuildCount.ShouldBe(0);
+ display.GlowEffectScopeCount.ShouldBe(0);
+ }
+ else
+ {
+ var display = CreateSegment();
+ Render(display);
+ display.GlowEffectBuildCount.ShouldBe(0);
+ display.GlowEffectScopeCount.ShouldBe(0);
+ }
+ }
+
+ [Fact]
+ public void Matrix_ShouldUseOneEffectScopePerRenderAndReuseEffect()
+ {
+ var display = CreateMatrix();
+ display.GlowBrush = Brushes.Cyan;
+
+ Render(display);
+ display.GlowEffectBuildCount.ShouldBe(1);
+ display.GlowEffectScopeCount.ShouldBe(1);
+
+ display.GlowBrush = Brushes.Magenta;
+ display.GlowOpacity = 0.6;
+ display.GlowRadius = 12;
+ Render(display);
+
+ display.GlowEffectBuildCount.ShouldBe(1);
+ display.GlowEffectScopeCount.ShouldBe(2);
+ }
+
+ [Fact]
+ public void Segment_ShouldUseOneEffectScopePerRenderAndReuseEffect()
+ {
+ var display = CreateSegment();
+ display.GlowBrush = Brushes.Cyan;
+
+ Render(display);
+ display.GlowEffectBuildCount.ShouldBe(1);
+ display.GlowEffectScopeCount.ShouldBe(1);
+
+ display.GlowBrush = Brushes.Magenta;
+ display.GlowOpacity = 0.6;
+ display.GlowRadius = 12;
+ Render(display);
+
+ display.GlowEffectBuildCount.ShouldBe(1);
+ display.GlowEffectScopeCount.ShouldBe(2);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void ClearingGlowBrush_ShouldReleaseRendererState(bool matrix)
+ {
+ if (matrix)
+ {
+ var display = CreateMatrix();
+ display.GlowBrush = Brushes.Cyan;
+ Render(display);
+ display.GlowEffectBuildCount.ShouldBe(1);
+
+ display.GlowBrush = null;
+
+ display.GlowEffectBuildCount.ShouldBe(0);
+ display.GlowEffectScopeCount.ShouldBe(0);
+ }
+ else
+ {
+ var display = CreateSegment();
+ display.GlowBrush = Brushes.Cyan;
+ Render(display);
+ display.GlowEffectBuildCount.ShouldBe(1);
+
+ display.GlowBrush = null;
+
+ display.GlowEffectBuildCount.ShouldBe(0);
+ display.GlowEffectScopeCount.ShouldBe(0);
+ }
+ }
+
+ [Theory]
+ [InlineData(0, 6)]
+ [InlineData(0.35, 0)]
+ [InlineData(double.NaN, 6)]
+ [InlineData(0.35, double.PositiveInfinity)]
+ public void InvalidOrZeroEffectiveValues_ShouldSkipEffect(double opacity, double radius)
+ {
+ var display = CreateMatrix();
+ display.GlowBrush = Brushes.Cyan;
+ display.GlowOpacity = opacity;
+ display.GlowRadius = radius;
+
+ Render(display);
+
+ display.GlowEffectBuildCount.ShouldBe(0);
+ display.GlowEffectScopeCount.ShouldBe(0);
+ }
+
+ private static MatrixDisplay CreateMatrix()
+ {
+ var display = new MatrixDisplay
+ {
+ Text = "MATRIX",
+ ActiveBrush = Brushes.Red,
+ InactiveBrush = Brushes.DarkGray
+ };
+ display.Measure(new Size(600, 120));
+ display.Arrange(new Rect(0, 0, 600, 120));
+ return display;
+ }
+
+ private static SegmentDisplay CreateSegment()
+ {
+ var display = new SegmentDisplay
+ {
+ Text = "12:45",
+ ActiveBrush = Brushes.Red,
+ InactiveBrush = Brushes.DarkGray
+ };
+ display.Measure(new Size(600, 120));
+ display.Arrange(new Rect(0, 0, 600, 120));
+ return display;
+ }
+
+ private static void Render(MatrixDisplay display)
+ {
+ using var context = new DrawingGroup().Open();
+ display.Render(context);
+ }
+
+ private static void Render(SegmentDisplay display)
+ {
+ using var context = new DrawingGroup().Open();
+ display.Render(context);
+ }
+}
diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/LedCharacterNormalizerTests.cs b/tests/AtomUI.Labs.Controls.Led.Tests/LedCharacterNormalizerTests.cs
new file mode 100644
index 0000000..c5648a5
--- /dev/null
+++ b/tests/AtomUI.Labs.Controls.Led.Tests/LedCharacterNormalizerTests.cs
@@ -0,0 +1,21 @@
+using AtomUI.Labs.Controls.Led;
+using Shouldly;
+using Xunit;
+
+namespace AtomUI.Labs.Controls.Led.Tests;
+
+public class LedCharacterNormalizerTests
+{
+ [Theory]
+ [InlineData('a', 'A')]
+ [InlineData('z', 'Z')]
+ [InlineData('m', 'M')]
+ [InlineData('A', 'A')]
+ [InlineData('0', '0')]
+ [InlineData(':', ':')]
+ [InlineData(' ', ' ')]
+ public void NormalizeAscii_ShouldOnlyUppercaseAsciiLowercaseLetters(char input, char expected)
+ {
+ LedCharacterNormalizer.NormalizeAscii(input).ShouldBe(expected);
+ }
+}
diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/LedDisplayLayoutMathTests.cs b/tests/AtomUI.Labs.Controls.Led.Tests/LedDisplayLayoutMathTests.cs
new file mode 100644
index 0000000..5038d52
--- /dev/null
+++ b/tests/AtomUI.Labs.Controls.Led.Tests/LedDisplayLayoutMathTests.cs
@@ -0,0 +1,84 @@
+using AtomUI.Labs.Controls.Led;
+using Avalonia;
+using Avalonia.Layout;
+using Shouldly;
+using Xunit;
+
+namespace AtomUI.Labs.Controls.Led.Tests;
+
+public class LedDisplayLayoutMathTests
+{
+ [Theory]
+ [InlineData(200, 100, 100, 80, 0.5)]
+ [InlineData(100, 200, 80, 100, 0.5)]
+ [InlineData(100, 50, 200, 100, 1)]
+ [InlineData(100, 100, 25, 40, 0.25)]
+ public void CalculateScaleDown_ShouldFitWithoutScalingUp(
+ double desiredWidth,
+ double desiredHeight,
+ double boundsWidth,
+ double boundsHeight,
+ double expected)
+ {
+ var scale = LedDisplayLayoutMath.CalculateScaleDown(
+ new Size(desiredWidth, desiredHeight),
+ new Size(boundsWidth, boundsHeight));
+
+ scale.ShouldBe(expected);
+ }
+
+ [Theory]
+ [InlineData(0, 100, 100, 100)]
+ [InlineData(100, 0, 100, 100)]
+ [InlineData(100, 100, 0, 100)]
+ [InlineData(100, 100, 100, 0)]
+ [InlineData(double.NaN, 100, 100, 100)]
+ [InlineData(100, double.PositiveInfinity, 100, 100)]
+ [InlineData(100, 100, double.NegativeInfinity, 100)]
+ public void CalculateScaleDown_ShouldUseIdentityForInvalidDimensions(
+ double desiredWidth,
+ double desiredHeight,
+ double boundsWidth,
+ double boundsHeight)
+ {
+ var scale = LedDisplayLayoutMath.CalculateScaleDown(
+ new Size(desiredWidth, desiredHeight),
+ new Size(boundsWidth, boundsHeight));
+
+ scale.ShouldBe(1);
+ }
+
+ [Theory]
+ [InlineData(HorizontalAlignment.Left, VerticalAlignment.Top, 0, 0)]
+ [InlineData(HorizontalAlignment.Center, VerticalAlignment.Center, 75, 40)]
+ [InlineData(HorizontalAlignment.Right, VerticalAlignment.Bottom, 150, 80)]
+ [InlineData(HorizontalAlignment.Stretch, VerticalAlignment.Stretch, 75, 40)]
+ public void CalculateAlignmentOffset_ShouldApplyAlignmentToScaledContent(
+ HorizontalAlignment horizontalAlignment,
+ VerticalAlignment verticalAlignment,
+ double expectedX,
+ double expectedY)
+ {
+ var offset = LedDisplayLayoutMath.CalculateAlignmentOffset(
+ new Size(100, 40),
+ new Size(200, 100),
+ 0.5,
+ horizontalAlignment,
+ verticalAlignment);
+
+ offset.ShouldBe(new Vector(expectedX, expectedY));
+ }
+
+ [Fact]
+ public void CalculateAlignmentOffset_ShouldNotProduceNegativeExtraSpace()
+ {
+ var offset = LedDisplayLayoutMath.CalculateAlignmentOffset(
+ new Size(200, 100),
+ new Size(100, 50),
+ 1,
+ HorizontalAlignment.Right,
+ VerticalAlignment.Bottom);
+
+ offset.ShouldBe(default);
+ }
+}
diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixAxamlHost.axaml b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixAxamlHost.axaml
new file mode 100644
index 0000000..c333747
--- /dev/null
+++ b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixAxamlHost.axaml
@@ -0,0 +1,17 @@
+
+
+
diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixAxamlHost.axaml.cs b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixAxamlHost.axaml.cs
new file mode 100644
index 0000000..cc35d26
--- /dev/null
+++ b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixAxamlHost.axaml.cs
@@ -0,0 +1,15 @@
+using AtomUI.Labs.Controls.Led.Matrix;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace AtomUI.Labs.Controls.Led.Tests.Matrix;
+
+internal partial class MatrixAxamlHost : UserControl
+{
+ public MatrixAxamlHost()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public MatrixDisplay Display => this.FindControl("PART_Matrix")!;
+}
diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixCharacterMapTests.cs b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixCharacterMapTests.cs
new file mode 100644
index 0000000..71051e9
--- /dev/null
+++ b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixCharacterMapTests.cs
@@ -0,0 +1,85 @@
+using AtomUI.Labs.Controls.Led.Matrix.Character;
+using System.Text;
+using Shouldly;
+using Xunit;
+
+namespace AtomUI.Labs.Controls.Led.Tests.Matrix;
+
+public class MatrixCharacterMapTests
+{
+ [Theory]
+ [InlineData('A', 'A')]
+ [InlineData('z', 'Z')]
+ [InlineData('7', '7')]
+ [InlineData('+', '+')]
+ [InlineData(' ', ' ')]
+ public void GetPattern_ShouldNormalizeAndMapSupportedCharacters(char input, char expected)
+ {
+ var pattern = MatrixCharacterMap.GetPattern(input);
+
+ pattern.Character.ShouldBe(expected);
+ if (expected == ' ')
+ {
+ pattern.Glyph.Bits.ShouldBe(0UL);
+ }
+ else
+ {
+ pattern.Glyph.Bits.ShouldNotBe(0UL);
+ }
+ }
+
+ [Theory]
+ [InlineData('中')]
+ [InlineData('@')]
+ [InlineData('\n')]
+ public void GetPattern_ShouldFallbackUnsupportedCharactersToQuestionMark(char input)
+ {
+ var pattern = MatrixCharacterMap.GetPattern(input);
+
+ pattern.Character.ShouldBe('?');
+ MatrixFiveBySevenGlyphMap.TryGetGlyph('?', out var fallbackGlyph).ShouldBeTrue();
+ pattern.Glyph.ShouldBe(fallbackGlyph);
+ }
+
+ [Theory]
+ [InlineData(null, "")]
+ [InlineData("", "")]
+ [InlineData("labs 2026", "LABS 2026")]
+ [InlineData("A中@Z", "A??Z")]
+ [InlineData("A😀Z", "A?Z")]
+ public void GetDisplayText_ShouldMatchRenderedCharacterSemantics(string? text, string expected)
+ {
+ MatrixCharacterMap.GetDisplayText(text).ShouldBe(expected);
+ }
+
+ [Fact]
+ public void GetDisplayText_ShouldFallbackUnpairedSurrogateOnce()
+ {
+ var text = new string(new[] { 'A', '\uD800', 'Z' });
+
+ MatrixCharacterMap.GetDisplayText(text).ShouldBe("A?Z");
+ }
+
+ [Fact]
+ public void GetPattern_ShouldFallbackSupplementaryRuneOnce()
+ {
+ var pattern = MatrixCharacterMap.GetPattern(new Rune(0x1F600));
+
+ pattern.Character.ShouldBe('?');
+ }
+
+ [Fact]
+ public void GetPatternCount_ShouldCountUnicodeScalarsInsteadOfUtf16CodeUnits()
+ {
+ MatrixCharacterMap.GetPatternCount("A😀Z").ShouldBe(3);
+ MatrixCharacterMap.GetPatternCount("A\uD800Z").ShouldBe(3);
+ }
+
+ [Fact]
+ public void GetDisplayText_ShouldReuseOriginalStringWhenNoNormalizationIsNeeded()
+ {
+ var text = new string("MATRIX 2026".ToCharArray());
+
+ MatrixCharacterMap.GetDisplayText(text).ShouldBeSameAs(text);
+ }
+}
diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixDisplayAutomationTests.cs b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixDisplayAutomationTests.cs
new file mode 100644
index 0000000..9c14751
--- /dev/null
+++ b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixDisplayAutomationTests.cs
@@ -0,0 +1,81 @@
+using AtomUI.Labs.Controls.Led.Matrix;
+using AtomUI.Labs.Controls.Led.Matrix.Character;
+using Avalonia.Automation;
+using Avalonia.Automation.Peers;
+using Shouldly;
+using Xunit;
+
+namespace AtomUI.Labs.Controls.Led.Tests.Matrix;
+
+public class MatrixDisplayAutomationTests
+{
+ static MatrixDisplayAutomationTests()
+ {
+ AvaloniaTestApp.EnsureInitialized();
+ }
+
+ [Fact]
+ public void AutomationPeer_ShouldExposeNormalizedDisplayAsTextContent()
+ {
+ var display = new MatrixDisplay { Text = "a中z" };
+
+ var peer = ControlAutomationPeer.CreatePeerForElement(display);
+
+ peer.ShouldBeOfType();
+ peer.GetClassName().ShouldBe(nameof(MatrixDisplay));
+ peer.GetAutomationControlType().ShouldBe(AutomationControlType.Text);
+ peer.GetName().ShouldBe("A?Z");
+ peer.IsContentElement().ShouldBeTrue();
+ }
+
+ [Fact]
+ public void AutomationPeer_ShouldExposeOneFallbackPerUnsupportedUnicodeScalar()
+ {
+ var display = new MatrixDisplay { Text = "A😀中Z" };
+
+ var peer = ControlAutomationPeer.CreatePeerForElement(display);
+
+ peer.GetName().ShouldBe("A??Z");
+ }
+
+ [Fact]
+ public void AutomationPeer_ShouldPreferExplicitAutomationNameIncludingEmptyString()
+ {
+ var display = new MatrixDisplay { Text = "1234" };
+ AutomationProperties.SetName(display, "Counter");
+ var peer = ControlAutomationPeer.CreatePeerForElement(display);
+
+ peer.GetName().ShouldBe("Counter");
+
+ AutomationProperties.SetName(display, string.Empty);
+ peer.GetName().ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void AutomationPeer_ShouldRaiseNameChangeForDifferentDisplayText()
+ {
+ var display = new MatrixDisplay { Text = "abc" };
+ var peer = ControlAutomationPeer.CreatePeerForElement(display);
+ var propertyChangeCount = 0;
+ peer.PropertyChanged += (_, _) => propertyChangeCount++;
+
+ display.Text = "98中";
+
+ peer.GetName().ShouldBe("98?");
+ propertyChangeCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public void AutomationPeer_ShouldNotRaiseNameChangeForEquivalentDisplayText()
+ {
+ var display = new MatrixDisplay { Text = "abc" };
+ var peer = ControlAutomationPeer.CreatePeerForElement(display);
+ var propertyChangeCount = 0;
+ peer.PropertyChanged += (_, _) => propertyChangeCount++;
+
+ display.Text = "ABC";
+
+ peer.GetName().ShouldBe(MatrixCharacterMap.GetDisplayText(display.Text));
+ propertyChangeCount.ShouldBe(0);
+ }
+}
diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixDisplayContractTests.cs b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixDisplayContractTests.cs
new file mode 100644
index 0000000..77702d9
--- /dev/null
+++ b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixDisplayContractTests.cs
@@ -0,0 +1,139 @@
+using AtomUI.Labs.Controls.Led.Matrix;
+using Avalonia;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Shouldly;
+using Xunit;
+
+namespace AtomUI.Labs.Controls.Led.Tests.Matrix;
+
+public class MatrixDisplayContractTests
+{
+ [Fact]
+ public void DefaultValues_ShouldMatchDocumentedContract()
+ {
+ var display = new MatrixDisplay();
+
+ display.Text.ShouldBeNull();
+ display.DotSize.ShouldBe(6);
+ display.DotSpacing.ShouldBe(2);
+ display.DotShape.ShouldBe(MatrixDotShape.Circle);
+ display.DotCornerRadiusRatio.ShouldBe(0.25);
+ display.CharacterSpacing.ShouldBe(8);
+ display.Padding.ShouldBe(default);
+ display.HorizontalContentAlignment.ShouldBe(HorizontalAlignment.Left);
+ display.VerticalContentAlignment.ShouldBe(VerticalAlignment.Top);
+ display.OverflowMode.ShouldBe(MatrixOverflowMode.Clip);
+ display.Background.ShouldBeNull();
+ display.BorderBrush.ShouldBeNull();
+ display.BorderThickness.ShouldBe(default);
+ display.CornerRadius.ShouldBe(default);
+ display.ActiveBrush.ShouldBeNull();
+ display.InactiveBrush.ShouldBeNull();
+ display.GlowBrush.ShouldBeNull();
+ display.GlowOpacity.ShouldBe(0.35);
+ display.GlowRadius.ShouldBe(6);
+ display.IsMarqueeEnabled.ShouldBeFalse();
+ display.MarqueeSpeed.ShouldBe(48);
+ display.MarqueeRepeatDelay.ShouldBe(TimeSpan.FromMilliseconds(500));
+ display.ShowInactiveDots.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void StyledProperties_ShouldKeepRegisteredNamesAndDefaults()
+ {
+ MatrixDisplay.TextProperty.Name.ShouldBe(nameof(MatrixDisplay.Text));
+ MatrixDisplay.DotSizeProperty.Name.ShouldBe(nameof(MatrixDisplay.DotSize));
+ MatrixDisplay.DotSpacingProperty.Name.ShouldBe(nameof(MatrixDisplay.DotSpacing));
+ MatrixDisplay.DotShapeProperty.Name.ShouldBe(nameof(MatrixDisplay.DotShape));
+ MatrixDisplay.DotCornerRadiusRatioProperty.Name.ShouldBe(nameof(MatrixDisplay.DotCornerRadiusRatio));
+ MatrixDisplay.CharacterSpacingProperty.Name.ShouldBe(nameof(MatrixDisplay.CharacterSpacing));
+ MatrixDisplay.PaddingProperty.Name.ShouldBe(nameof(MatrixDisplay.Padding));
+ MatrixDisplay.HorizontalContentAlignmentProperty.Name.ShouldBe(nameof(MatrixDisplay.HorizontalContentAlignment));
+ MatrixDisplay.VerticalContentAlignmentProperty.Name.ShouldBe(nameof(MatrixDisplay.VerticalContentAlignment));
+ MatrixDisplay.OverflowModeProperty.Name.ShouldBe(nameof(MatrixDisplay.OverflowMode));
+ MatrixDisplay.BackgroundProperty.Name.ShouldBe(nameof(MatrixDisplay.Background));
+ MatrixDisplay.BorderBrushProperty.Name.ShouldBe(nameof(MatrixDisplay.BorderBrush));
+ MatrixDisplay.BorderThicknessProperty.Name.ShouldBe(nameof(MatrixDisplay.BorderThickness));
+ MatrixDisplay.CornerRadiusProperty.Name.ShouldBe(nameof(MatrixDisplay.CornerRadius));
+ MatrixDisplay.ActiveBrushProperty.Name.ShouldBe(nameof(MatrixDisplay.ActiveBrush));
+ MatrixDisplay.InactiveBrushProperty.Name.ShouldBe(nameof(MatrixDisplay.InactiveBrush));
+ MatrixDisplay.GlowBrushProperty.Name.ShouldBe(nameof(MatrixDisplay.GlowBrush));
+ MatrixDisplay.GlowOpacityProperty.Name.ShouldBe(nameof(MatrixDisplay.GlowOpacity));
+ MatrixDisplay.GlowRadiusProperty.Name.ShouldBe(nameof(MatrixDisplay.GlowRadius));
+ MatrixDisplay.IsMarqueeEnabledProperty.Name.ShouldBe(nameof(MatrixDisplay.IsMarqueeEnabled));
+ MatrixDisplay.MarqueeSpeedProperty.Name.ShouldBe(nameof(MatrixDisplay.MarqueeSpeed));
+ MatrixDisplay.MarqueeRepeatDelayProperty.Name.ShouldBe(nameof(MatrixDisplay.MarqueeRepeatDelay));
+ MatrixDisplay.ShowInactiveDotsProperty.Name.ShouldBe(nameof(MatrixDisplay.ShowInactiveDots));
+
+ MatrixDisplay.DotSizeProperty.GetMetadata(typeof(MatrixDisplay)).DefaultValue.ShouldBe(6);
+ MatrixDisplay.DotSpacingProperty.GetMetadata(typeof(MatrixDisplay)).DefaultValue.ShouldBe(2);
+ MatrixDisplay.DotShapeProperty.GetMetadata(typeof(MatrixDisplay)).DefaultValue.ShouldBe(MatrixDotShape.Circle);
+ MatrixDisplay.DotCornerRadiusRatioProperty.GetMetadata(typeof(MatrixDisplay)).DefaultValue.ShouldBe(0.25);
+ MatrixDisplay.CharacterSpacingProperty.GetMetadata(typeof(MatrixDisplay)).DefaultValue.ShouldBe(8);
+ MatrixDisplay.OverflowModeProperty.GetMetadata(typeof(MatrixDisplay)).DefaultValue.ShouldBe(MatrixOverflowMode.Clip);
+ MatrixDisplay.ShowInactiveDotsProperty.GetMetadata(typeof(MatrixDisplay)).DefaultValue.ShouldBe(true);
+ MatrixDisplay.GlowBrushProperty.GetMetadata(typeof(MatrixDisplay)).DefaultValue.ShouldBeNull();
+ MatrixDisplay.GlowOpacityProperty.GetMetadata(typeof(MatrixDisplay)).DefaultValue.ShouldBe(0.35);
+ MatrixDisplay.GlowRadiusProperty.GetMetadata(typeof(MatrixDisplay)).DefaultValue.ShouldBe(6);
+ MatrixDisplay.IsMarqueeEnabledProperty.GetMetadata(typeof(MatrixDisplay)).DefaultValue.ShouldBe(false);
+ MatrixDisplay.MarqueeSpeedProperty.GetMetadata(typeof(MatrixDisplay)).DefaultValue.ShouldBe(48);
+ MatrixDisplay.MarqueeRepeatDelayProperty.GetMetadata(typeof(MatrixDisplay)).DefaultValue
+ .ShouldBe(TimeSpan.FromMilliseconds(500));
+ }
+
+ [Fact]
+ public void StyledProperties_ShouldAcceptConfiguredValues()
+ {
+ var display = new MatrixDisplay
+ {
+ Text = "MATRIX",
+ DotSize = 9,
+ DotSpacing = 3,
+ DotShape = MatrixDotShape.RoundedSquare,
+ DotCornerRadiusRatio = 0.35,
+ CharacterSpacing = 11,
+ Padding = new Thickness(4),
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Bottom,
+ OverflowMode = MatrixOverflowMode.ScaleDown,
+ Background = Brushes.Black,
+ BorderBrush = Brushes.Blue,
+ BorderThickness = new Thickness(1, 2, 3, 4),
+ CornerRadius = new CornerRadius(6),
+ ActiveBrush = Brushes.Red,
+ InactiveBrush = Brushes.Gray,
+ GlowBrush = Brushes.Yellow,
+ GlowOpacity = 0.4,
+ GlowRadius = 12,
+ IsMarqueeEnabled = true,
+ MarqueeSpeed = 96,
+ MarqueeRepeatDelay = TimeSpan.FromSeconds(2),
+ ShowInactiveDots = false
+ };
+
+ display.Text.ShouldBe("MATRIX");
+ display.DotSize.ShouldBe(9);
+ display.DotSpacing.ShouldBe(3);
+ display.DotShape.ShouldBe(MatrixDotShape.RoundedSquare);
+ display.DotCornerRadiusRatio.ShouldBe(0.35);
+ display.CharacterSpacing.ShouldBe(11);
+ display.Padding.ShouldBe(new Thickness(4));
+ display.HorizontalContentAlignment.ShouldBe(HorizontalAlignment.Center);
+ display.VerticalContentAlignment.ShouldBe(VerticalAlignment.Bottom);
+ display.OverflowMode.ShouldBe(MatrixOverflowMode.ScaleDown);
+ display.Background.ShouldBeSameAs(Brushes.Black);
+ display.BorderBrush.ShouldBeSameAs(Brushes.Blue);
+ display.BorderThickness.ShouldBe(new Thickness(1, 2, 3, 4));
+ display.CornerRadius.ShouldBe(new CornerRadius(6));
+ display.ActiveBrush.ShouldBeSameAs(Brushes.Red);
+ display.InactiveBrush.ShouldBeSameAs(Brushes.Gray);
+ display.GlowBrush.ShouldBeSameAs(Brushes.Yellow);
+ display.GlowOpacity.ShouldBe(0.4);
+ display.GlowRadius.ShouldBe(12);
+ display.IsMarqueeEnabled.ShouldBeTrue();
+ display.MarqueeSpeed.ShouldBe(96);
+ display.MarqueeRepeatDelay.ShouldBe(TimeSpan.FromSeconds(2));
+ display.ShowInactiveDots.ShouldBeFalse();
+ }
+}
diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixDisplayInvalidationTests.cs b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixDisplayInvalidationTests.cs
new file mode 100644
index 0000000..91f0bcb
--- /dev/null
+++ b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixDisplayInvalidationTests.cs
@@ -0,0 +1,112 @@
+using AtomUI.Labs.Controls.Led.Matrix;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+using Shouldly;
+using Xunit;
+
+namespace AtomUI.Labs.Controls.Led.Tests.Matrix;
+
+public class MatrixDisplayInvalidationTests
+{
+ static MatrixDisplayInvalidationTests()
+ {
+ AvaloniaTestApp.EnsureInitialized();
+ }
+
+ [Fact]
+ public void LayoutProperties_ShouldInvalidateMeasure()
+ {
+ var display = new MatrixDisplay { Text = "A" };
+ ShowInWindow(display, window =>
+ {
+ AssertInvalidatesMeasure(display, () => display.Text = "B");
+ AssertInvalidatesMeasure(display, () => display.DotSize = 7);
+ AssertInvalidatesMeasure(display, () => display.DotSpacing = 3);
+ AssertInvalidatesMeasure(display, () => display.CharacterSpacing = 9);
+ AssertInvalidatesMeasure(display, () => display.Padding = new Thickness(2));
+ AssertInvalidatesMeasure(display, () => display.BorderThickness = new Thickness(2));
+ });
+ }
+
+ [Fact]
+ public void VisualProperties_ShouldInvalidateRenderWithoutInvalidatingMeasure()
+ {
+ var display = new CountingMatrixDisplay { Text = "A" };
+ ShowInWindow(display, window =>
+ {
+ AssertInvalidatesRenderOnly(display, window, () => display.HorizontalContentAlignment = HorizontalAlignment.Center);
+ AssertInvalidatesRenderOnly(display, window, () => display.VerticalContentAlignment = VerticalAlignment.Center);
+ AssertInvalidatesRenderOnly(display, window, () => display.OverflowMode = MatrixOverflowMode.ScaleDown);
+ AssertInvalidatesRenderOnly(display, window, () => display.DotShape = MatrixDotShape.RoundedSquare);
+ AssertInvalidatesRenderOnly(display, window, () => display.DotCornerRadiusRatio = 0.3);
+ AssertInvalidatesRenderOnly(display, window, () => display.Background = Brushes.Black);
+ AssertInvalidatesRenderOnly(display, window, () => display.BorderBrush = Brushes.Blue);
+ AssertInvalidatesRenderOnly(display, window, () => display.CornerRadius = new CornerRadius(4));
+ AssertInvalidatesRenderOnly(display, window, () => display.ActiveBrush = Brushes.Red);
+ AssertInvalidatesRenderOnly(display, window, () => display.InactiveBrush = Brushes.Gray);
+ AssertInvalidatesRenderOnly(display, window, () => display.ShowInactiveDots = false);
+ AssertInvalidatesRenderOnly(display, window, () => display.MarqueeSpeed = 64);
+ AssertInvalidatesRenderOnly(display, window, () => display.MarqueeRepeatDelay = TimeSpan.FromSeconds(1));
+ });
+ }
+
+ private static void AssertInvalidatesMeasure(MatrixDisplay display, Action change)
+ {
+ display.IsMeasureValid.ShouldBeTrue();
+
+ change();
+
+ display.IsMeasureValid.ShouldBeFalse();
+ Dispatcher.UIThread.RunJobs();
+ display.IsMeasureValid.ShouldBeTrue();
+ }
+
+ private static void AssertInvalidatesRenderOnly(CountingMatrixDisplay display, Window window, Action change)
+ {
+ using var previousFrame = window.CaptureRenderedFrame();
+ var renderCount = display.RenderCount;
+ display.IsMeasureValid.ShouldBeTrue();
+
+ change();
+
+ display.IsMeasureValid.ShouldBeTrue();
+ using var currentFrame = window.CaptureRenderedFrame();
+ display.RenderCount.ShouldBeGreaterThan(renderCount);
+ }
+
+ private static void ShowInWindow(Control content, Action assertion)
+ {
+ var window = new Window
+ {
+ Width = 320,
+ Height = 120,
+ Content = content
+ };
+
+ try
+ {
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ assertion(window);
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+
+ private sealed class CountingMatrixDisplay : MatrixDisplay
+ {
+ public int RenderCount { get; private set; }
+
+ public override void Render(DrawingContext context)
+ {
+ RenderCount++;
+ base.Render(context);
+ }
+ }
+}
diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixDisplayMeasureTests.cs b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixDisplayMeasureTests.cs
new file mode 100644
index 0000000..a7fcf26
--- /dev/null
+++ b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixDisplayMeasureTests.cs
@@ -0,0 +1,126 @@
+using AtomUI.Labs.Controls.Led.Matrix;
+using Avalonia;
+using Avalonia.Media;
+using Shouldly;
+using Xunit;
+
+namespace AtomUI.Labs.Controls.Led.Tests.Matrix;
+
+public class MatrixDisplayMeasureTests
+{
+ [Theory]
+ [InlineData(null, 0, 0)]
+ [InlineData("", 0, 0)]
+ [InlineData("A", 38, 54)]
+ [InlineData("AB", 84, 54)]
+ public void Measure_ShouldUseMatrixLayoutSemantics(string? text, double expectedWidth, double expectedHeight)
+ {
+ var display = new MatrixDisplay { Text = text };
+
+ display.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+
+ display.DesiredSize.ShouldBe(new Size(expectedWidth, expectedHeight));
+ }
+
+ [Fact]
+ public void Measure_ShouldApplyPaddingAndCoerceInvalidValues()
+ {
+ var display = new MatrixDisplay
+ {
+ Text = "AA",
+ DotSize = double.NaN,
+ DotSpacing = double.PositiveInfinity,
+ CharacterSpacing = -1,
+ Padding = new Thickness(double.NaN, -1, double.PositiveInfinity, 2)
+ };
+
+ display.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+
+ display.DesiredSize.ShouldBe(new Size(10, 9));
+ }
+
+ [Fact]
+ public void Measure_ShouldKeepFiniteSizeForExtremelyLargeFiniteValues()
+ {
+ var display = new MatrixDisplay
+ {
+ Text = "AA",
+ DotSize = double.MaxValue,
+ DotSpacing = double.MaxValue,
+ CharacterSpacing = double.MaxValue,
+ Padding = new Thickness(double.MaxValue)
+ };
+
+ display.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+
+ display.DesiredSize.ShouldBe(new Size(21_000_000, 15_000_000));
+ MatrixValueSanitizer.IsFinite(display.DesiredSize.Width).ShouldBeTrue();
+ MatrixValueSanitizer.IsFinite(display.DesiredSize.Height).ShouldBeTrue();
+ display.DotSize.ShouldBe(double.MaxValue);
+ }
+
+ [Fact]
+ public void DotShapeChanges_ShouldNotChangeDesiredSize()
+ {
+ var display = new MatrixDisplay { Text = "MATRIX" };
+ display.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+ var desiredSize = display.DesiredSize;
+
+ display.DotShape = MatrixDotShape.RoundedSquare;
+ display.DotCornerRadiusRatio = 0.4;
+ display.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+
+ display.DesiredSize.ShouldBe(desiredSize);
+ }
+
+ [Fact]
+ public void Measure_ShouldAddEachBorderSideIndependentlyOfBrush()
+ {
+ var display = new MatrixDisplay
+ {
+ Text = "A",
+ BorderBrush = null,
+ BorderThickness = new Thickness(1, 2, 3, 4)
+ };
+
+ display.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+
+ display.DesiredSize.ShouldBe(new Size(42, 60));
+ }
+
+ [Fact]
+ public void Measure_ShouldCoerceInvalidBorderThicknessWithoutChangingRawProperty()
+ {
+ var thickness = new Thickness(double.NaN, -1, double.PositiveInfinity, 2);
+ var display = new MatrixDisplay
+ {
+ Text = "A",
+ BorderThickness = thickness
+ };
+
+ display.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+
+ display.DesiredSize.ShouldBe(new Size(38, 56));
+ double.IsNaN(display.BorderThickness.Left).ShouldBeTrue();
+ display.BorderThickness.Top.ShouldBe(-1);
+ double.IsPositiveInfinity(display.BorderThickness.Right).ShouldBeTrue();
+ display.BorderThickness.Bottom.ShouldBe(2);
+ }
+
+ [Fact]
+ public void BorderBrushChanges_ShouldNotChangeDesiredSize()
+ {
+ var display = new MatrixDisplay
+ {
+ Text = "A",
+ BorderThickness = new Thickness(2)
+ };
+ display.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+ var desiredSize = display.DesiredSize;
+
+ display.BorderBrush = Brushes.Blue;
+ display.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+
+ display.DesiredSize.ShouldBe(desiredSize);
+ }
+}
diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixDisplayPixelTests.cs b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixDisplayPixelTests.cs
new file mode 100644
index 0000000..4c836e7
--- /dev/null
+++ b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixDisplayPixelTests.cs
@@ -0,0 +1,390 @@
+using System.Runtime.InteropServices;
+using AtomUI.Labs.Controls.Led.Matrix;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+using Shouldly;
+using Xunit;
+
+namespace AtomUI.Labs.Controls.Led.Tests.Matrix;
+
+public class MatrixDisplayPixelTests
+{
+ static MatrixDisplayPixelTests()
+ {
+ AvaloniaTestApp.EnsureInitialized();
+ }
+
+ public static IEnumerable