From 49100fce702c1894b530d2debb9c76cff2394ced Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 13:41:03 +0800 Subject: [PATCH 01/33] add led control docs --- .../led/family-common-boundary-audit.md | 79 +++ .../controls/led/glow-prototype-evaluation.md | 548 +++++++++++++++ docs/controls/led/glow-technical-options.md | 374 ++++++++++ docs/controls/led/matrix-implementation.md | 655 ++++++++++++++++++ .../led/matrix-marquee-minimum-contract.md | 85 +++ docs/controls/led/matrix-mvp-audit.md | 124 ++++ .../matrix-performance-allocation-and-soak.md | 72 ++ .../led/matrix-performance-baseline.md | 23 + .../led/matrix-performance-dynamic-load.md | 35 + .../led/matrix-performance-geometry-batch.md | 32 + .../led/matrix-static-visual-system.md | 166 +++++ docs/controls/led/overview.md | 219 ++++++ docs/controls/led/segment-implementation.md | 614 ++++++++++++++++ .../led/segment-performance-regression.md | 48 ++ docs/controls/overview.md | 9 + 15 files changed, 3083 insertions(+) create mode 100644 docs/controls/led/family-common-boundary-audit.md create mode 100644 docs/controls/led/glow-prototype-evaluation.md create mode 100644 docs/controls/led/glow-technical-options.md create mode 100644 docs/controls/led/matrix-implementation.md create mode 100644 docs/controls/led/matrix-marquee-minimum-contract.md create mode 100644 docs/controls/led/matrix-mvp-audit.md create mode 100644 docs/controls/led/matrix-performance-allocation-and-soak.md create mode 100644 docs/controls/led/matrix-performance-baseline.md create mode 100644 docs/controls/led/matrix-performance-dynamic-load.md create mode 100644 docs/controls/led/matrix-performance-geometry-batch.md create mode 100644 docs/controls/led/matrix-static-visual-system.md create mode 100644 docs/controls/led/overview.md create mode 100644 docs/controls/led/segment-implementation.md create mode 100644 docs/controls/led/segment-performance-regression.md create mode 100644 docs/controls/overview.md 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..81bd4b2 --- /dev/null +++ b/docs/controls/led/family-common-boundary-audit.md @@ -0,0 +1,79 @@ +# LED 家族公共边界审计 + +> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 + +- 审计日期:2026-07-10 +- 审计对象:`LED.Segment` 与 `LED.Matrix` +- 目标:只提取已经由两套稳定实现证明语义相同的内部基础代码 +- 公共 API 变化:无 + +## 最终结论 + +当前 LED 家族共享两项内部基础设施: + +```text +LEDCharacterNormalizer + ASCII小写 -> 大写 + +LEDDisplayLayoutMath + 理想尺寸 + 最终Bounds -> ScaleDown比例 + 内容对齐偏移 +``` + +二者都是无状态纯计算,不读取控件属性,不持有缓存,不生成Geometry,也不提交DrawingContext命令。 + +## 新增共享边界 + +`LEDDisplayLayoutMath`直接位于`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继续拥有: + +- 最终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..a1a1e26 --- /dev/null +++ b/docs/controls/led/glow-prototype-evaluation.md @@ -0,0 +1,548 @@ +# LED Glow 首轮原型评估 + +> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 + +## 评估状态 + +本报告记录2026-07-11的首轮可行性Smoke,不是最终选型或正式性能结论。原型只存在于`AtomUI.Desktop.Controls.Labs.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.Desktop.Controls.Labs.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.Desktop.Controls.Labs.GlowPrototype.Desktop\AtomUI.Desktop.Controls.Labs.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.Desktop.Controls.Labs.GlowPrototype.Desktop/AtomUI.Desktop.Controls.Labs.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.Desktop.Controls.Labs`正式运行时。共享实现位于`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.Desktop.Controls.Labs.Performance/AtomUI.Desktop.Controls.Labs.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.Desktop.Controls.Labs.GlowPrototype.Desktop/AtomUI.Desktop.Controls.Labs.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..30b5ac2 --- /dev/null +++ b/docs/controls/led/glow-technical-options.md @@ -0,0 +1,374 @@ +# LED Glow 技术路线选型 + +> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 + +本文记录 `AtomUI.Labs.Controls.LED` 家族真实静态 Glow 的技术路线。候选路线、评估过程和迁移前参考实现的最终决议均保留在本文中;当前 Labs 仓库尚未实现对应运行时代码。 + +Glow是Matrix与Segment之上的可选视觉增强层。两个基础控件在没有Glow时必须保持完整、可生产使用。Glow只接收已经生成的Active Geometry,不读取字符、字模、点阵行列或SegmentParts。 + +```text +Matrix Active Geometry ─┐ + ├─> LED Glow增强层 -> Active本体绘制 +Segment Active Geometry ─┘ +``` + +Segment当前的`GlowBrush`和`GlowOpacity`只是使用相同Geometry做一次透明叠色,没有向Geometry外部扩散。该实现是历史现状,不视为本文定义的真实空间Glow。 + +## 第一版契约约束 + +候选公共属性严格限制为: + +| 属性 | 类型 | 默认值 | 语义 | +|---|---|---:|---| +| `GlowBrush` | `IBrush?` | `null` | 光晕画刷;`null`关闭Glow | +| `GlowOpacity` | `double` | `0.35` | 光晕强度,有效范围`0..1` | +| `GlowRadius` | `double` | `6` DIP | 光晕在局部绘制坐标中的扩散范围;有效范围`0..24` DIP | + +不增加`GlowEnabled`、`GlowColor`、`GlowIntensity`、`GlowBlurRadius`、`GlowSpread`、`GlowOffsetX/Y`、`GlowLayerCount`、`GlowQuality`或专用动画属性。动画由Avalonia Animation驱动上述静态StyledProperty。 + +`GlowRadius`第一版使用固定安全范围: + +```text +负数、NaN、正负Infinity -> 0 +0..24 -> 保持原值 +大于24 -> 内部按24处理 +``` + +规整只作用于内部有效值,不回写开发者设置的StyledProperty,避免破坏Binding和属性优先级。Matrix与Segment必须使用完全相同的范围和规则。视觉语义建议为:`0`关闭、`2`轻微柔光、`6`默认、`12`明显、`24`第一版强Glow上限。 + +Radius上限只限制Geometry外扩,不能限制超大字符本体产生的Mask。因此正式实现仍必须同时定义单个离屏缓冲区物理像素上限和单控件Glow缓存总字节预算。 + +路线B单个离屏Glow Mask使用以下内部安全上限: + +```text +单边最大值:1024物理像素 +总面积上限:262144物理像素(等价于512 x 512) +``` + +请求尺寸超限时只降低Glow Mask分辨率,Active Geometry本体仍按正常RenderScaling清晰绘制。降采样比例为: + +```text +scaleByWidth = 1024 / requestedWidth +scaleByHeight = 1024 / requestedHeight +scaleByArea = sqrt(262144 / requestedArea) +maskScale = min(1, scaleByWidth, scaleByHeight, scaleByArea) +``` + +降采样后必须保持逻辑Glow Bounds和Radius语义不变。若极端输入或后端错误导致仍无法安全创建资源,只跳过该Glow绘制,基础Active Geometry必须继续正常显示;第一版不回退到路线A,避免同一属性在不同尺寸下静默切换视觉算法。 + +降采样或跳过只能按控件和同类配置记录一次诊断,禁止每帧刷日志。诊断至少包含请求/实际物理尺寸、RenderScaling、Content Scale、GlowRadius和降级结果。不公开`GlowQuality`,分辨率控制属于内部资源安全策略。 + +路线B单个控件的Glow派生资源缓存初始总预算固定为`16 MiB`。预算统计所有长期保留的Alpha Mask、Blurred Mask、后端纹理/Image和缓存项专属辅助缓冲,不得只统计托管对象或缓存项数量。该数值是内部安全预算,不公开为开发者属性;原型数据证明不合理时必须保留原始数据并形成书面调整决议。 + +缓存淘汰使用LRU,但当前帧可见的不同字符级Glow Source组成帧内Pin工作集,绘制当前帧时不得被淘汰。每帧先去重并估算工作集字节;工作集超过16 MiB时,对该帧全部Glow Mask统一降低分辨率,禁止同一画面混用无依据的高低质量。达到内部最低Mask比例后仍超限时,跳过无法安全容纳的Glow并继续绘制Active本体,禁止进入每帧反复构建和淘汰的缓存抖动路径。 + +`GlowBrush=null`表示明确关闭,必须立即释放全部Glow缓存并令CachedBytes和EntryCount归零。`GlowOpacity=0`只跳过合成并保留缓存,保证呼吸动画经过零点时不触发重建。 + +Glow不参与Measure。开发者通过Padding预留完整显示空间;光晕允许进入Padding,但Matrix裁剪在Border内缘,Segment裁剪在控件Bounds。绘制层次固定为: + +```text +Background + -> 全部Inactive Geometry + -> 全部Active Geometry的Glow + -> 全部Active Geometry本体 + -> Matrix Border +``` + +## Matrix与Segment的共享边界 + +Matrix与Segment不仅要保持Glow语义一致,还必须共享同一套Glow核心算法。不得在两个控件中分别复制Mask、Blur、着色、缓存和资源释放实现。 + +```text +MatrixDisplay + -> Matrix字符级Active Geometry适配 ─┐ + ├─> LEDGlowRenderer / LEDGlowCache +SegmentDisplay │ + -> Segment活跃段Geometry集合适配 ───┘ +``` + +分层职责固定为: + +| 层 | Matrix | Segment | 是否共享 | +|---|---|---|---| +| 公共StyledProperty | 分别注册`GlowBrush/GlowOpacity/GlowRadius` | 分别注册同名、同类型、同默认值属性 | 共享契约,不共享属性所有者 | +| 基础显示 | 字模映射、点阵布局、点Geometry | 段位映射、Segment布局、段Geometry | 不共享 | +| 输入适配 | 提供聚合后的字符Active Geometry | 提供一个字符的活跃段Geometry集合 | 各自实现薄适配 | +| Glow核心 | Mask、Blur、Brush着色、Opacity合成、Radius和DPI处理 | 使用完全相同实现 | 真实共享 | +| 派生资源 | 字符级Glow缓存 | 字符级Glow缓存 | 共享缓存实现,实例分别拥有 | + +不建立`LEDGlowControl`公共控件基类,不让Matrix通过`SegmentDisplay.GlowBrushProperty.AddOwner`依赖Segment,也不为了Glow合并字符映射、布局、Overflow或基础Geometry缓存。分别注册StyledProperty是为了保持控件所有权边界,不代表允许复制Glow算法。 + +最终内部结构允许类似: + +```text +LED/ + Glow/ + LEDGlowRenderOptions + LEDGlowRenderer + LEDGlowCache + 选定路线的Mask/Blur实现 + Matrix/ + Matrix自己的基础显示和Glow输入适配 + Segment/ + Segment自己的基础显示和Glow输入适配 +``` + +Segment现有同形叠色路径升级为真实空间Glow后必须删除,不保留`LegacyGlowMode`、`UseOldGlow`或两套并行算法。默认`GlowBrush=null`保证未启用Glow的基础视觉不变;已显式启用Glow的Segment获得与Matrix相同的真实外围光晕语义。 + +## 路线A:多层矢量扩张 + +围绕Active Geometry绘制多层不同宽度和透明度的描边,使用离本体越远越透明的层模拟光晕。 + +```text +Active Geometry + -> 宽描边、低透明度 + -> 中描边、中低透明度 + -> 窄描边、较高透明度 + -> Active本体 +``` + +### 优势 + +- 直接消费Avalonia Geometry,不需要栅格化或离屏位图。 +- 可仅使用Avalonia公共矢量绘制API,跨渲染后端和NativeAOT风险较低。 +- 裁剪、Transform和Brush语义与现有Matrix/Segment绘制路径一致。 +- Geometry和Pen可按Radius档位缓存,生命周期容易限定在控件实例或Glow渲染器实例。 +- Headless测试可以检查绘制命令、像素外扩范围和缓存上界。 + +### 劣势 + +- 多层描边是离散近似,不是真正连续高斯模糊;层数不足时可能出现色带。 +- 增加层数会线性增加绘制命令和合成成本。 +- 大Radius或高Opacity下容易呈现粗轮廓,而不是柔和空气光。 +- 描边扩张可能填平Segment尖角,并在Matrix相邻灯珠之间过早连成一片。 +- Geometry为填充区域而非单一路径时,需要确认描边对内孔、组合Geometry和自交路径的行为。 + +### 定位 + +路线A是低风险保底方案,适合较小Radius、克制的工业视觉和无法安全使用离屏模糊的后端。它不能在没有视觉证据时被描述为与真实高斯Glow等价。 + +## 路线B:Alpha Mask加模糊 + +路线B先把Active Geometry栅格化为只记录透明度的遮罩,再模糊遮罩、使用GlowBrush着色并合成到主DrawingContext。 + +```text +Active Geometry + -> 栅格化到透明离屏缓冲区 + -> Alpha Mask + -> Blur(GlowRadius) + -> 乘以GlowBrush和GlowOpacity + -> 合成模糊光晕 + -> 绘制清晰Active本体 +``` + +Alpha Mask只表达发光源覆盖率,不提前写入Glow颜色。因此同一份轮廓可以使用不同GlowBrush着色,Brush和Geometry职责保持分离。 + +```text +原始Mask 模糊后的Mask + + █████ ······· + █████████ ··░░░░░░░░░·· + █████████ ·░░▒▒█████▒▒░░· + █████ ······· +``` + +### 优势 + +- 透明度连续衰减,最接近网页Neon、真实灯珠和柔和空气光。 +- 算法只依赖Alpha轮廓,不关心输入是Circle、Square、RoundedSquare还是十四段Geometry。 +- GlowBrush、GlowOpacity和GlowRadius三项契约都能获得直接、可解释的视觉含义。 +- 合理实现后可由渲染后端加速模糊与合成。 +- 不需要通过公开LayerCount或Quality暴露算法内部细节。 + +### 劣势 + +- 必须管理离屏像素缓冲区。缓冲区大致为`Geometry.Bounds + 四周GlowRadius`,物理像素还要乘以RenderScaling。 +- 内存、栅格化和模糊成本同时受可见面积、DPI和Radius影响;Radius动画可能导致缓冲尺寸或模糊核持续变化。 +- 不能把超长Matrix文本整行渲染到一张巨大位图。必须按可见字模或有限批次处理,并给Matrix字符剔除范围增加Radius外扩。 +- 缓存键至少涉及Geometry身份或版本、Radius、RenderScaling和可能影响Mask的Transform;失效和释放比路线A复杂。 +- Brush变化原则上应复用Alpha Mask,但若底层API把着色与模糊绑定在一起,可能无法做到。 +- Headless、不同平台后端和NativeAOT发布都需要真实验证,不能只依靠桌面Skia肉眼效果。 + +### 必须证明的工程条件 + +- 使用公开且稳定的Avalonia API完成局部Alpha Mask和Blur,或者明确记录所需后端边界。 +- Glow关闭后不创建离屏缓冲、不增加绘制命令或持续分配。 +- 静态Radius稳态帧不反复创建大型位图;缓存有固定上界且旧资源可释放。 +- Matrix按可见字模或有限批次处理,Segment按可见字符/Geometry集合处理。 +- Glow只作用于Active层,Background、Inactive和Border不得进入Mask。 + +### 定位 + +路线B是当前视觉质量首选。只有在公开API、局部缓冲、稳态分配和跨平台验证全部通过后才能成为正式方案;不能只因为效果最好而忽略资源成本。 + +## 路线C:Avalonia Effect或Skia自定义效果 + +路线C优先评估Avalonia现有`BlurEffect`、`DropShadowEffect`等后端效果。Avalonia 12公开`DrawingContext.PushEffect(IEffect, Rect)`,可以把Effect限制在一组Active Geometry绘制命令内,并按Effect输出Padding扩张给定的预膨胀Bounds;该能力必须通过原型证明实际像素隔离。公开Effect仍无法满足时,再评估`ICustomDrawOperation`或Skia自定义绘制。 + +### 优势 + +- 可能直接使用Avalonia渲染后端或GPU完成模糊与合成。 +- 模糊质量和大Radius性能可能优于应用层多次矢量绘制。 +- 若公开Effect能够作用于独立Active视觉层,业务代码可以较少。 + +### 劣势 + +- 直接设置`Visual.Effect`仍会把MatrixDisplay或SegmentDisplay的Background、Inactive、Active和Border一起处理,不符合Glow契约;必须使用绘制作用域隔离Active层。 +- 为隔离Active层而新增子Visual、离屏Visual或模板层,会改变当前自绘控件结构、生命周期和命令组织。 +- Skia自定义路径绑定具体渲染后端,削弱Avalonia跨平台后端边界。 +- 自定义绘制需要处理渲染线程资源、相等性、失效、设备上下文变化和释放,测试与维护成本最高。 +- Headless实现与真实Skia/GPU表现可能不同;NativeAOT和平台发布风险也更高。 + +### 定位 + +Avalonia公开`PushEffect`原型已经证明可在不新增子Visual的情况下产生Geometry外像素;是否成为正式方案仍需通过图层隔离、Brush、裁剪、真实窗口、缓存和完整性能门禁。Skia自定义实现是最后备选,不作为第一版优先路线。 + +## 统一评估矩阵 + +三个原型必须使用相同输入和指标: + +| 维度 | 验收要求 | +|---|---| +| 视觉真实性 | 光晕必须扩散到Geometry外,不能只是同形叠色 | +| 输入覆盖 | Matrix三种点形、Segment典型横段/竖段/斜段/符号 | +| 图层隔离 | Background、Inactive、Border不参与Glow | +| 裁剪 | Glow进入Padding但不越过外壳边界 | +| 布局 | GlowRadius不影响Measure和DesiredSize | +| DPI | 100%、125%、150%、200%下无明显断层或异常裁剪 | +| 动态 | GlowOpacity和GlowRadius变化不破坏Geometry基础缓存 | +| 性能 | 记录首次构建、稳态帧分配、命令数、缓冲区尺寸和Radius动画成本 | +| 缓存 | 历史文本、Radius和DPI变化不导致无界增长,旧资源可回收/释放 | +| 长文本 | Matrix不能创建整行无上限离屏缓冲;可见性剔除包含GlowRadius | +| 发布 | Labs测试、Sample、性能工具和win-x64 NativeAOT通过 | + +## 性能契约与测试强度 + +Glow选型不得凭单次肉眼观察或单次Benchmark结果通过。验证分为PR门禁、专项性能审计和发布前长稳三层;每个Case必须对应明确风险,禁止用重复但无判定价值的测试数量制造虚假覆盖。 + +### 硬性性能契约 + +Glow关闭时: + +- `GlowBrush=null`不得创建Glow Renderer后端资源、Mask、Blur结果或Glow缓存。 +- 不增加Glow绘制命令,不查询Glow缓存,不启动计时器或订阅事件。 +- 稳态托管分配必须与当前无Glow基线相同;相同场景中位耗时不得超过基线`1.05x`。 + +静态Glow预热后: + +- 重复Render的Mask和Blur新增数必须为0。 +- Brush或Opacity变化不得重建Geometry、Mask或Blur结果。 +- 缓存数量不得超过当前实例出现过的不同字符级Active Geometry数量。 +- 重复字符只允许复用同一份字符级Glow资源。 +- 单控件长期保留资源不得超过16 MiB;缓存必须同时报告EntryCount和CachedBytes。 + +动画时: + +- Opacity动画不得重建Geometry、Mask或Blur,缓存数量全程不变。 +- Radius动画允许重建Blur派生资源,但只允许保留当前配置的一代缓存;历史Radius不得累计。 +- 动画停止并强制完整回收后,存活托管内存和后端资源数量不得呈持续增长趋势。 + +缓冲区与长文本: + +- 单个离屏缓冲区只能覆盖单字符或明确有上界的有限批次,物理尺寸按`(CharacterBounds + 2 * GlowRadius) * RenderScaling * ContentScale`核算。 +- 单边不得超过1024物理像素,单Mask不得超过262144物理像素;超限时按统一公式降采样Glow Mask。 +- 禁止按完整长文本宽度创建无上限离屏缓冲。 +- Matrix必须先执行包含GlowRadius外扩的可见字符剔除,再进入Mask、Blur和合成。 + +### 第一层:PR确定性门禁 + +该层进入常规Labs测试,要求快速、可重复,不使用墙钟耗时作为断言。 + +数值边界Case: + +- `GlowOpacity`覆盖`NaN`、正负Infinity、负数、0、接近0、0.35、接近1、1和大于1,至少10组。 +- `GlowRadius`覆盖`NaN`、正负Infinity、负数、0、亚像素值、2、6、12、24、刚超过24和极大有限值,至少12组;验证大于24时内部有效值固定为24。 +- 原始StyledProperty值不得因内部规整被回写。 + +Brush Case: + +- `null`、不透明SolidColorBrush、带Alpha的SolidColorBrush、LinearGradientBrush、RadialGradientBrush、Brush实例替换和DynamicResource更新,至少7组。 +- Brush与Opacity变化必须通过计数器证明Mask/Blur未重建。 + +Matrix像素Case: + +- 3种DotShape × 4种RenderScaling × 4种Radius × 亮点稀疏/密集两类代表字模,共至少96组。 +- 另测无Border、有Border、Padding不足、Padding充足、Clip、ScaleDown、左中右及上中下边界组合。 +- Glow像素必须出现在Active Geometry外部,Background、Inactive和Border像素不得被模糊。 + +Segment像素Case: + +- 横段、竖段、斜段、交汇段、冒号、小数点至少6类 × 4种RenderScaling × 4种Radius,共至少96组。 +- 验证字符级Glow一次处理全部活跃段,不能退化为逐段Blur。 + +缓存与生命周期Case: + +- 重复字模、全支持字模、历史文本、Geometry参数抖动、Radius抖动、RenderScaling切换和Content Scale切换。 +- 每项参数抖动至少1000次;每轮后断言缓存上界和当前代资源数量。 +- LRU验收覆盖命中、淘汰顺序、帧内Pin、工作集统一降采样、最低比例超限和Brush关闭清空。 +- Geometry失效、`GlowBrush=null`、Detach和控件失去引用分别执行WeakReference/显式资源释放验收。 + +### 第二层:专项性能审计 + +使用Labs性能工具独立运行,不放入普通PR单元测试时长预算。三条技术路线必须使用相同输入、相同进程配置和相同预热策略。 + +场景矩阵: + +- 字符数量:6、16、64、256。 +- RenderScaling:100%、125%、150%、200%。 +- GlowRadius:2、6、12、24。 +- Matrix:Circle、Square、RoundedSquare,分别测试仅亮点和亮暗双层。 +- Segment:数字、字母、符号混合,覆盖低活跃段和高活跃段字符。 +- 状态:Glow关闭、静态Glow、Opacity动画、Radius动画、动态文本。 + +每个静态场景至少预热600帧,再测量6000帧。每组Benchmark至少使用5个独立进程;报告Median、P95、Allocated Bytes、Gen0/1/2、MaskBuildCount、BlurBuildCount、绘制命令数、离屏物理像素总量和最终缓存数,不得只报告平均值。 + +初始时间目标: + +- 16字符、200% RenderScaling、Radius=6的静态Glow,真实窗口CPU侧Glow处理P95目标不超过4ms。 +- 64字符、200% RenderScaling、Radius=6的压力场景,整帧P95目标不超过16.67ms。 +- Glow关闭路径相对无Glow基线中位耗时不得超过`1.05x`,分配必须相同。 + +这些是原型选型门槛。若测试环境证明指标不可比或目标不合理,必须保留原始数据、解释测量偏差并形成新的书面决议;不得静默放宽。 + +### 第三层:发布前长稳 + +- 静态Glow:固定6、16、64字符分别运行100000帧,预热后Mask/Blur新增数必须为0。 +- Opacity呼吸:至少36000帧,Mask/Blur新增数必须为0,缓存数量恒定。 +- Radius往返动画:至少36000帧,缓存始终只有当前配置代,旧后端资源及时释放。 +- 动态文本:至少100000次固定长度更新和10000次长度/拓扑变化,缓存不得按历史槽位增长。 +- DPI/Scale切换:100%、125%、150%、200%往返至少1000轮,旧配置资源不得存活累积。 +- 多实例压力:1、10、50个Glow控件分别覆盖关闭、重复字模和不同字模;关闭Glow的50个控件必须保持零Glow缓存,移除控件后实例资源必须释放。 +- 真实窗口连续运行至少30分钟,采集进程工作集、托管堆、Gen2次数、帧时间P95/P99和后端资源计数;内存曲线不得持续单调增长。 +- 长稳结束后停止动画、Detach控件、释放窗口并强制完整回收;控件、Glow缓存和可释放后端资源必须通过生命周期验收。 +- 离屏安全测试覆盖刚低于、等于、刚超过面积上限,单边超限、双边与面积同时超限、极端CharacterHeight、100%/200% DPI以及ScaleDown重新进入安全范围。 +- 连续1000次跨越降采样阈值时,逻辑Glow Bounds保持一致、Active像素不受影响且旧Mask不累积。 + +### 选型失败条件 + +出现任一情况即阻止该路线进入正式实现: + +- Glow关闭路径产生额外Mask、Blur、命令、订阅或持续分配。 +- Opacity动画触发Mask或Blur重建。 +- 缓存随历史文本、Radius、DPI或动画帧数无界增长。 +- Background、Inactive或Border被错误纳入Glow。 +- 依赖AtomUI成型控件包、非公开Avalonia API或未被明确批准的Skia后端耦合。 +- Headless通过但真实窗口出现裁剪、DPI断层、资源泄漏或无法满足性能门槛。 +- NativeAOT新增未解释的动态代码、反射或裁剪告警。 + +## 最终选型决议 + +```text +正式路线:路线C,Avalonia公开Scoped BlurEffect +Effect粒度:每个控件一次 +路线A:仅保留实验基线,不进入正式运行时 +路线B:停止,不进入正式运行时 +Skia自定义:未启动,不进入正式运行时 +``` + +最终实现使用`DrawingContext.PushEffect(BlurEffect, bounds)`隔离全部可见Active Geometry。Matrix与Segment分别完成基础Geometry和可见性剔除,共享同一个内部`LEDGlowRenderer`,每个控件每帧最多建立一个Glow Effect作用域。相邻Active Geometry的Glow允许自然融合,清晰Active本体在Effect作用域退出后重新绘制。 + +路线B章节中的Alpha Mask、LRU、16 MiB派生缓存、单Mask尺寸和降采样公式只记录被评估路线的工程要求,不再是正式路线C的实现契约。正式路线不得为了机械满足路线B要求而创建应用层Mask或Glow缓存。路线C仍必须执行以下资源安全约束:Effect Bounds只来自已剔除的可见Active Geometry并受控件内容视口限制;异常或空Bounds跳过Glow但保留Active本体;Glow关闭不提交Effect命令、不创建后端资源。 + +不实现`PerGeometry`、Batch8或Batch16运行时分支,不增加`GlowQuality`、`GlowRenderMode`或粒度配置。不得根据字符数量、帧率或全局负载自动切换算法或关闭Glow。 + +首轮原型事实和Smoke数据见 [glow-prototype-evaluation.md](glow-prototype-evaluation.md)。 diff --git a/docs/controls/led/matrix-implementation.md b/docs/controls/led/matrix-implementation.md new file mode 100644 index 0000000..9b8f0bd --- /dev/null +++ b/docs/controls/led/matrix-implementation.md @@ -0,0 +1,655 @@ +# LED Matrix 工业级实现原理 + +> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 + +本文记录 `AtomUI.Labs.Controls.LED.Matrix` 的目标实现设计。Matrix 是 LED 家族中的点阵屏路线,不是字体控件,不是 Segment 的升级版,也不是硬件 LED 控制器。 + +第一版公共控件类型固定为 `MatrixDisplay`。第一版只实现单行静态 `5x7` 等宽点阵文本。 + +## 核心链路 + +```text +原始 Text + -> Unicode Rune 遍历和 ASCII 字符规范化 + -> 5x7 字模映射和 fallback + -> 字符格布局测量 + -> 最终空间下的对齐或缩小变换 + -> 查询或生成字模亮暗 Geometry + -> DrawingContext 按字模批量绘制 + -> Avalonia 底层渲染 +``` + +每一层只承担自己的职责: + +- 字符规范化只处理 ASCII 小写转大写。 +- 字模映射只回答一个字符对应哪些点位。 +- 布局只计算字符格原点和整体理想尺寸。 +- 几何工厂把35个点位互斥分流到亮点和暗点 Geometry。 +- 绘制按可见字符提交每字模最多两个 Geometry 命令。 +- Avalonia 负责抗锯齿、DPI、render scale 和底层平台渲染。 + +字符判断、bit 解析、坐标计算和 `DrawingContext` 调用不得混成一组字符专用分支。 + +## 工程目录 + +Matrix 按当前真实职责组织: + +```text +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 +AtomUILabsThemesProvider.axaml + -> LED/Themes/LEDThemes.axaml + -> LED/Matrix/Themes/MatrixThemes.axaml + -> LED/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` | 是否绘制熄灭点位 | + +MVP第一版固定圆点。V2以增量合同加入`MatrixDotShape`和`DotCornerRadiusRatio`;仍不公开Glow、Provider、FontSet、`GlyphWidth`或`GlyphHeight`。 + +后续Glow增量已经落地,新增`GlowBrush`、`GlowOpacity`和`GlowRadius`,默认`GlowBrush=null`,因此不改变基础显示。正式算法、范围和测试结论以[LED Glow技术路线选型](glow-technical-options.md)及[LED Glow原型评估](glow-prototype-evaluation.md)为准;本段中“仍不公开Glow”只描述MVP历史边界,不再代表当前API。 + +`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 资源宿主。 + +真实发布验收使用 Labs Sample 的 Release NativeAOT 配置和专用 `LabsPublishAot` 开关,避免把全局 `PublishAot` 属性传播到 `AtomUI.Generator` Analyzer 项目。`win-x64` NativeAOT 已完成真实 publish;当前剩余 warning 来自 `AtomUI.Core/AppBuilderExtensions.cs` 的既有 Win32 反射配置路径,不来自 Matrix 或 Labs。 + +## 性能基线 + +Matrix 使用独立测量程序: + +```text +tools/performances/AtomUI.Desktop.Controls.Labs.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 成型控件包。 + +## 第一版边界 + +以下列表记录已冻结的MVP第一版。V2只增量加入静态DotShape系统,完整合同见[matrix-static-visual-system.md](matrix-static-visual-system.md)。 + +第一版必须完成: + +- 单行静态 `5x7` 等宽点阵文本。 +- 固定 45 个字模和未知字符 fallback。 +- 固定圆点、亮暗互斥绘制。 +- 可配置点尺寸、点间距、字符间距和 Padding。 +- 背景、圆角、亮点画刷、暗点画刷和暗点开关。 +- 水平/垂直内容对齐。 +- `Clip` 和显式 `ScaleDown`。 +- 自绘、测量、实例级 layout 缓存和自动化支持。 +- Shared Token 主题默认值和 Labs sample。 +- 可选的单向穿屏Marquee,最小契约见[LED Matrix Marquee最小契约](matrix-marquee-minimum-contract.md)。 + +第一版不做: + +- Provider 接口或自定义字模来源。 +- 多套字模规格或任意分辨率配置。 +- 中文、CJK、复杂脚本和独立小写字形。 +- 方点、圆角方点和点形状切换 API。 +- 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、内容对齐和暗点开关。 + +Labs sample 至少展示: + +- 大写字母和小写输入。 +- 数字。 +- `? - . : _ + = /` 全部符号。 +- 未知字符 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..71a7203 --- /dev/null +++ b/docs/controls/led/matrix-marquee-minimum-contract.md @@ -0,0 +1,85 @@ +# LED Matrix Marquee 最小契约 + +> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 + +## 领域边界 + +Marquee与Glow同属LED基础显示之上的可选动态增强领域,但二者是平行模块: + +```text +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时不启动动画。 + +## 内部扩展结构 + +```text +MatrixDisplay + -> LEDMarqueeController:Avalonia Animation生命周期和进度 + -> IMarqueeMotion:无Avalonia绘制依赖的纯运动数学 + -> MarqueeRenderPlan:一帧中一个或多个内容放置位置 + -> Matrix可见字符剔除、Geometry和Render +``` + +首版只有`LeftThroughMarqueeMotion`。内部帧计划从第一版起允许多个放置位置,使后续官方连续首尾模式无需重写Matrix渲染组合流程;首版不向开发者开放策略注入,不使用反射、动态发现、DI或插件注册。 + +动画使用Avalonia Animation驱动内部归一化进度,不使用`DispatcherTimer`,也不按帧累加固定像素。Text、点尺寸、间距、Padding、边框、Bounds、速度或重复间隔变化时取消旧周期并从右侧重新开始。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全量测试、Sample Release、真实Windows窗口长稳和win-x64 NativeAOT。 + +## 首轮实现验证结果 + +- Labs全量测试`405/405`通过,包含公共合同、纯运动数学、非法输入、AXAML、渲染位置、长文本视口剔除、600帧缓存稳定和Controller WeakReference释放。 +- 100与10000字符在相同窄视口和中段进度下提交相同数量级的可见字模命令,不随完整文本长度线性增长。 +- `net8.0`与`net10.0` Release双目标构建通过,0 warning、0 error。 +- Sample Release构建通过;普通Win32产物和NativeAOT产物分别持续运行15秒,均未提前退出,关闭路径无异常。 +- win-x64 NativeAOT发布成功。警告仍来自`AtomUI.Core/AppBuilderExtensions.cs`既有Win32反射配置,不来自Labs或Marquee。 +- 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..31a886d --- /dev/null +++ b/docs/controls/led/matrix-mvp-audit.md @@ -0,0 +1,124 @@ +# Matrix MVP 收口审计 + +> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 + +- 审计日期:2026-07-10 +- 审计对象:`AtomUI.Desktop.Controls.Labs.LED.Matrix.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.Desktop.Controls.Labs.6.0.8.nupkg`。 +- 包目标:`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..58f06bf --- /dev/null +++ b/docs/controls/led/matrix-performance-allocation-and-soak.md @@ -0,0 +1,72 @@ +# Matrix Interaction Performance Baseline + +> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 + +- Date: 2026-07-10 19:08:33 +08:00 +- Configuration: Release, .NET 10 +- Runner: `tools/performances/AtomUI.Desktop.Controls.Labs.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..69d4041 --- /dev/null +++ b/docs/controls/led/matrix-performance-baseline.md @@ -0,0 +1,23 @@ +# Matrix Interaction Performance Baseline + +> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 + +- Date: 2026-07-10 17:22:19 +08:00 +- Configuration: Release, .NET 10 +- Runner: `tools/performances/AtomUI.Desktop.Controls.Labs.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..1f9d652 --- /dev/null +++ b/docs/controls/led/matrix-performance-dynamic-load.md @@ -0,0 +1,35 @@ +# Matrix Interaction Performance Baseline + +> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 + +- Date: 2026-07-10 18:53:37 +08:00 +- Configuration: Release, .NET 10 +- Runner: `tools/performances/AtomUI.Desktop.Controls.Labs.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..188cccb --- /dev/null +++ b/docs/controls/led/matrix-performance-geometry-batch.md @@ -0,0 +1,32 @@ +# Matrix Interaction Performance Baseline + +> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 + +- Date: 2026-07-10 18:21:22 +08:00 +- Configuration: Release, .NET 10 +- Runner: `tools/performances/AtomUI.Desktop.Controls.Labs.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..2194051 --- /dev/null +++ b/docs/controls/led/matrix-static-visual-system.md @@ -0,0 +1,166 @@ +# Matrix V2 静态视觉系统 + +> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 + +本文定义`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..8094efc --- /dev/null +++ b/docs/controls/led/overview.md @@ -0,0 +1,219 @@ +# LED 控件家族设计 + +> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 + +本文记录 `AtomUI.Labs.Controls.LED` 的组件域设计。LED 是 Labs 下的实验控件家族名,不是单一控件名。 + +## 文档导航 + +- Segment:[实现原理](segment-implementation.md)、[性能回归矩阵](segment-performance-regression.md)。 +- Matrix:[实现原理](matrix-implementation.md)、[静态视觉系统](matrix-static-visual-system.md)、[Marquee 最小契约](matrix-marquee-minimum-contract.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)。 +- 家族增强与边界:[公共边界审计](family-common-boundary-audit.md)、[Glow 技术选型](glow-technical-options.md)、[Glow 原型评估](glow-prototype-evaluation.md)。 + +## 定位 + +`AtomUI.Labs.Controls.LED` 用于承载 LED 风格显示控件。它不表示硬件 LED 控制器,也不表示普通文本控件。 + +LED 家族目标包含两条并列路线: + +```text +AtomUI.Labs.Controls.LED + Segment 十四段数码管路线 + Matrix 点阵屏路线 +``` + +`Segment` 和 `Matrix` 不是超集关系,也不是升级关系。它们分别面向不同的显示模型: + +- `Segment` 以“段”为最小视觉单元,适合数字、英文字母、仪表读数和电子设备面板风格。 +- `Matrix` 以“点阵像素”为最小视觉单元,适合字符屏、公告屏、滚动文字和更自由的符号表达。 + +共享基础代码直接放在 `LED/` 根目录下,当前包括`LEDCharacterNormalizer`和`LEDDisplayLayoutMath`,不创建`Primitives`、`Shared`或`Internal`等独立目录。 + +LED 家族主题必须采用聚合入口: + +```text +LED/Themes/LEDThemes.axaml + -> LED/Segment/Themes/SegmentThemes.axaml + -> LED/Matrix/Themes/MatrixThemes.axaml +``` + +包级 `AtomUILabsThemesProvider.axaml` 只引用 `LED/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 家族内部基础代码,但第一版不可为了架构感过度抽象。共享代码不需要提前放入固定目录,可以先直接放在 `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` 合并为一个万能控件。 +- 提前固定 LED 家族公共代码目录名。 + +## 实现验证 + +实现阶段至少需要验证: + +- Labs 项目构建通过。 +- Labs Sample 构建通过。 +- 搜索确认 LED 家族没有引用 AtomUI 成型控件包。 +- Sample 能展示 Segment 和 Matrix 的基础视觉。 +- `git diff --check` 通过。 + +## 相关设计 + +- [LED 家族公共边界审计](family-common-boundary-audit.md):记录 Segment 与 Matrix 之间已验证的共享边界。 +- [LED Glow 技术路线选型](glow-technical-options.md):记录多层矢量扩张、Alpha Mask模糊和Avalonia/Skia Effect三条候选路线。 +- [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..050f403 --- /dev/null +++ b/docs/controls/led/segment-implementation.md @@ -0,0 +1,614 @@ +# LED Segment 工业级实现原理 + +> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 + +> 当前实现已将早期同形叠色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 +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 +AtomUILabsThemesProvider.axaml + -> LED/Themes/LEDThemes.axaml + -> LED/Segment/Themes/SegmentThemes.axaml + -> LED/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 崩溃。 + +## 布局测量 + +映射决定“哪些段亮”,布局测量决定“每个字符放哪里、多大”。 + +布局输入: + +- 规范化后的字符 pattern 列表。 +- 字符期望高度或最终可用尺寸。 +- 字符宽高比。 +- 字符间距。 +- 符号宽度规则。 +- Padding。 + +`CharacterHeight` 表示期望字符高度,用于 `MeasureOverride` 计算理想尺寸。实际 `Render` 阶段会根据最终 `Bounds.Height` 重新计算 layout;如果父容器给了更高或更低的最终高度,最终绘制高度以 arranged bounds 为准。因此 `CharacterHeight` 不是强制绘制高度,而是参与测量的期望值。 + +段厚度和段间隙不参与理想尺寸计算。它们只影响字符格内部的几何形状,所以只应触发重绘,不应触发布局测量。 + +父容器给的最终空间可能大于或小于理想尺寸。工业级控件不能假设父容器一定尊重 `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 + -> 根据 Bounds.Size 计算实际 layout + -> 根据 OverflowMode 计算绘制缩放 + -> 根据 HorizontalContentAlignment / VerticalContentAlignment 计算偏移 + -> PushClip 到 Bounds + -> PushTransform 应用缩放和偏移 + -> 为每个 slot 生成或读取 geometry + -> 根据 pattern 绘制暗段和亮段 +``` + +`ArrangeOverride` 不是主要几何生成入口。Segment 是自绘控件,通常没有子控件需要 arrange。`ArrangeOverride` 最多用于记录最终尺寸或标记缓存失效: + +最终空间中的ScaleDown比例和内容对齐偏移由LED家族根目录的`LEDDisplayLayoutMath`计算。Segment仍自行决定何时启用ScaleDown,并保留最终Bounds参与字符高度布局的路线专属语义。 + +```csharp +protected override Size ArrangeOverride(Size finalSize) +{ + if (_lastArrangeSize != finalSize) + { + _lastArrangeSize = finalSize; + InvalidateGeometryCache(); + } + + return finalSize; +} +``` + +不要把几何生成主要塞进 `ArrangeOverride`。几何不仅依赖最终尺寸,也依赖段厚度、间隙、几何风格和字符 slot。Avalonia 可能因为视觉失效重新 `Render`,但不一定重新 `Arrange`。 + +## 几何生成 + +逻辑段不是图形。几何生成负责把逻辑段转换为 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 轮廓。没有暗段时,控件更像普通矢量图形,不像真实设备面板。 + +第一版不做真实 blur 发光。当前 Glow 是一个可选半透明预绘制层: + +- `GlowBrush = null` 时完全关闭,这是默认状态。 +- `GlowOpacity` 默认 `0.35`,但只有 `GlowBrush` 存在时才生效。 +- Glow 使用同一份 cached `Geometry`,不会因为开启 Glow 生成另一套几何。 +- `GlowBrush` 和 `GlowOpacity` 只影响绘制,不进入 geometry cache key。 + +这不是最终真实 LED 光晕模型,只是最低风险的视觉层次能力。后续如果做 blur、外扩光晕或材质效果,必须重新审查性能、缓存和边界测试。 + +当前第一版已经落地的绘制语义: + +- 背景如果存在,先绘制背景。 +- `ActiveBrush` 为 `null` 时,只绘制背景,不绘制亮段、暗段、冒号或小数点。 +- 普通十四段字符在 `ShowInactiveSegments = true` 且 `InactiveBrush` 不为空时,先绘制完整 14 个暗段,再绘制当前字符的亮段。 +- `ShowInactiveSegments = false` 时,普通十四段字符只绘制当前字符的亮段。 +- 冒号和小数点不走 14 段暗段逻辑,只绘制对应点位。 +- 冒号和小数点使用 cached `Geometry` 绘制,不再直接 `DrawEllipse`。 +- `GlowBrush` 存在且 `GlowOpacity > 0` 时,亮段和符号点位会先绘制一层 Glow,再绘制亮段主体。 + +这些语义已经通过 `SegmentDisplayRenderTests` 固定。 + +## 数值规整策略 + +Segment 第一版采用“显示控件不因非法输入崩溃”的策略。所有用户输入的数值型属性在进入布局、几何或绘制前都会规整。 + +当前内部由 `SegmentValueSanitizer` 统一处理: + +- `NaN` 视为最小值。 +- `Infinity` 视为最小值或被 clamp 到范围内。 +- 负数按 0 处理,带最小值的属性按最小值处理。 +- `Padding` 的四个方向分别规整为非负有限数。 +- `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`、最终 `Bounds.Size`。 +- 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`、冒号、小数点、负号、空格。 +- 小写输入转大写。 +- 自绘。 +- 可测量。 +- 可缩放。 +- 可对齐。 +- 小空间下可裁剪或显式等比缩小。 +- 可主题化。 +- 基础段形态配置。 +- 冒号和小数点统一几何缓存。 +- 最小 Glow 绘制层。 + +第一版不做: + +- 普通字体模拟 LED。 +- 点阵显示。 +- 滚动字幕。 +- 内置闪烁和复杂动画。 +- 真实 blur 光晕和复杂材质。 +- 多行文本。 +- 中文和复杂脚本。 +- 富文本。 +- 硬件 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 不抛异常、暗段/亮段绘制数量、冒号/小数点绘制语义、`ActiveBrush = null` 语义、Glow 绘制语义、同 topology 文本更新复用几何、topology 或几何参数变化刷新几何、Glow 参数变化不刷新几何、内容对齐或溢出策略变化不刷新几何、`ScaleDown` 产生绘制变换。 +- `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)。Segment当前同形叠色Glow属于历史现状,不代表选型已经完成。 diff --git a/docs/controls/led/segment-performance-regression.md b/docs/controls/led/segment-performance-regression.md new file mode 100644 index 0000000..024f82a --- /dev/null +++ b/docs/controls/led/segment-performance-regression.md @@ -0,0 +1,48 @@ +# LED Segment 性能回归矩阵 + +> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 + +本文定义 `SegmentDisplay` 高频更新和缓存行为的长期回归边界。指标使用确定性的 layout/geometry 重建次数,不使用容易受机器负载影响的单元测试耗时阈值。 + +## 场景资格 + +- Labs sample 同时展示超过 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..e222733 --- /dev/null +++ b/docs/controls/overview.md @@ -0,0 +1,9 @@ +# 实验控件文档 + +本目录记录 AtomUI Labs 中各实验控件的长期设计、实现约束和验证资料。每个控件或控件家族使用独立子目录,入口文件统一命名为 `overview.md`。 + +文档可以先于源码进入本仓库,但必须明确区分目标设计、当前实现和历史验证结果。控件落地后,文档中的包名、公开 API、测试和 Gallery 验收应与源码同步维护。 + +## 控件目录 + +- [LED 控件家族](led/overview.md):十四段 Segment、5x7 Matrix、Glow 和 Marquee 的设计与参考实现资料。 From c97f1d92448682d15c4be42d6f7835436e90ae6c Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 13:41:45 +0800 Subject: [PATCH 02/33] update architecture docs --- README.md | 2 ++ README.zh-CN.md | 2 ++ docs/engineering/overview.md | 3 +++ docs/global-engineering-guidelines.md | 4 ++++ 4 files changed, 11 insertions(+) diff --git a/README.md b/README.md index b9b0214..585d67d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,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..c90c64e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -45,6 +45,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/docs/engineering/overview.md b/docs/engineering/overview.md index 623f670..06107f6 100644 --- a/docs/engineering/overview.md +++ b/docs/engineering/overview.md @@ -17,9 +17,12 @@ tests/.Tests/ controlgallery/AtomUILabsGallery/ controlgallery/AtomUILabsGallery.Desktop/ docs/architecture/ +docs/controls// docs/engineering/ ``` +长期维护的控件设计、实现约束和验证资料放在 `docs/controls//`,并以 `overview.md` 作为专题入口。文档先于源码迁入时,必须明确标注目标设计与历史参考实现的边界。 + 新增控件时,优先使用包名和项目名: ```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) From cad1353a2fc95929bc4c291ab57ef749b713dd34 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 15:14:38 +0800 Subject: [PATCH 03/33] add Led controls docs --- .../led/family-common-boundary-audit.md | 12 +++--- .../controls/led/glow-prototype-evaluation.md | 22 +++++------ docs/controls/led/glow-technical-options.md | 18 ++++----- docs/controls/led/matrix-implementation.md | 18 ++++----- .../led/matrix-marquee-minimum-contract.md | 6 +-- docs/controls/led/matrix-mvp-audit.md | 8 ++-- .../matrix-performance-allocation-and-soak.md | 4 +- .../led/matrix-performance-baseline.md | 4 +- .../led/matrix-performance-dynamic-load.md | 4 +- .../led/matrix-performance-geometry-batch.md | 4 +- .../led/matrix-static-visual-system.md | 2 +- docs/controls/led/overview.md | 35 ++++++++--------- docs/controls/led/segment-implementation.md | 18 ++++----- .../led/segment-performance-regression.md | 2 +- docs/controls/overview.md | 2 +- src/AtomUI.Labs.Led/README.nuget.md | 38 +++++++++++++++++++ 16 files changed, 118 insertions(+), 79 deletions(-) create mode 100644 src/AtomUI.Labs.Led/README.nuget.md diff --git a/docs/controls/led/family-common-boundary-audit.md b/docs/controls/led/family-common-boundary-audit.md index 81bd4b2..89e135f 100644 --- a/docs/controls/led/family-common-boundary-audit.md +++ b/docs/controls/led/family-common-boundary-audit.md @@ -1,9 +1,9 @@ # LED 家族公共边界审计 -> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 +> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 - 审计日期:2026-07-10 -- 审计对象:`LED.Segment` 与 `LED.Matrix` +- 审计对象:`Led.Segment` 与 `Led.Matrix` - 目标:只提取已经由两套稳定实现证明语义相同的内部基础代码 - 公共 API 变化:无 @@ -12,10 +12,10 @@ 当前 LED 家族共享两项内部基础设施: ```text -LEDCharacterNormalizer +LedCharacterNormalizer ASCII小写 -> 大写 -LEDDisplayLayoutMath +LedDisplayLayoutMath 理想尺寸 + 最终Bounds -> ScaleDown比例 + 内容对齐偏移 ``` @@ -23,7 +23,7 @@ LEDDisplayLayoutMath ## 新增共享边界 -`LEDDisplayLayoutMath`直接位于`LED/`根目录,不新增`Primitives`、`Shared`或`Internal`目录。 +`LedDisplayLayoutMath` 直接位于 `src/AtomUI.Labs.Led/` 根目录,不新增 `Primitives`、`Shared` 或 `Internal` 目录。 它只包含: @@ -69,7 +69,7 @@ Matrix继续拥有: ## 验证结果 -- `LEDDisplayLayoutMathTests`:16/16通过。 +- `LedDisplayLayoutMathTests`:16/16通过。 - 布局数学、Segment Render、Matrix Render/Pixel相关测试:92/92通过。 - Labs全量测试:275/275通过,Release net10.0。 - Labs Sample Debug/Release:均为0 warning、0 error。 diff --git a/docs/controls/led/glow-prototype-evaluation.md b/docs/controls/led/glow-prototype-evaluation.md index a1a1e26..10b2a9c 100644 --- a/docs/controls/led/glow-prototype-evaluation.md +++ b/docs/controls/led/glow-prototype-evaluation.md @@ -1,10 +1,10 @@ # LED Glow 首轮原型评估 -> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 +> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 ## 评估状态 -本报告记录2026-07-11的首轮可行性Smoke,不是最终选型或正式性能结论。原型只存在于`AtomUI.Desktop.Controls.Labs.Performance`工具,不进入Labs运行时程序集。 +本报告记录2026-07-11的首轮可行性Smoke,不是最终选型或正式性能结论。原型只存在于`AtomUI.Labs.Led.Performance`工具,不进入Labs运行时程序集。 ## Avalonia公开API审计 @@ -136,7 +136,7 @@ Scoped Blur行为Gate: ```text tools/performances/ - AtomUI.Desktop.Controls.Labs.GlowPrototype.Desktop/ + AtomUI.Labs.Led.GlowPrototype.Desktop/ ``` 该项目引用Performance试验程序集并通过friend assembly复用同一份路线A与Scoped Blur Renderer,不复制算法,也不引用或修改Labs运行时Glow实现。它并排展示: @@ -150,12 +150,12 @@ tools/performances/ 运行: ```powershell -dotnet run --project tools\performances\AtomUI.Desktop.Controls.Labs.GlowPrototype.Desktop\AtomUI.Desktop.Controls.Labs.GlowPrototype.Desktop.csproj -c Release +dotnet run --project tools\performances\AtomUI.Labs.Led.GlowPrototype.Desktop\AtomUI.Labs.Led.GlowPrototype.Desktop.csproj -c Release ``` Release构建为0警告、0错误。短时真实Win32进程Smoke保持运行5秒且未提前退出;该结果只证明桌面生命周期和窗口建立成功,不代表人工视觉验收或真实GPU性能已经通过。 -桌面原型代码不进入NuGet包。最终选型后,失败路线和只服务对比的桌面原型应删除;有长期价值的性能Case改为直接测试正式LEDGlowRenderer和真实Matrix/Segment。 +桌面原型代码不进入NuGet包。最终选型后,失败路线和只服务对比的桌面原型应删除;有长期价值的性能Case改为直接测试正式LedGlowRenderer和真实Matrix/Segment。 ## 第三轮:真实窗口Render回调压力Gate @@ -166,7 +166,7 @@ Release构建为0警告、0错误。短时真实Win32进程Smoke保持运行5秒 运行示例: ```powershell -dotnet run --project tools/performances/AtomUI.Desktop.Controls.Labs.GlowPrototype.Desktop/AtomUI.Desktop.Controls.Labs.GlowPrototype.Desktop.csproj -c Release --no-build -- --benchmark --route scoped --instances 64 --warmup 30 --ticks 120 +dotnet run --project tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/AtomUI.Labs.Led.GlowPrototype.Desktop.csproj -c Release --no-build -- --benchmark --route scoped --instances 64 --warmup 30 --ticks 120 ``` ### 五个独立进程 @@ -354,11 +354,11 @@ PerControl的P95比最佳Batch16高约1.4%,没有达到预先约定的15%改 - 正式Renderer先执行包含GlowRadius的可见性剔除,再在一个Effect作用域内绘制当前控件全部可见Active Geometry。 - 相邻Geometry的Glow允许自然融合;退出Effect后重新绘制清晰Active本体。 - `PerGeometry`、`Batch8`和`Batch16`不进入正式运行时,不增加粒度公开属性,也不根据运行负载动态切换算法。 -- 该决议结束技术路线与Effect粒度游移。下一阶段直接设计共享`LEDGlowRenderer`并接入Matrix/Segment。 +- 该决议结束技术路线与Effect粒度游移。下一阶段直接设计共享`LedGlowRenderer`并接入Matrix/Segment。 ## 第七轮:正式控件接入 -Scoped Blur与PerControl粒度已进入`AtomUI.Desktop.Controls.Labs`正式运行时。共享实现位于`LED/Glow`,Matrix和Segment不复制Effect创建、数值规整或作用域释放逻辑。 +Scoped Blur 与 PerControl 粒度已进入 `AtomUI.Labs.Led` 正式运行时。共享实现位于 `Glow/`,Matrix 和 Segment 不复制 Effect 创建、数值规整或作用域释放逻辑。 正式公共契约: @@ -375,7 +375,7 @@ Scoped Blur与PerControl粒度已进入`AtomUI.Desktop.Controls.Labs`正式运 正式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引用。 +关闭路径采用惰性Renderer:控件构造和`GlowBrush=null`稳态不创建`LedGlowRenderer`或`BlurEffect`,不提交Effect作用域。首次有效Glow创建一个BlurEffect;Brush、Opacity和Radius变化复用同一实例。`GlowBrush`清回null时释放Renderer及其BlurEffect引用。 正式测试从334项增加到371项,新增覆盖: @@ -431,7 +431,7 @@ Labs Sample增加Matrix默认关闭、Radius 6/12/24和多色Glow案例,以及 运行方式: ```powershell -dotnet run --project tools/performances/AtomUI.Desktop.Controls.Labs.Performance/AtomUI.Desktop.Controls.Labs.Performance.csproj -c Release --no-build -- --formal-glow --frames 6000 --markdown output/formal-glow.md +dotnet run --project tools/performances/AtomUI.Labs.Led.Performance/AtomUI.Labs.Led.Performance.csproj -c Release --no-build -- --formal-glow --frames 6000 --markdown output/formal-glow.md ``` ## 第九轮:正式控件真实Win32窗口门禁 @@ -480,7 +480,7 @@ Segment在10实例60Hz时,NoGlow、Static和DynamicText均处于约62%同一 运行示例: ```powershell -dotnet run --project tools/performances/AtomUI.Desktop.Controls.Labs.GlowPrototype.Desktop/AtomUI.Desktop.Controls.Labs.GlowPrototype.Desktop.csproj -c Release --no-build -- --formal-controls --control matrix --mode opacity --instances 10 --hz 60 --warmup 30 --ticks 120 +dotnet run --project tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/AtomUI.Labs.Led.GlowPrototype.Desktop.csproj -c Release --no-build -- --formal-controls --control matrix --mode opacity --instances 10 --hz 60 --warmup 30 --ticks 120 ``` ## 第十轮:Segment基础Geometry命令聚合 diff --git a/docs/controls/led/glow-technical-options.md b/docs/controls/led/glow-technical-options.md index 30b5ac2..317576c 100644 --- a/docs/controls/led/glow-technical-options.md +++ b/docs/controls/led/glow-technical-options.md @@ -1,8 +1,8 @@ # LED Glow 技术路线选型 -> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 +> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 -本文记录 `AtomUI.Labs.Controls.LED` 家族真实静态 Glow 的技术路线。候选路线、评估过程和迁移前参考实现的最终决议均保留在本文中;当前 Labs 仓库尚未实现对应运行时代码。 +本文记录 `AtomUI.Labs.Led` 家族真实静态 Glow 的技术路线。候选路线、评估过程和迁移前参考实现的最终决议均保留在本文中;对应运行时代码现已迁入本仓库。 Glow是Matrix与Segment之上的可选视觉增强层。两个基础控件在没有Glow时必须保持完整、可生产使用。Glow只接收已经生成的Active Geometry,不读取字符、字模、点阵行列或SegmentParts。 @@ -81,7 +81,7 @@ Matrix与Segment不仅要保持Glow语义一致,还必须共享同一套Glow ```text MatrixDisplay -> Matrix字符级Active Geometry适配 ─┐ - ├─> LEDGlowRenderer / LEDGlowCache + ├─> LedGlowRenderer / LedGlowCache SegmentDisplay │ -> Segment活跃段Geometry集合适配 ───┘ ``` @@ -96,16 +96,16 @@ SegmentDisplay │ | Glow核心 | Mask、Blur、Brush着色、Opacity合成、Radius和DPI处理 | 使用完全相同实现 | 真实共享 | | 派生资源 | 字符级Glow缓存 | 字符级Glow缓存 | 共享缓存实现,实例分别拥有 | -不建立`LEDGlowControl`公共控件基类,不让Matrix通过`SegmentDisplay.GlowBrushProperty.AddOwner`依赖Segment,也不为了Glow合并字符映射、布局、Overflow或基础Geometry缓存。分别注册StyledProperty是为了保持控件所有权边界,不代表允许复制Glow算法。 +不建立 `LedGlowControl` 公共控件基类,不让 Matrix 通过 `SegmentDisplay.GlowBrushProperty.AddOwner` 依赖 Segment,也不为了 Glow 合并字符映射、布局、Overflow 或基础 Geometry 缓存。分别注册 StyledProperty 是为了保持控件所有权边界,不代表允许复制 Glow 算法。 最终内部结构允许类似: ```text -LED/ +src/AtomUI.Labs.Led/ Glow/ - LEDGlowRenderOptions - LEDGlowRenderer - LEDGlowCache + LedGlowRenderOptions + LedGlowRenderer + LedGlowCache 选定路线的Mask/Blur实现 Matrix/ Matrix自己的基础显示和Glow输入适配 @@ -365,7 +365,7 @@ Effect粒度:每个控件一次 Skia自定义:未启动,不进入正式运行时 ``` -最终实现使用`DrawingContext.PushEffect(BlurEffect, bounds)`隔离全部可见Active Geometry。Matrix与Segment分别完成基础Geometry和可见性剔除,共享同一个内部`LEDGlowRenderer`,每个控件每帧最多建立一个Glow Effect作用域。相邻Active Geometry的Glow允许自然融合,清晰Active本体在Effect作用域退出后重新绘制。 +最终实现使用`DrawingContext.PushEffect(BlurEffect, bounds)`隔离全部可见Active Geometry。Matrix与Segment分别完成基础Geometry和可见性剔除,共享同一个内部`LedGlowRenderer`,每个控件每帧最多建立一个Glow Effect作用域。相邻Active Geometry的Glow允许自然融合,清晰Active本体在Effect作用域退出后重新绘制。 路线B章节中的Alpha Mask、LRU、16 MiB派生缓存、单Mask尺寸和降采样公式只记录被评估路线的工程要求,不再是正式路线C的实现契约。正式路线不得为了机械满足路线B要求而创建应用层Mask或Glow缓存。路线C仍必须执行以下资源安全约束:Effect Bounds只来自已剔除的可见Active Geometry并受控件内容视口限制;异常或空Bounds跳过Glow但保留Active本体;Glow关闭不提交Effect命令、不创建后端资源。 diff --git a/docs/controls/led/matrix-implementation.md b/docs/controls/led/matrix-implementation.md index 9b8f0bd..9a2d421 100644 --- a/docs/controls/led/matrix-implementation.md +++ b/docs/controls/led/matrix-implementation.md @@ -1,8 +1,8 @@ # LED Matrix 工业级实现原理 -> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 +> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 -本文记录 `AtomUI.Labs.Controls.LED.Matrix` 的目标实现设计。Matrix 是 LED 家族中的点阵屏路线,不是字体控件,不是 Segment 的升级版,也不是硬件 LED 控制器。 +本文记录 `AtomUI.Labs.Led.Matrix` 的目标实现设计。Matrix 是 LED 家族中的点阵屏路线,不是字体控件,不是 Segment 的升级版,也不是硬件 LED 控制器。 第一版公共控件类型固定为 `MatrixDisplay`。第一版只实现单行静态 `5x7` 等宽点阵文本。 @@ -35,7 +35,7 @@ Matrix 按当前真实职责组织: ```text -LED/Matrix/ +src/AtomUI.Labs.Led/Matrix/ MatrixDisplay.cs MatrixDisplayAutomationPeer.cs MatrixDotShape.cs @@ -67,10 +67,10 @@ LED/Matrix/ 主题聚合链固定为: ```text -AtomUILabsThemesProvider.axaml - -> LED/Themes/LEDThemes.axaml - -> LED/Matrix/Themes/MatrixThemes.axaml - -> LED/Matrix/Themes/MatrixDisplayTheme.axaml +LedThemesProvider.axaml + -> Themes/LedThemes.axaml + -> Matrix/Themes/MatrixThemes.axaml + -> Matrix/Themes/MatrixDisplayTheme.axaml ``` 第一版不创建 Provider、FontSet、Primitives 或 Shared 目录。`Rendering/` 是性能基线证明逐点命令提交成本后形成的真实职责边界,不是为了机械复制 Segment。 @@ -354,7 +354,7 @@ Render 不能默认偷偷缩小,也不能让绘制越过控件 Bounds 污染相邻视觉。 -ScaleDown比例和内容对齐偏移由LED家族根目录的`LEDDisplayLayoutMath`计算。Matrix仍自行判断`MatrixOverflowMode`,并保留可见视口逆变换和字符剔除逻辑;共享工具不参与layout、Geometry或Render命令提交。 +ScaleDown比例和内容对齐偏移由LED家族根目录的`LedDisplayLayoutMath`计算。Matrix仍自行判断`MatrixOverflowMode`,并保留可见视口逆变换和字符剔除逻辑;共享工具不参与layout、Geometry或Render命令提交。 面板边框采用`Border + Padding + Glyph` Box Model。Measure在原内容DesiredSize外增加四边有效BorderThickness。Render按Background、内侧内容视口、Border顺序提交;内容对齐、裁剪和ScaleDown以Border内侧视口为边界,边框自身保持DIP厚度。Background铺满外框,因此半透明BorderBrush会与背景混色。 @@ -554,7 +554,7 @@ Labs 程序集通过 `https://atomui.net/labs` XML 命名空间公开 `MatrixDis Matrix 使用独立测量程序: ```text -tools/performances/AtomUI.Desktop.Controls.Labs.Performance +tools/performances/AtomUI.Labs.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 或平台呈现成本,也不把机器相关毫秒数作为单元测试阈值。 diff --git a/docs/controls/led/matrix-marquee-minimum-contract.md b/docs/controls/led/matrix-marquee-minimum-contract.md index 71a7203..9760076 100644 --- a/docs/controls/led/matrix-marquee-minimum-contract.md +++ b/docs/controls/led/matrix-marquee-minimum-contract.md @@ -1,13 +1,13 @@ # LED Matrix Marquee 最小契约 -> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 +> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 ## 领域边界 Marquee与Glow同属LED基础显示之上的可选动态增强领域,但二者是平行模块: ```text -LED/ +src/AtomUI.Labs.Led/ ├── Matrix/ MatrixDisplay、字符、布局和Geometry ├── Segment/ SegmentDisplay及十四段基础实现 ├── Glow/ 可见Active Geometry的光效增强 @@ -46,7 +46,7 @@ Matrix静态显示在Marquee关闭时必须独立、完整可用。关闭路径 ```text MatrixDisplay - -> LEDMarqueeController:Avalonia Animation生命周期和进度 + -> LedMarqueeController:Avalonia Animation生命周期和进度 -> IMarqueeMotion:无Avalonia绘制依赖的纯运动数学 -> MarqueeRenderPlan:一帧中一个或多个内容放置位置 -> Matrix可见字符剔除、Geometry和Render diff --git a/docs/controls/led/matrix-mvp-audit.md b/docs/controls/led/matrix-mvp-audit.md index 31a886d..5cb0915 100644 --- a/docs/controls/led/matrix-mvp-audit.md +++ b/docs/controls/led/matrix-mvp-audit.md @@ -1,9 +1,9 @@ # Matrix MVP 收口审计 -> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 +> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 - 审计日期:2026-07-10 -- 审计对象:`AtomUI.Desktop.Controls.Labs.LED.Matrix.MatrixDisplay` +- 审计对象:`AtomUI.Labs.Led.Matrix.MatrixDisplay` - 审计类型:MVP 交付收口,不进行行为修改或性能优化 - 结论:未发现高严重度运行缺陷;审计发现已于同日修复并进入回归验证 @@ -29,7 +29,7 @@ - 项目结构树只展开 Segment,没有展开当前 Matrix 的 Character、Layout、Rendering 和 Themes 结构。 - LED 家族章节仍写“未来 Matrix 应与 Segment 同级”,与当前源码不符。 -- 主题聚合链只写到 Segment,没有记录 `LEDThemes.axaml -> MatrixThemes.axaml -> MatrixDisplayTheme.axaml`。 +- 主题聚合链只写到 Segment,没有记录 `LedThemes.axaml -> MatrixThemes.axaml -> MatrixDisplayTheme.axaml`。 - Namespace 策略仍把 `https://atomui.net/labs` 写成待评估选项,但程序集已经正式使用该 namespace。 - 模块概览仍写“第一阶段只放入 Dashboard”,不能反映当前 Dashboard、Segment、Matrix 三个入口。 - Token 章节包含 Matrix 落地前的后续假设,不符合当前状态文档应只描述已实现事实的要求。 @@ -90,7 +90,7 @@ Matrix 对 `DotSize`、间距和 Padding 有明确数值规整,但 `CornerRadi - Matrix 专项测试:114/114 通过,Release net10.0。 - Labs 全量测试:259/259 通过,Release net10.0。 -- Labs NuGet pack:成功生成 `AtomUI.Desktop.Controls.Labs.6.0.8.nupkg`。 +- 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。 diff --git a/docs/controls/led/matrix-performance-allocation-and-soak.md b/docs/controls/led/matrix-performance-allocation-and-soak.md index 58f06bf..03cd344 100644 --- a/docs/controls/led/matrix-performance-allocation-and-soak.md +++ b/docs/controls/led/matrix-performance-allocation-and-soak.md @@ -1,10 +1,10 @@ # Matrix Interaction Performance Baseline -> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 +> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 - Date: 2026-07-10 19:08:33 +08:00 - Configuration: Release, .NET 10 -- Runner: `tools/performances/AtomUI.Desktop.Controls.Labs.Performance --count --frames --soak-frames ` +- Runner: `tools/performances/AtomUI.Labs.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 | diff --git a/docs/controls/led/matrix-performance-baseline.md b/docs/controls/led/matrix-performance-baseline.md index 69d4041..d09a55c 100644 --- a/docs/controls/led/matrix-performance-baseline.md +++ b/docs/controls/led/matrix-performance-baseline.md @@ -1,10 +1,10 @@ # Matrix Interaction Performance Baseline -> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 +> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 - Date: 2026-07-10 17:22:19 +08:00 - Configuration: Release, .NET 10 -- Runner: `tools/performances/AtomUI.Desktop.Controls.Labs.Performance --count ` +- Runner: `tools/performances/AtomUI.Labs.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 | diff --git a/docs/controls/led/matrix-performance-dynamic-load.md b/docs/controls/led/matrix-performance-dynamic-load.md index 1f9d652..427d46a 100644 --- a/docs/controls/led/matrix-performance-dynamic-load.md +++ b/docs/controls/led/matrix-performance-dynamic-load.md @@ -1,10 +1,10 @@ # Matrix Interaction Performance Baseline -> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 +> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 - Date: 2026-07-10 18:53:37 +08:00 - Configuration: Release, .NET 10 -- Runner: `tools/performances/AtomUI.Desktop.Controls.Labs.Performance --count --frames ` +- Runner: `tools/performances/AtomUI.Labs.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 | diff --git a/docs/controls/led/matrix-performance-geometry-batch.md b/docs/controls/led/matrix-performance-geometry-batch.md index 188cccb..56ec38a 100644 --- a/docs/controls/led/matrix-performance-geometry-batch.md +++ b/docs/controls/led/matrix-performance-geometry-batch.md @@ -1,10 +1,10 @@ # Matrix Interaction Performance Baseline -> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 +> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 - Date: 2026-07-10 18:21:22 +08:00 - Configuration: Release, .NET 10 -- Runner: `tools/performances/AtomUI.Desktop.Controls.Labs.Performance --count ` +- Runner: `tools/performances/AtomUI.Labs.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 | diff --git a/docs/controls/led/matrix-static-visual-system.md b/docs/controls/led/matrix-static-visual-system.md index 2194051..882b740 100644 --- a/docs/controls/led/matrix-static-visual-system.md +++ b/docs/controls/led/matrix-static-visual-system.md @@ -1,6 +1,6 @@ # Matrix V2 静态视觉系统 -> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 +> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 本文定义`MatrixDisplay`第二阶段的静态点轮廓系统。它只改变每个点的外轮廓,不改变字模、点位坐标、测量尺寸、颜色合同、溢出策略或动态文本行为。 diff --git a/docs/controls/led/overview.md b/docs/controls/led/overview.md index 8094efc..6209493 100644 --- a/docs/controls/led/overview.md +++ b/docs/controls/led/overview.md @@ -1,8 +1,8 @@ # LED 控件家族设计 -> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 +> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 -本文记录 `AtomUI.Labs.Controls.LED` 的组件域设计。LED 是 Labs 下的实验控件家族名,不是单一控件名。 +本文记录 `AtomUI.Labs.Led` 的组件域设计。LED 是 Labs 下的实验控件家族名,不是单一控件名。 ## 文档导航 @@ -13,12 +13,12 @@ ## 定位 -`AtomUI.Labs.Controls.LED` 用于承载 LED 风格显示控件。它不表示硬件 LED 控制器,也不表示普通文本控件。 +`AtomUI.Labs.Led` 用于承载 LED 风格显示控件。它不表示硬件 LED 控制器,也不表示普通文本控件。 LED 家族目标包含两条并列路线: ```text -AtomUI.Labs.Controls.LED +AtomUI.Labs.Led Segment 十四段数码管路线 Matrix 点阵屏路线 ``` @@ -28,17 +28,18 @@ AtomUI.Labs.Controls.LED - `Segment` 以“段”为最小视觉单元,适合数字、英文字母、仪表读数和电子设备面板风格。 - `Matrix` 以“点阵像素”为最小视觉单元,适合字符屏、公告屏、滚动文字和更自由的符号表达。 -共享基础代码直接放在 `LED/` 根目录下,当前包括`LEDCharacterNormalizer`和`LEDDisplayLayoutMath`,不创建`Primitives`、`Shared`或`Internal`等独立目录。 +共享基础代码直接放在 `src/AtomUI.Labs.Led/` 根目录下,当前包括 `LedCharacterNormalizer` 和 `LedDisplayLayoutMath`,不创建 `Primitives`、`Shared` 或 `Internal` 等独立目录。 LED 家族主题必须采用聚合入口: ```text -LED/Themes/LEDThemes.axaml - -> LED/Segment/Themes/SegmentThemes.axaml - -> LED/Matrix/Themes/MatrixThemes.axaml +LedThemesProvider.axaml + -> Themes/LedThemes.axaml + -> Segment/Themes/SegmentThemes.axaml + -> Matrix/Themes/MatrixThemes.axaml ``` -包级 `AtomUILabsThemesProvider.axaml` 只引用 `LED/Themes/LEDThemes.axaml`,不直接引用某个子控件的最底层主题文件。 +包级 `LedThemesProvider.axaml` 只引用 `Themes/LedThemes.axaml`,不直接引用某个子控件的最底层主题文件。 ## 显示模型 @@ -47,8 +48,8 @@ LED 家族需要先区分三种常见显示模型: | 模型 | 核心单元 | 适合内容 | 优点 | 缺点 | Labs 定位 | |---|---|---|---|---|---| | 七段 | 7 个发光段 | 数字、少量符号 | 简单、经典、计算量低 | 字母表现差,很多字符不可读 | 不作为独立第一路线,可作为 Segment 的简化能力评估 | -| 十四段 | 14 个发光段 | 数字、A-Z、常用符号 | 保留数码管风格,能覆盖英文字母 | 字符映射没有唯一标准,实现复杂度高于七段 | `LED.Segment` 的主要方向 | -| 点阵 | 点阵像素 | 文本、符号、滚动屏 | 表达能力强,可读性更可控 | 风格变成像素屏,需要字模系统 | `LED.Matrix` 的主要方向 | +| 十四段 | 14 个发光段 | 数字、A-Z、常用符号 | 保留数码管风格,能覆盖英文字母 | 字符映射没有唯一标准,实现复杂度高于七段 | `Led.Segment` 的主要方向 | +| 点阵 | 点阵像素 | 文本、符号、滚动屏 | 表达能力强,可读性更可控 | 风格变成像素屏,需要字模系统 | `Led.Matrix` 的主要方向 | 第一阶段文档约定: @@ -58,7 +59,7 @@ LED 家族需要先区分三种常见显示模型: ## Segment 路线 -`LED.Segment` 是十四段数码管路线。 +`Led.Segment` 是十四段数码管路线。 工程核心: @@ -84,7 +85,7 @@ Text ## Matrix 路线 -`LED.Matrix` 是点阵屏路线。 +`Led.Matrix` 是点阵屏路线。 工程核心: @@ -110,7 +111,7 @@ Matrix静态轮廓的完整合同见[matrix-static-visual-system.md](matrix-stat ## 共享基础代码 -`Segment` 和 `Matrix` 可以共享 LED 家族内部基础代码,但第一版不可为了架构感过度抽象。共享代码不需要提前放入固定目录,可以先直接放在 `LED/` 根目录下;只有当真实重复稳定后,再评估是否增加 `Primitives`、`Shared` 或 `Internal` 等目录。 +`Segment` 和 `Matrix` 可以共享 LED 家族内部基础代码,但第一版不可为了架构感过度抽象。共享代码直接放在包项目根目录;只有当真实重复稳定后,再评估是否增加 `Primitives`、`Shared` 或 `Internal` 等目录。 子控件内部目录应按稳定职责组织。`Segment` 当前采用: @@ -128,8 +129,8 @@ Segment/ 当前已经共享的内容: -- `LEDCharacterNormalizer`:ASCII小写转大写。 -- `LEDDisplayLayoutMath`:ScaleDown比例和内容对齐偏移纯计算。 +- `LedCharacterNormalizer`:ASCII小写转大写。 +- `LedDisplayLayoutMath`:ScaleDown比例和内容对齐偏移纯计算。 已经统一行为但不提取代码的内容: @@ -151,7 +152,7 @@ Segment/ - Matrix dot 绘制逻辑。 - 两条路线各自的几何生成细节。 -原则上,第一版只共享输入、状态、选项和少量数学辅助;不共享字符映射、字模、几何生成和具体绘制。公共抽象必须来自真实重复,不能先设计一个看起来通用但掩盖路线差异的 `LEDLayoutEngine`、`LEDRenderer` 或 `LEDGeometryFactory`。 +原则上,第一版只共享输入、状态、选项和少量数学辅助;不共享字符映射、字模、几何生成和具体绘制。公共抽象必须来自真实重复,不能先设计一个看起来通用但掩盖路线差异的 `LedLayoutEngine`、`LedRenderer` 或 `LedGeometryFactory`。 公共边界的逐项审计结论见[family-common-boundary-audit.md](family-common-boundary-audit.md)。 diff --git a/docs/controls/led/segment-implementation.md b/docs/controls/led/segment-implementation.md index 050f403..57d49ed 100644 --- a/docs/controls/led/segment-implementation.md +++ b/docs/controls/led/segment-implementation.md @@ -1,12 +1,12 @@ # LED Segment 工业级实现原理 -> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 +> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 > 当前实现已将早期同形叠色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 控件的新手,但实现标准必须按工业级自绘控件来约束。 +本文记录 `AtomUI.Labs.Led.Segment` 的目标实现原理。目标读者可以是第一次接触 LED 控件的新手,但实现标准必须按工业级自绘控件来约束。 `Segment` 是十四段数码管路线。它不是字体控件,不是点阵控件,也不是硬件 LED 控制器。 @@ -55,7 +55,7 @@ Segment 的本质是基于字符映射表和参数化几何生成器的 Avalonia `Segment` 必须保持和正式控件库一致的工程入口习惯:公共控件类型留在控件根目录,内部实现按稳定职责进入子目录,主题通过 `*Themes.axaml` 聚合。 ```text -LED/Segment/ +src/AtomUI.Labs.Led/Segment/ SegmentDisplay.cs SegmentOverflowMode.cs SegmentValueSanitizer.cs @@ -82,10 +82,10 @@ LED/Segment/ 主题入口链路: ```text -AtomUILabsThemesProvider.axaml - -> LED/Themes/LEDThemes.axaml - -> LED/Segment/Themes/SegmentThemes.axaml - -> LED/Segment/Themes/SegmentDisplayTheme.axaml +LedThemesProvider.axaml + -> Themes/LedThemes.axaml + -> Segment/Themes/SegmentThemes.axaml + -> Segment/Themes/SegmentDisplayTheme.axaml ``` `SegmentToken.cs` 当前不创建。Token 会固定主题契约,必须等视觉语义稳定后单独设计,不能作为目录对齐的附带动作。 @@ -281,7 +281,7 @@ Render `ArrangeOverride` 不是主要几何生成入口。Segment 是自绘控件,通常没有子控件需要 arrange。`ArrangeOverride` 最多用于记录最终尺寸或标记缓存失效: -最终空间中的ScaleDown比例和内容对齐偏移由LED家族根目录的`LEDDisplayLayoutMath`计算。Segment仍自行决定何时启用ScaleDown,并保留最终Bounds参与字符高度布局的路线专属语义。 +最终空间中的ScaleDown比例和内容对齐偏移由LED家族根目录的`LedDisplayLayoutMath`计算。Segment仍自行决定何时启用ScaleDown,并保留最终Bounds参与字符高度布局的路线专属语义。 ```csharp protected override Size ArrangeOverride(Size finalSize) @@ -598,7 +598,7 @@ Segment 不能只靠手动看 sample。 当前测试覆盖: -- `LEDCharacterNormalizerTests`:ASCII 小写转大写。 +- `LedCharacterNormalizerTests`:ASCII 小写转大写。 - `SegmentCharacterMapTests`:数字、`A-Z`、符号、冒号、小数点、未知字符 fallback。 - `SegmentLayoutEngineTests`:slot 数量、窄符号宽度、padding、spacing、最终高度、非法数值规整。 - `SegmentGeometryFactoryTests`:14 段完整性、bounds 内几何、极端 thickness/gap、非正 bounds、非有限选项。 diff --git a/docs/controls/led/segment-performance-regression.md b/docs/controls/led/segment-performance-regression.md index 024f82a..ac33899 100644 --- a/docs/controls/led/segment-performance-regression.md +++ b/docs/controls/led/segment-performance-regression.md @@ -1,6 +1,6 @@ # LED Segment 性能回归矩阵 -> 文档状态:迁移参考。本文于 2026-07-20 从 AtomUI 仓库的 `dev-and-mark/modules/desktop-controls-labs` 复制到 AtomUI.Labs 并适配文档结构。`AtomUI.Labs.Controls.LED` 表示本仓库的目标设计;文中的“已实现”、验证数据及旧项目命令来自迁移前的 `AtomUI.Desktop.Controls.Labs` 参考实现,不表示当前仓库已经包含相应源码、测试或性能工具。 +> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 本文定义 `SegmentDisplay` 高频更新和缓存行为的长期回归边界。指标使用确定性的 layout/geometry 重建次数,不使用容易受机器负载影响的单元测试耗时阈值。 diff --git a/docs/controls/overview.md b/docs/controls/overview.md index e222733..3165e2a 100644 --- a/docs/controls/overview.md +++ b/docs/controls/overview.md @@ -6,4 +6,4 @@ ## 控件目录 -- [LED 控件家族](led/overview.md):十四段 Segment、5x7 Matrix、Glow 和 Marquee 的设计与参考实现资料。 +- [LED 控件家族](led/overview.md):已实现的 `AtomUI.Labs.Led` 包,包含十四段 Segment、5x7 Matrix、Glow 和 Marquee。 diff --git a/src/AtomUI.Labs.Led/README.nuget.md b/src/AtomUI.Labs.Led/README.nuget.md new file mode 100644 index 0000000..763318a --- /dev/null +++ b/src/AtomUI.Labs.Led/README.nuget.md @@ -0,0 +1,38 @@ +## AtomUI Labs Led + +`AtomUI.Labs.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.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. From 7b2571660f9fcb6205326eb8748c7dbf6e4191af Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 15:15:20 +0800 Subject: [PATCH 04/33] update main docs because adding Led controls --- README.md | 6 ++++++ README.zh-CN.md | 6 ++++++ docs/engineering/overview.md | 2 ++ 3 files changed, 14 insertions(+) diff --git a/README.md b/README.md index 585d67d..b6a3b68 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,12 @@ Labs package names follow this pattern: dotnet add package AtomUI.Labs.Controls. ``` +The currently implemented LED family is published as an explicit naming exception: + +```bash +dotnet add package AtomUI.Labs.Led +``` + Use a Labs package version that matches your AtomUI package version. ## Common Commands diff --git a/README.zh-CN.md b/README.zh-CN.md index c90c64e..55ba502 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.Led +``` + Labs 包版本应与应用使用的 AtomUI 主包版本保持一致。 ## 常用命令 diff --git a/docs/engineering/overview.md b/docs/engineering/overview.md index 06107f6..52e9c7e 100644 --- a/docs/engineering/overview.md +++ b/docs/engineering/overview.md @@ -29,6 +29,8 @@ docs/engineering/ AtomUI.Labs.Controls. ``` +LED 控件家族沿用迁移时确定的独立包名 `AtomUI.Labs.Led`;其测试项目为 `AtomUI.Labs.Led.Tests`。这是显式命名例外,不改变后续控件的默认规则。 + 测试项目使用: ```text From 060681152446b23de1c1f8dacd25b32df5b96960 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 15:15:58 +0800 Subject: [PATCH 05/33] add Led main project --- src/AtomUI.Labs.Led/AtomUI.Labs.Led.csproj | 24 +++++++++++++++++++ .../Properties/AssemblyInfo.cs | 6 +++++ ...omUI.Labs.Led.GlowPrototype.Desktop.csproj | 18 ++++++++++++++ .../AtomUI.Labs.Led.Performance.csproj | 23 ++++++++++++++++++ 4 files changed, 71 insertions(+) create mode 100644 src/AtomUI.Labs.Led/AtomUI.Labs.Led.csproj create mode 100644 src/AtomUI.Labs.Led/Properties/AssemblyInfo.cs create mode 100644 tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/AtomUI.Labs.Led.GlowPrototype.Desktop.csproj create mode 100644 tools/performances/AtomUI.Labs.Led.Performance/AtomUI.Labs.Led.Performance.csproj diff --git a/src/AtomUI.Labs.Led/AtomUI.Labs.Led.csproj b/src/AtomUI.Labs.Led/AtomUI.Labs.Led.csproj new file mode 100644 index 0000000..e05e5be --- /dev/null +++ b/src/AtomUI.Labs.Led/AtomUI.Labs.Led.csproj @@ -0,0 +1,24 @@ + + + $(AtomUITargetFrameworks) + AtomUI.Labs.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.Led/Properties/AssemblyInfo.cs b/src/AtomUI.Labs.Led/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..b194e9c --- /dev/null +++ b/src/AtomUI.Labs.Led/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using Avalonia.Metadata; + +[assembly: XmlnsPrefix("https://atomui.net/labs", "labs")] +[assembly: XmlnsDefinition("https://atomui.net/labs", "AtomUI.Labs.Led")] +[assembly: XmlnsDefinition("https://atomui.net/labs", "AtomUI.Labs.Led.Segment")] +[assembly: XmlnsDefinition("https://atomui.net/labs", "AtomUI.Labs.Led.Matrix")] diff --git a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/AtomUI.Labs.Led.GlowPrototype.Desktop.csproj b/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/AtomUI.Labs.Led.GlowPrototype.Desktop.csproj new file mode 100644 index 0000000..adcce3e --- /dev/null +++ b/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/AtomUI.Labs.Led.GlowPrototype.Desktop.csproj @@ -0,0 +1,18 @@ + + + net10.0 + WinExe + false + enable + enable + AtomUI.Labs.Led.GlowPrototype.Desktop + + + + + + + + + + diff --git a/tools/performances/AtomUI.Labs.Led.Performance/AtomUI.Labs.Led.Performance.csproj b/tools/performances/AtomUI.Labs.Led.Performance/AtomUI.Labs.Led.Performance.csproj new file mode 100644 index 0000000..55c35b6 --- /dev/null +++ b/tools/performances/AtomUI.Labs.Led.Performance/AtomUI.Labs.Led.Performance.csproj @@ -0,0 +1,23 @@ + + + net10.0 + Exe + false + enable + enable + AtomUI.Labs.Led.Performance + + + + + + + + + + + + + + + From e7bb8803aab37cb73609eea1c5236515b4b6146c Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 15:16:15 +0800 Subject: [PATCH 06/33] add Led controls tests --- .../AtomUI.Labs.Led.Tests.csproj | 19 +++++++ .../AtomUI.Labs.Led.Tests/AvaloniaTestApp.cs | 57 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 tests/AtomUI.Labs.Led.Tests/AtomUI.Labs.Led.Tests.csproj create mode 100644 tests/AtomUI.Labs.Led.Tests/AvaloniaTestApp.cs diff --git a/tests/AtomUI.Labs.Led.Tests/AtomUI.Labs.Led.Tests.csproj b/tests/AtomUI.Labs.Led.Tests/AtomUI.Labs.Led.Tests.csproj new file mode 100644 index 0000000..cdf7733 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/AtomUI.Labs.Led.Tests.csproj @@ -0,0 +1,19 @@ + + + $(AtomUIDevelopTargetFramework) + false + AtomUI.Labs.Led.Tests + + + + + + + + + + + + + + diff --git a/tests/AtomUI.Labs.Led.Tests/AvaloniaTestApp.cs b/tests/AtomUI.Labs.Led.Tests/AvaloniaTestApp.cs new file mode 100644 index 0000000..23eff91 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/AvaloniaTestApp.cs @@ -0,0 +1,57 @@ +using System.Threading; +using AtomUI; +using AtomUI.Labs.Led; +using Avalonia; +using Avalonia.Headless; +using Xunit; + +[assembly: AvaloniaTestApplication(typeof(AtomUI.Labs.Led.Tests.TestAppBuilder))] +[assembly: CollectionBehavior(DisableTestParallelization = true)] + +namespace AtomUI.Labs.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.UseLed()); + } +} From b0379d6cee6ce972877ffe3002d017c1016f773b Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 15:17:14 +0800 Subject: [PATCH 07/33] add performance tool for Led controls --- .../FormalGlowDesktop.cs | 357 +++++++ .../GlowDesktopBenchmark.cs | 553 +++++++++++ .../GlowLifecycle.cs | 206 ++++ .../GlowPrototypeApplication.cs | 24 + .../GlowPrototypeWindow.cs | 265 ++++++ .../Program.cs | 19 + .../Glow/FormalGlowPerformanceRunner.cs | 387 ++++++++ .../Glow/GlowPrototypeRunner.cs | 892 ++++++++++++++++++ .../AtomUI.Labs.Led.Performance/Program.cs | 708 ++++++++++++++ 9 files changed, 3411 insertions(+) create mode 100644 tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/FormalGlowDesktop.cs create mode 100644 tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowDesktopBenchmark.cs create mode 100644 tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowLifecycle.cs create mode 100644 tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowPrototypeApplication.cs create mode 100644 tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowPrototypeWindow.cs create mode 100644 tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/Program.cs create mode 100644 tools/performances/AtomUI.Labs.Led.Performance/Glow/FormalGlowPerformanceRunner.cs create mode 100644 tools/performances/AtomUI.Labs.Led.Performance/Glow/GlowPrototypeRunner.cs create mode 100644 tools/performances/AtomUI.Labs.Led.Performance/Program.cs diff --git a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/FormalGlowDesktop.cs b/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/FormalGlowDesktop.cs new file mode 100644 index 0000000..34421e5 --- /dev/null +++ b/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/FormalGlowDesktop.cs @@ -0,0 +1,357 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using AtomUI.Labs.Led.Matrix; +using AtomUI.Labs.Led.Segment; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; + +namespace AtomUI.Labs.Led.GlowPrototype.Desktop; + +internal enum FormalGlowDesktopControl +{ + Matrix, + Segment +} + +internal enum FormalGlowDesktopMode +{ + NoGlow, + Static, + Opacity, + Radius, + DynamicText +} + +internal sealed record FormalGlowDesktopOptions( + FormalGlowDesktopControl Control, + FormalGlowDesktopMode Mode, + int Instances, + int FrequencyHz, + int WarmupTicks, + int MeasurementTicks, + string? OutputPath) +{ + public static bool TryParse(string[] args, out FormalGlowDesktopOptions options) + { + options = new FormalGlowDesktopOptions( + FormalGlowDesktopControl.Matrix, + FormalGlowDesktopMode.NoGlow, + 10, + 60, + 60, + 300, + null); + if (!args.Contains("--formal-controls", StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + options = new FormalGlowDesktopOptions( + ParseControl(GetValue(args, "--control") ?? "matrix"), + ParseMode(GetValue(args, "--mode") ?? "noglow"), + ParsePositive(args, "--instances", 10), + ParseFrequency(args), + ParseNonNegative(args, "--warmup", 60), + ParsePositive(args, "--ticks", 300), + GetValue(args, "--output")); + return true; + } + + private static FormalGlowDesktopControl ParseControl(string value) + { + return value.ToLowerInvariant() switch + { + "matrix" => FormalGlowDesktopControl.Matrix, + "segment" => FormalGlowDesktopControl.Segment, + _ => throw new ArgumentException("--control must be matrix or segment.") + }; + } + + private static FormalGlowDesktopMode ParseMode(string value) + { + return value.ToLowerInvariant() switch + { + "noglow" => FormalGlowDesktopMode.NoGlow, + "static" => FormalGlowDesktopMode.Static, + "opacity" => FormalGlowDesktopMode.Opacity, + "radius" => FormalGlowDesktopMode.Radius, + "dynamictext" => FormalGlowDesktopMode.DynamicText, + _ => throw new ArgumentException("--mode must be noglow, static, opacity, radius, or dynamictext.") + }; + } + + private static int ParseFrequency(string[] args) + { + var value = ParseNonNegative(args, "--hz", 60); + return value <= 240 ? value : throw new ArgumentOutOfRangeException("--hz", "Value must not exceed 240."); + } + + private static int ParsePositive(string[] args, string name, int fallback) + { + var value = ParseNonNegative(args, name, fallback); + return value > 0 ? value : throw new ArgumentOutOfRangeException(name, "Value must be positive."); + } + + private static int ParseNonNegative(string[] args, string name, int fallback) + { + var value = GetValue(args, name); + if (value is null) + { + return fallback; + } + + return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) && parsed >= 0 + ? parsed + : throw new ArgumentOutOfRangeException(name, "Value must be a non-negative integer."); + } + + private static string? GetValue(string[] args, string name) + { + var index = Array.FindIndex(args, item => string.Equals(item, name, StringComparison.OrdinalIgnoreCase)); + return index >= 0 && index + 1 < args.Length ? args[index + 1] : null; + } +} + +internal sealed class FormalGlowDesktopWindow : Window +{ + private readonly FormalGlowDesktopOptions _options; + private readonly IReadOnlyList _targets; + private readonly DispatcherTimer _timer; + private readonly Stopwatch _stopwatch = new(); + private int _ticks; + private long _allocatedBefore; + + public FormalGlowDesktopWindow(FormalGlowDesktopOptions options) + { + _options = options; + Title = $"Formal LED Glow - {options.Control} {options.Mode}"; + Width = 1600; + Height = 1100; + Background = Brushes.Black; + var panel = new WrapPanel { Orientation = Orientation.Horizontal }; + _targets = Enumerable.Range(0, options.Instances) + .Select(index => CreateTarget(options, index)) + .ToArray(); + foreach (var target in _targets) + { + panel.Children.Add(target.Control); + } + + Content = panel; + _timer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(1d / Math.Max(1, options.FrequencyHz)) + }; + _timer.Tick += OnTick; + Opened += (_, _) => _timer.Start(); + } + + private void OnTick(object? sender, EventArgs e) + { + _ticks++; + if (_ticks == _options.WarmupTicks + 1) + { + foreach (var target in _targets) + { + target.ResetRenderCallbacks(); + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + _allocatedBefore = GC.GetTotalAllocatedBytes(true); + _stopwatch.Restart(); + } + + if (_options.FrequencyHz > 0) + { + foreach (var target in _targets) + { + target.Update(_options.Mode, _ticks); + } + } + + if (_ticks < _options.WarmupTicks + _options.MeasurementTicks) + { + return; + } + + _timer.Stop(); + Dispatcher.UIThread.Post(Finish, DispatcherPriority.ApplicationIdle); + } + + private void Finish() + { + _stopwatch.Stop(); + var callbacks = _targets.Sum(target => target.RenderCallbacks); + var expected = _options.FrequencyHz == 0 ? 0L : (long)_options.Instances * _options.MeasurementTicks; + var completion = expected == 0 ? 1 : callbacks / (double)expected; + var allocated = GC.GetTotalAllocatedBytes(false) - _allocatedBefore; + var report = new StringBuilder() + .AppendLine("Formal LED Glow real-window benchmark") + .AppendLine(CultureInfo.InvariantCulture, $"Control: {_options.Control}") + .AppendLine(CultureInfo.InvariantCulture, $"Mode: {_options.Mode}") + .AppendLine(CultureInfo.InvariantCulture, $"Instances: {_options.Instances}") + .AppendLine(CultureInfo.InvariantCulture, $"Frequency Hz: {_options.FrequencyHz}") + .AppendLine(CultureInfo.InvariantCulture, $"Warmup ticks: {_options.WarmupTicks}") + .AppendLine(CultureInfo.InvariantCulture, $"Measurement ticks: {_options.MeasurementTicks}") + .AppendLine(CultureInfo.InvariantCulture, $"Elapsed ms: {_stopwatch.Elapsed.TotalMilliseconds:0.00}") + .AppendLine(CultureInfo.InvariantCulture, $"Render callbacks: {callbacks}") + .AppendLine(CultureInfo.InvariantCulture, $"Expected callbacks: {expected}") + .AppendLine(CultureInfo.InvariantCulture, $"Callback completion: {completion:P2}") + .AppendLine(CultureInfo.InvariantCulture, $"Allocated bytes/process: {allocated}") + .AppendLine(CultureInfo.InvariantCulture, + $"Allocated bytes/completed callback: {(callbacks == 0 ? 0 : allocated / (double)callbacks):0.0}") + .AppendLine("Warning: elapsed time includes dispatcher, composition and window backend work; it is not isolated GPU time.") + .ToString(); + Console.Write(report); + if (_options.OutputPath is not null) + { + File.WriteAllText(_options.OutputPath, report); + } + + _timer.Tick -= OnTick; + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.Shutdown(0); + } + } + + private static IFormalGlowDesktopTarget CreateTarget(FormalGlowDesktopOptions options, int index) + { + return options.Control == FormalGlowDesktopControl.Matrix + ? new FormalMatrixGlowTarget(options.Mode, index) + : new FormalSegmentGlowTarget(options.Mode, index); + } +} + +internal interface IFormalGlowDesktopTarget +{ + Control Control { get; } + + long RenderCallbacks { get; } + + void ResetRenderCallbacks(); + + void Update(FormalGlowDesktopMode mode, int tick); +} + +internal sealed class FormalMatrixGlowTarget : MatrixDisplay, IFormalGlowDesktopTarget +{ + public FormalMatrixGlowTarget(FormalGlowDesktopMode mode, int index) + { + Width = 210; + Height = 92; + Margin = new Thickness(4); + Text = (index % 100_000_000).ToString("D8", CultureInfo.InvariantCulture); + DotSize = 4; + DotSpacing = 2; + CharacterSpacing = 5; + Padding = new Thickness(8); + Background = new SolidColorBrush(Color.FromRgb(6, 10, 14)); + ActiveBrush = Brushes.White; + InactiveBrush = new SolidColorBrush(Color.FromArgb(28, 100, 130, 140)); + GlowBrush = mode == FormalGlowDesktopMode.NoGlow ? null : Brushes.Cyan; + GlowOpacity = 0.35; + GlowRadius = 6; + } + + public Control Control => this; + + public long RenderCallbacks { get; private set; } + + public void ResetRenderCallbacks() => RenderCallbacks = 0; + + public void Update(FormalGlowDesktopMode mode, int tick) + { + ApplyMode(this, mode, tick); + if (mode == FormalGlowDesktopMode.DynamicText) + { + Text = (tick % 100_000_000).ToString("D8", CultureInfo.InvariantCulture); + } + else + { + InvalidateVisual(); + } + } + + public override void Render(DrawingContext context) + { + RenderCallbacks++; + base.Render(context); + } + + private static void ApplyMode(MatrixDisplay display, FormalGlowDesktopMode mode, int tick) + { + if (mode == FormalGlowDesktopMode.Opacity) + { + display.GlowOpacity = tick % 120 / 119d; + } + else if (mode == FormalGlowDesktopMode.Radius) + { + var phase = tick % 49; + display.GlowRadius = phase <= 24 ? phase : 48 - phase; + } + } +} + +internal sealed class FormalSegmentGlowTarget : SegmentDisplay, IFormalGlowDesktopTarget +{ + public FormalSegmentGlowTarget(FormalGlowDesktopMode mode, int index) + { + Width = 260; + Height = 92; + Margin = new Thickness(4); + Text = (index % 100_000).ToString("D5", CultureInfo.InvariantCulture); + CharacterHeight = 54; + SegmentThickness = 6; + SegmentGap = 2; + CharacterSpacing = 5; + Padding = new Thickness(8); + Background = new SolidColorBrush(Color.FromRgb(6, 10, 14)); + ActiveBrush = Brushes.White; + InactiveBrush = new SolidColorBrush(Color.FromArgb(28, 100, 130, 140)); + GlowBrush = mode == FormalGlowDesktopMode.NoGlow ? null : Brushes.Cyan; + GlowOpacity = 0.35; + GlowRadius = 6; + } + + public Control Control => this; + + public long RenderCallbacks { get; private set; } + + public void ResetRenderCallbacks() => RenderCallbacks = 0; + + public void Update(FormalGlowDesktopMode mode, int tick) + { + if (mode == FormalGlowDesktopMode.Opacity) + { + GlowOpacity = tick % 120 / 119d; + } + else if (mode == FormalGlowDesktopMode.Radius) + { + var phase = tick % 49; + GlowRadius = phase <= 24 ? phase : 48 - phase; + } + + if (mode == FormalGlowDesktopMode.DynamicText) + { + Text = (tick % 100_000).ToString("D5", CultureInfo.InvariantCulture); + } + else + { + InvalidateVisual(); + } + } + + public override void Render(DrawingContext context) + { + RenderCallbacks++; + base.Render(context); + } +} diff --git a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowDesktopBenchmark.cs b/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowDesktopBenchmark.cs new file mode 100644 index 0000000..4973b17 --- /dev/null +++ b/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowDesktopBenchmark.cs @@ -0,0 +1,553 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using AtomUI.Labs.Led.Performance; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; + +namespace AtomUI.Labs.Led.GlowPrototype.Desktop; + +internal enum GlowDesktopBenchmarkRoute +{ + NoGlow, + Geometry, + Opacity, + Vector, + Scoped +} + +internal enum GlowEffectGranularity +{ + PerGeometry, + PerControl, + Batch8, + Batch16 +} + +internal sealed record GlowDesktopBenchmarkOptions( + GlowDesktopBenchmarkRoute Route, + int Instances, + int WarmupTicks, + int MeasurementTicks, + int FrequencyHz, + double Radius, + bool BatchTopology, + GlowEffectGranularity Granularity, + double Spacing, + string? OutputPath) +{ + public static bool TryParse(string[] args, out GlowDesktopBenchmarkOptions options) + { + options = new GlowDesktopBenchmarkOptions( + GlowDesktopBenchmarkRoute.NoGlow, + 64, + 120, + 600, + 60, + 12, + false, + GlowEffectGranularity.PerGeometry, + 40, + null); + if (!args.Contains("--benchmark", StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + var route = ParseRoute(GetValue(args, "--route") ?? "noglow"); + var instances = ParsePositiveInt(GetValue(args, "--instances"), 64, "--instances"); + var warmupTicks = ParseNonNegativeInt(GetValue(args, "--warmup"), 120, "--warmup"); + var measurementTicks = ParsePositiveInt(GetValue(args, "--ticks"), 600, "--ticks"); + var frequencyHz = ParseNonNegativeInt(GetValue(args, "--hz"), 60, "--hz"); + if (frequencyHz > 240) + { + throw new ArgumentOutOfRangeException("--hz", "Value must not exceed 240 Hz."); + } + + var radius = ParseRadius(GetValue(args, "--radius")); + options = new GlowDesktopBenchmarkOptions( + route, + instances, + warmupTicks, + measurementTicks, + frequencyHz, + radius, + string.Equals(GetValue(args, "--topology"), "batch", StringComparison.OrdinalIgnoreCase), + ParseGranularity(GetValue(args, "--granularity")), + ParseSpacing(GetValue(args, "--spacing")), + GetValue(args, "--output")); + return true; + } + + private static string? GetValue(string[] args, string name) + { + var index = Array.FindIndex(args, item => string.Equals(item, name, StringComparison.OrdinalIgnoreCase)); + return index >= 0 && index + 1 < args.Length ? args[index + 1] : null; + } + + private static GlowDesktopBenchmarkRoute ParseRoute(string value) + { + return value.ToLowerInvariant() switch + { + "noglow" => GlowDesktopBenchmarkRoute.NoGlow, + "geometry" => GlowDesktopBenchmarkRoute.Geometry, + "opacity" => GlowDesktopBenchmarkRoute.Opacity, + "vector" => GlowDesktopBenchmarkRoute.Vector, + "scoped" => GlowDesktopBenchmarkRoute.Scoped, + _ => throw new ArgumentException( + $"Unsupported --route value '{value}'. Use noglow, geometry, opacity, vector, or scoped.") + }; + } + + private static double ParseRadius(string? value) + { + if (value is null) + { + return 12; + } + + return double.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var parsed) + && parsed is >= 0 and <= 24 + ? parsed + : throw new ArgumentOutOfRangeException("--radius", "Value must be between 0 and 24 DIP."); + } + + private static GlowEffectGranularity ParseGranularity(string? value) + { + return value?.ToLowerInvariant() switch + { + null or "pergeometry" => GlowEffectGranularity.PerGeometry, + "percontrol" => GlowEffectGranularity.PerControl, + "batch8" => GlowEffectGranularity.Batch8, + "batch16" => GlowEffectGranularity.Batch16, + _ => throw new ArgumentException( + $"Unsupported --granularity value '{value}'. Use pergeometry, percontrol, batch8, or batch16.") + }; + } + + private static double ParseSpacing(string? value) + { + if (value is null) + { + return 40; + } + + return double.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var parsed) + && parsed is >= 28 and <= 120 + ? parsed + : throw new ArgumentOutOfRangeException("--spacing", "Value must be between 28 and 120 DIP."); + } + + private static int ParsePositiveInt(string? value, int fallback, string name) + { + var parsed = ParseNonNegativeInt(value, fallback, name); + return parsed > 0 ? parsed : throw new ArgumentOutOfRangeException(name, "Value must be greater than zero."); + } + + private static int ParseNonNegativeInt(string? value, int fallback, string name) + { + if (value is null) + { + return fallback; + } + + return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) && parsed >= 0 + ? parsed + : throw new ArgumentOutOfRangeException(name, "Value must be a non-negative integer."); + } +} + +internal sealed class GlowDesktopBenchmarkWindow : Window +{ + private readonly GlowDesktopBenchmarkOptions _options; + private readonly IReadOnlyList _targets; + private readonly DispatcherTimer _timer; + private readonly Stopwatch _measurement = new(); + private int _ticks; + private long _allocatedBefore; + + public GlowDesktopBenchmarkWindow(GlowDesktopBenchmarkOptions options) + { + _options = options; + Title = $"Glow desktop benchmark - {options.Route}"; + Width = 960; + Height = 720; + Background = Brushes.Black; + + if (options.BatchTopology) + { + var surface = new GlowDesktopBenchmarkSurface( + options.Route, + options.Radius, + options.Instances, + options.Granularity, + options.Spacing); + _targets = [surface]; + Content = surface; + } + else + { + var panel = new WrapPanel { Orientation = Orientation.Horizontal }; + var cells = Enumerable.Range(0, options.Instances) + .Select(index => new GlowDesktopBenchmarkCell(options.Route, options.Radius, index)) + .ToList(); + _targets = cells; + foreach (var cell in cells) + { + panel.Children.Add(cell); + } + + Content = panel; + } + _timer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(1d / Math.Max(1, options.FrequencyHz)) + }; + _timer.Tick += OnTick; + Opened += (_, _) => _timer.Start(); + } + + private void OnTick(object? sender, EventArgs e) + { + _ticks++; + if (_ticks == _options.WarmupTicks + 1) + { + foreach (var target in _targets) + { + target.ResetCounters(); + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + _allocatedBefore = GC.GetTotalAllocatedBytes(true); + _measurement.Restart(); + } + + if (_options.FrequencyHz > 0) + { + foreach (var target in _targets) + { + target.Advance(); + } + } + + if (_ticks < _options.WarmupTicks + _options.MeasurementTicks) + { + return; + } + + _timer.Stop(); + Dispatcher.UIThread.Post(Finish, DispatcherPriority.ApplicationIdle); + } + + private void Finish() + { + _measurement.Stop(); + var renderCallbacks = _targets.Sum(target => target.RenderCallbacks); + var glowSubmissions = _targets.Sum(target => target.GlowSubmissions); + var effectScopes = _targets.Sum(target => target.EffectScopes); + var allocatedBytes = GC.GetTotalAllocatedBytes(false) - _allocatedBefore; + var expectedCallbacks = _options.FrequencyHz == 0 + ? 0 + : (long)(_options.BatchTopology ? 1 : _options.Instances) * _options.MeasurementTicks; + var callbackCompletion = expectedCallbacks == 0 ? 1 : renderCallbacks / (double)expectedCallbacks; + var bytesPerCallback = renderCallbacks == 0 ? 0 : allocatedBytes / (double)renderCallbacks; + var noGlowContractPassed = _options.Route != GlowDesktopBenchmarkRoute.NoGlow || glowSubmissions == 0; + var report = new StringBuilder() + .AppendLine("Glow desktop render-callback benchmark") + .AppendLine(CultureInfo.InvariantCulture, $"Route: {_options.Route}") + .AppendLine(CultureInfo.InvariantCulture, $"Instances: {_options.Instances}") + .AppendLine(CultureInfo.InvariantCulture, $"Warmup ticks: {_options.WarmupTicks}") + .AppendLine(CultureInfo.InvariantCulture, $"Measurement ticks: {_options.MeasurementTicks}") + .AppendLine(CultureInfo.InvariantCulture, $"Frequency Hz: {_options.FrequencyHz}") + .AppendLine(CultureInfo.InvariantCulture, $"Radius DIP: {_options.Radius:0.##}") + .AppendLine(CultureInfo.InvariantCulture, $"Topology: {(_options.BatchTopology ? "Batch" : "Cells")}") + .AppendLine(CultureInfo.InvariantCulture, $"Effect granularity: {_options.Granularity}") + .AppendLine(CultureInfo.InvariantCulture, $"Geometry spacing DIP: {_options.Spacing:0.##}") + .AppendLine(CultureInfo.InvariantCulture, $"Elapsed ms: {_measurement.Elapsed.TotalMilliseconds:0.00}") + .AppendLine(CultureInfo.InvariantCulture, $"Render callbacks: {renderCallbacks}") + .AppendLine(CultureInfo.InvariantCulture, $"Expected callbacks: {expectedCallbacks}") + .AppendLine(CultureInfo.InvariantCulture, $"Callback completion: {callbackCompletion:P2}") + .AppendLine(CultureInfo.InvariantCulture, $"Glow submissions: {glowSubmissions}") + .AppendLine(CultureInfo.InvariantCulture, $"Effect scopes: {effectScopes}") + .AppendLine(CultureInfo.InvariantCulture, $"Allocated bytes/process: {allocatedBytes}") + .AppendLine(CultureInfo.InvariantCulture, $"Allocated bytes/completed callback: {bytesPerCallback:0.0}") + .AppendLine(CultureInfo.InvariantCulture, + $"NoGlow zero-submission contract: {(_options.Route == GlowDesktopBenchmarkRoute.NoGlow ? (noGlowContractPassed ? "PASS" : "FAIL") : "N/A")}") + .AppendLine("Warning: elapsed time includes dispatcher scheduling and window rendering; it is not isolated GPU time.") + .ToString(); + + Console.Write(report); + if (_options.OutputPath is not null) + { + File.WriteAllText(_options.OutputPath, report); + } + + _timer.Tick -= OnTick; + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.Shutdown(noGlowContractPassed ? 0 : 2); + } + else + { + Close(); + } + } +} + +internal interface IGlowDesktopBenchmarkTarget +{ + long RenderCallbacks { get; } + + long GlowSubmissions { get; } + + long EffectScopes { get; } + + void Advance(); + + void ResetCounters(); +} + +internal sealed class GlowDesktopBenchmarkCell : Control, IGlowDesktopBenchmarkTarget +{ + private static readonly Geometry Source = new EllipseGeometry(new Rect(8, 8, 24, 24)); + private readonly IGlowPrototypeRenderer? _renderer; + private double _offset; + + public GlowDesktopBenchmarkCell(GlowDesktopBenchmarkRoute route, double radius, int index) + { + Width = 48; + Height = 48; + _offset = index % 2; + var brush = new SolidColorBrush(Color.FromRgb(64, 222, 255)); + _renderer = route switch + { + GlowDesktopBenchmarkRoute.NoGlow => null, + GlowDesktopBenchmarkRoute.Geometry => new OpacityOnlyGlowPrototype(brush, 1), + GlowDesktopBenchmarkRoute.Opacity => new OpacityOnlyGlowPrototype(brush, 0.65), + GlowDesktopBenchmarkRoute.Vector => new VectorExpansionGlowPrototype(brush, 0.65, radius), + GlowDesktopBenchmarkRoute.Scoped => new ScopedBlurEffectGlowPrototype(brush, 0.65, radius), + _ => throw new ArgumentOutOfRangeException(nameof(route)) + }; + } + + public long RenderCallbacks { get; private set; } + + public long GlowSubmissions { get; private set; } + + public long EffectScopes { get; private set; } + + public void Advance() + { + _offset = _offset == 0 ? 1 : 0; + InvalidateVisual(); + } + + public void ResetCounters() + { + RenderCallbacks = 0; + GlowSubmissions = 0; + EffectScopes = 0; + } + + public override void Render(DrawingContext context) + { + base.Render(context); + RenderCallbacks++; + context.DrawRectangle(Brushes.Black, null, new Rect(Bounds.Size)); + using (context.PushTransform(Avalonia.Matrix.CreateTranslation(_offset, 0))) + { + if (_renderer is not null) + { + _renderer.Render(context, Source); + GlowSubmissions++; + if (_renderer is ScopedBlurEffectGlowPrototype) + { + EffectScopes++; + } + } + + context.DrawGeometry(Brushes.White, null, Source); + } + } +} + +internal sealed class GlowDesktopBenchmarkSurface : Control, IGlowDesktopBenchmarkTarget +{ + private static readonly Geometry Source = new EllipseGeometry(new Rect(0, 0, 24, 24)); + private readonly IGlowPrototypeRenderer?[] _renderers; + private readonly GlowEffectGranularity _granularity; + private readonly ScopedBlurEffectGlowPrototype? _groupedScopedRenderer; + private readonly List _offsets = []; + private readonly double _spacing; + private double _offset; + private bool _glowEnabled = true; + + public GlowDesktopBenchmarkSurface( + GlowDesktopBenchmarkRoute route, + double radius, + int instances, + GlowEffectGranularity granularity = GlowEffectGranularity.PerGeometry, + double spacing = 40) + { + _granularity = granularity; + _spacing = spacing; + _renderers = Enumerable.Range(0, instances) + .Select(_ => CreateRenderer(route, radius)) + .ToArray(); + if (route == GlowDesktopBenchmarkRoute.Scoped && granularity != GlowEffectGranularity.PerGeometry) + { + _groupedScopedRenderer = new ScopedBlurEffectGlowPrototype( + new SolidColorBrush(Color.FromRgb(64, 222, 255)), + 0.65, + radius); + } + } + + public long RenderCallbacks { get; private set; } + + public long GlowSubmissions { get; private set; } + + public long EffectScopes { get; private set; } + + public void Advance() + { + _offset = _offset == 0 ? 1 : 0; + InvalidateVisual(); + } + + public void ResetCounters() + { + RenderCallbacks = 0; + GlowSubmissions = 0; + EffectScopes = 0; + } + + public void MutateGlow(int iteration) + { + _glowEnabled = iteration % 7 != 0; + var radius = iteration % 4 switch + { + 0 => 0, + 1 => 6, + 2 => 12, + _ => 24 + }; + var opacity = iteration % 5 / 5d; + var brush = iteration % 2 == 0 ? Brushes.Cyan : Brushes.Magenta; + foreach (var renderer in _renderers) + { + if (renderer is ScopedBlurEffectGlowPrototype scoped) + { + scoped.UpdateBrush(brush); + scoped.UpdateOpacity(opacity); + scoped.UpdateRadius(radius); + } + } + if (_groupedScopedRenderer is not null) + { + _groupedScopedRenderer.UpdateBrush(brush); + _groupedScopedRenderer.UpdateOpacity(opacity); + _groupedScopedRenderer.UpdateRadius(radius); + } + } + + public override void Render(DrawingContext context) + { + base.Render(context); + RenderCallbacks++; + context.DrawRectangle(Brushes.Black, null, new Rect(Bounds.Size)); + var columns = Math.Max(1, (int)(Bounds.Width / _spacing)); + BuildOffsets(columns); + if (_glowEnabled && _groupedScopedRenderer is not null) + { + RenderGroupedGlow(context); + } + for (var i = 0; i < _renderers.Length; i++) + { + var point = _offsets[i]; + var x = point.X; + var y = point.Y; + using (context.PushTransform(Avalonia.Matrix.CreateTranslation(x, y))) + { + var renderer = _renderers[i]; + if (_glowEnabled && _groupedScopedRenderer is null && renderer is not null) + { + renderer.Render(context, Source); + GlowSubmissions++; + if (renderer is ScopedBlurEffectGlowPrototype) + { + EffectScopes++; + } + } + + context.DrawGeometry(Brushes.White, null, Source); + } + } + } + + private void BuildOffsets(int columns) + { + _offsets.Clear(); + for (var i = 0; i < _renderers.Length; i++) + { + _offsets.Add(new Point( + 8 + i % columns * _spacing + _offset, + 8 + i / columns * _spacing)); + } + } + + private void RenderGroupedGlow(DrawingContext context) + { + var batchSize = _granularity switch + { + GlowEffectGranularity.PerControl => _offsets.Count, + GlowEffectGranularity.Batch8 => 8, + GlowEffectGranularity.Batch16 => 16, + _ => throw new InvalidOperationException($"Unsupported grouped granularity {_granularity}.") + }; + for (var start = 0; start < _offsets.Count; start += batchSize) + { + var count = Math.Min(batchSize, _offsets.Count - start); + var bounds = CalculateBounds(start, count).Intersect(new Rect(Bounds.Size)); + if (bounds.Width <= 0 || bounds.Height <= 0) + { + continue; + } + + _groupedScopedRenderer!.RenderMany(context, Source, _offsets, start, count, bounds); + GlowSubmissions += count; + EffectScopes++; + } + } + + private Rect CalculateBounds(int start, int count) + { + var bounds = Source.Bounds.Translate((Vector)_offsets[start]); + for (var i = start + 1; i < start + count; i++) + { + bounds = bounds.Union(Source.Bounds.Translate((Vector)_offsets[i])); + } + + return bounds; + } + + private static IGlowPrototypeRenderer? CreateRenderer(GlowDesktopBenchmarkRoute route, double radius) + { + var brush = new SolidColorBrush(Color.FromRgb(64, 222, 255)); + return route switch + { + GlowDesktopBenchmarkRoute.NoGlow => null, + GlowDesktopBenchmarkRoute.Geometry => new OpacityOnlyGlowPrototype(brush, 1), + GlowDesktopBenchmarkRoute.Opacity => new OpacityOnlyGlowPrototype(brush, 0.65), + GlowDesktopBenchmarkRoute.Vector => new VectorExpansionGlowPrototype(brush, 0.65, radius), + GlowDesktopBenchmarkRoute.Scoped => new ScopedBlurEffectGlowPrototype(brush, 0.65, radius), + _ => throw new ArgumentOutOfRangeException(nameof(route)) + }; + } +} diff --git a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowLifecycle.cs b/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowLifecycle.cs new file mode 100644 index 0000000..01e5603 --- /dev/null +++ b/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowLifecycle.cs @@ -0,0 +1,206 @@ +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Media; +using Avalonia.Threading; + +namespace AtomUI.Labs.Led.GlowPrototype.Desktop; + +internal sealed record GlowLifecycleOptions( + int Cycles, + int TicksPerCycle, + int Instances, + string? OutputPath) +{ + public static bool TryParse(string[] args, out GlowLifecycleOptions options) + { + options = new GlowLifecycleOptions(100, 10, 64, null); + if (!args.Contains("--lifecycle", StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + options = new GlowLifecycleOptions( + ParsePositive(args, "--cycles", 100), + ParsePositive(args, "--ticks-per-cycle", 10), + ParsePositive(args, "--instances", 64), + GetValue(args, "--output")); + return true; + } + + private static int ParsePositive(string[] args, string name, int fallback) + { + var value = GetValue(args, name); + if (value is null) + { + return fallback; + } + + return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) && parsed > 0 + ? parsed + : throw new ArgumentOutOfRangeException(name, "Value must be a positive integer."); + } + + private static string? GetValue(string[] args, string name) + { + var index = Array.FindIndex(args, item => string.Equals(item, name, StringComparison.OrdinalIgnoreCase)); + return index >= 0 && index + 1 < args.Length ? args[index + 1] : null; + } +} + +internal sealed class GlowLifecycleCoordinatorWindow : Window +{ + private readonly GlowLifecycleOptions _options; + private readonly List> _windowReferences = []; + private readonly List> _surfaceReferences = []; + private readonly List _settledMemory = []; + private GlowLifecycleChildWindow? _current; + private int _completedCycles; + private bool _finishScheduled; + + public GlowLifecycleCoordinatorWindow(GlowLifecycleOptions options) + { + _options = options; + Title = "Glow lifecycle coordinator"; + Width = 320; + Height = 120; + Background = Brushes.Black; + Content = new TextBlock + { + Text = "Glow lifecycle test is running...", + Margin = new Thickness(16), + Foreground = Brushes.White + }; + Opened += (_, _) => StartNextCycle(); + } + + private void StartNextCycle() + { + if (_completedCycles >= _options.Cycles) + { + if (!_finishScheduled) + { + _finishScheduled = true; + DispatcherTimer.RunOnce(Finish, TimeSpan.FromMilliseconds(500)); + } + return; + } + + var child = new GlowLifecycleChildWindow(_options.Instances, _options.TicksPerCycle); + _current = child; + _windowReferences.Add(new WeakReference(child)); + _surfaceReferences.Add(new WeakReference(child.Surface)); + child.Closed += OnChildClosed; + child.Show(this); + } + + private void OnChildClosed(object? sender, EventArgs e) + { + if (sender is GlowLifecycleChildWindow child) + { + child.Closed -= OnChildClosed; + } + + _current = null; + _completedCycles++; + if (_completedCycles % 20 == 0) + { + ForceCollection(); + _settledMemory.Add(GC.GetTotalMemory(true)); + } + + Dispatcher.UIThread.Post(StartNextCycle, DispatcherPriority.Background); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void Finish() + { + _current = null; + ForceCollection(); + var retainedWindows = _windowReferences.Count(reference => reference.TryGetTarget(out _)); + var retainedSurfaces = _surfaceReferences.Count(reference => reference.TryGetTarget(out _)); + var memoryGrowth = _settledMemory.Count < 2 ? 0 : _settledMemory[^1] - _settledMemory[0]; + var passed = retainedWindows == 0 && retainedSurfaces == 0; + var report = new StringBuilder() + .AppendLine("Glow lifecycle stress") + .AppendLine(CultureInfo.InvariantCulture, $"Cycles: {_options.Cycles}") + .AppendLine(CultureInfo.InvariantCulture, $"Ticks/cycle: {_options.TicksPerCycle}") + .AppendLine(CultureInfo.InvariantCulture, $"Instances/window: {_options.Instances}") + .AppendLine(CultureInfo.InvariantCulture, $"Retained windows: {retainedWindows}") + .AppendLine(CultureInfo.InvariantCulture, $"Retained surfaces: {retainedSurfaces}") + .AppendLine(CultureInfo.InvariantCulture, $"Settled memory samples: {string.Join(",", _settledMemory)}") + .AppendLine(CultureInfo.InvariantCulture, $"First-to-last settled memory delta: {memoryGrowth}") + .AppendLine(CultureInfo.InvariantCulture, $"Weak-reference lifecycle contract: {(passed ? "PASS" : "FAIL")}") + .ToString(); + Console.Write(report); + if (_options.OutputPath is not null) + { + File.WriteAllText(_options.OutputPath, report); + } + + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.Shutdown(passed ? 0 : 3); + } + } + + private static void ForceCollection() + { + for (var i = 0; i < 3; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + } +} + +internal sealed class GlowLifecycleChildWindow : Window +{ + private readonly DispatcherTimer _timer; + private readonly int _ticksPerCycle; + private int _ticks; + + public GlowLifecycleChildWindow(int instances, int ticksPerCycle) + { + _ticksPerCycle = ticksPerCycle; + Width = 720; + Height = 480; + ShowInTaskbar = false; + Surface = new GlowDesktopBenchmarkSurface(GlowDesktopBenchmarkRoute.Scoped, 12, instances); + Content = Surface; + _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + _timer.Tick += OnTick; + Opened += OnOpened; + Closed += OnClosed; + } + + public GlowDesktopBenchmarkSurface Surface { get; } + + private void OnOpened(object? sender, EventArgs e) + { + _timer.Start(); + } + + private void OnTick(object? sender, EventArgs e) + { + Surface.MutateGlow(_ticks); + Surface.Advance(); + _ticks++; + if (_ticks >= _ticksPerCycle) + { + Close(); + } + } + + private void OnClosed(object? sender, EventArgs e) + { + _timer.Stop(); + _timer.Tick -= OnTick; + Opened -= OnOpened; + Closed -= OnClosed; + Content = null; + } +} diff --git a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowPrototypeApplication.cs b/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowPrototypeApplication.cs new file mode 100644 index 0000000..1434c37 --- /dev/null +++ b/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowPrototypeApplication.cs @@ -0,0 +1,24 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; + +namespace AtomUI.Labs.Led.GlowPrototype.Desktop; + +internal sealed class GlowPrototypeApplication : Application +{ + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var args = desktop.Args ?? []; + desktop.MainWindow = FormalGlowDesktopOptions.TryParse(args, out var formalOptions) + ? new FormalGlowDesktopWindow(formalOptions) + : GlowLifecycleOptions.TryParse(args, out var lifecycleOptions) + ? new GlowLifecycleCoordinatorWindow(lifecycleOptions) + : GlowDesktopBenchmarkOptions.TryParse(args, out var options) + ? new GlowDesktopBenchmarkWindow(options) + : new GlowPrototypeWindow(); + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowPrototypeWindow.cs b/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowPrototypeWindow.cs new file mode 100644 index 0000000..77939fc --- /dev/null +++ b/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowPrototypeWindow.cs @@ -0,0 +1,265 @@ +using AtomUI.Labs.Led.Performance; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Layout; +using Avalonia.Media; + +namespace AtomUI.Labs.Led.GlowPrototype.Desktop; + +internal sealed class GlowPrototypeWindow : Window +{ + private static readonly Color WindowBackground = Color.FromRgb(10, 13, 17); + private static readonly Color PanelBackground = Color.FromRgb(16, 23, 29); + + public GlowPrototypeWindow() + { + Title = "AtomUI Labs LED Glow Prototype"; + Width = 1120; + Height = 820; + MinWidth = 760; + MinHeight = 560; + Background = new SolidColorBrush(WindowBackground); + Content = new ScrollViewer + { + VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, + HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled, + Content = BuildContent() + }; + } + + private static Control BuildContent() + { + var root = new StackPanel + { + Margin = new Thickness(24), + Spacing = 18 + }; + root.Children.Add(new TextBlock + { + Text = "LED Glow route comparison", + FontSize = 24, + FontWeight = FontWeight.SemiBold, + Foreground = Brushes.White + }); + root.Children.Add(new TextBlock + { + Text = "Same geometry, brush, opacity and radius. Active source remains white; cyan is Glow; gray is inactive; yellow is panel border.", + FontSize = 13, + TextWrapping = TextWrapping.Wrap, + Foreground = new SolidColorBrush(Color.FromRgb(170, 181, 190)) + }); + + foreach (var radius in new[] { 6d, 12d, 24d }) + { + root.Children.Add(BuildRadiusSection(radius)); + } + + root.Children.Add(BuildClipSection()); + return root; + } + + private static Control BuildRadiusSection(double radius) + { + var section = new StackPanel { Spacing = 10 }; + section.Children.Add(new TextBlock + { + Text = $"GlowRadius {radius:0} DIP", + FontSize = 17, + FontWeight = FontWeight.SemiBold, + Foreground = Brushes.White + }); + + var grid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("*,*"), + ColumnSpacing = 14 + }; + grid.Children.Add(BuildRouteColumn("Route A - Vector expansion", radius, false)); + var scoped = BuildRouteColumn("Route C - Scoped BlurEffect", radius, true); + Grid.SetColumn(scoped, 1); + grid.Children.Add(scoped); + section.Children.Add(grid); + return section; + } + + private static Control BuildRouteColumn(string title, double radius, bool scopedBlur) + { + var panel = new StackPanel + { + Spacing = 8, + Background = new SolidColorBrush(PanelBackground), + Margin = new Thickness(0) + }; + panel.Children.Add(new TextBlock + { + Text = title, + Margin = new Thickness(12, 10, 12, 0), + FontSize = 14, + FontWeight = FontWeight.SemiBold, + Foreground = Brushes.White + }); + + var previews = new UniformGrid + { + Columns = 2, + Rows = 2, + Margin = new Thickness(8) + }; + foreach (var source in CreateSources()) + { + previews.Children.Add(new GlowPrototypePreview( + source.Name, + source.Geometry, + CreateRenderer(scopedBlur, radius))); + } + + panel.Children.Add(previews); + return panel; + } + + private static Control BuildClipSection() + { + var section = new StackPanel { Spacing = 8 }; + section.Children.Add(new TextBlock + { + Text = "Strict clip and layer isolation", + FontSize = 17, + FontWeight = FontWeight.SemiBold, + Foreground = Brushes.White + }); + section.Children.Add(new GlowPrototypePreview( + "Scoped Blur, Radius 24, clipped inside yellow border", + new EllipseGeometry(new Rect(54, 34, 52, 52)), + CreateRenderer(true, 24), + showInactive: true, + clipBounds: new Rect(16, 16, 128, 88)) + { + Width = 520, + HorizontalAlignment = HorizontalAlignment.Left + }); + return section; + } + + private static IGlowPrototypeRenderer CreateRenderer(bool scopedBlur, double radius) + { + var brush = new SolidColorBrush(Color.FromRgb(64, 222, 255)); + return scopedBlur + ? new ScopedBlurEffectGlowPrototype(brush, 0.65, radius) + : new VectorExpansionGlowPrototype(brush, 0.65, radius); + } + + private static IReadOnlyList CreateSources() + { + return new[] + { + new GlowSource("Circle", new EllipseGeometry(new Rect(56, 36, 48, 48))), + new GlowSource("Rounded square", new RectangleGeometry(new Rect(56, 36, 48, 48), 10, 10)), + new GlowSource( + "Segment diagonal", + CreatePolygon( + new Point(46, 28), + new Point(59, 28), + new Point(116, 92), + new Point(103, 92))), + new GlowSource( + "Colon dots", + new GeometryGroup + { + Children = + { + new EllipseGeometry(new Rect(72, 35, 16, 16)), + new EllipseGeometry(new Rect(72, 69, 16, 16)) + } + }) + }; + } + + private static StreamGeometry CreatePolygon(params Point[] points) + { + 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], true); + } + + context.EndFigure(true); + return geometry; + } + + private sealed record GlowSource(string Name, Geometry Geometry); +} + +internal sealed class GlowPrototypePreview : Control +{ + private readonly string _label; + private readonly Geometry _activeGeometry; + private readonly IGlowPrototypeRenderer _renderer; + private readonly bool _showInactive; + private readonly Rect? _clipBounds; + private readonly Pen _borderPen = new(Brushes.Yellow, 2); + + public GlowPrototypePreview( + string label, + Geometry activeGeometry, + IGlowPrototypeRenderer renderer, + bool showInactive = true, + Rect? clipBounds = null) + { + _label = label; + _activeGeometry = activeGeometry; + _renderer = renderer; + _showInactive = showInactive; + _clipBounds = clipBounds; + Width = 180; + Height = 132; + Margin = new Thickness(4); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + context.DrawRectangle( + new SolidColorBrush(Color.FromRgb(7, 11, 14)), + null, + new RoundedRect(new Rect(Bounds.Size), 6)); + + if (_showInactive) + { + context.DrawGeometry( + new SolidColorBrush(Color.FromArgb(110, 88, 101, 108)), + null, + new EllipseGeometry(new Rect(24, 50, 18, 18))); + } + + if (_clipBounds is { } clipBounds) + { + using (context.PushClip(clipBounds)) + { + RenderGlowAndSource(context); + } + context.DrawRectangle(null, _borderPen, new RoundedRect(clipBounds, 4)); + } + else + { + RenderGlowAndSource(context); + } + + var label = new FormattedText( + _label, + System.Globalization.CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, + new Typeface("Segoe UI"), + 11, + Brushes.White); + context.DrawText(label, new Point(8, Bounds.Height - 20)); + } + + private void RenderGlowAndSource(DrawingContext context) + { + _renderer.Render(context, _activeGeometry); + context.DrawGeometry(Brushes.White, null, _activeGeometry); + } +} diff --git a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/Program.cs b/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/Program.cs new file mode 100644 index 0000000..7ff9430 --- /dev/null +++ b/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/Program.cs @@ -0,0 +1,19 @@ +using Avalonia; + +namespace AtomUI.Labs.Led.GlowPrototype.Desktop; + +internal static class Program +{ + [STAThread] + public static void Main(string[] args) + { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + private static AppBuilder BuildAvaloniaApp() + { + return AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace(); + } +} diff --git a/tools/performances/AtomUI.Labs.Led.Performance/Glow/FormalGlowPerformanceRunner.cs b/tools/performances/AtomUI.Labs.Led.Performance/Glow/FormalGlowPerformanceRunner.cs new file mode 100644 index 0000000..c25015c --- /dev/null +++ b/tools/performances/AtomUI.Labs.Led.Performance/Glow/FormalGlowPerformanceRunner.cs @@ -0,0 +1,387 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using AtomUI.Labs.Led.Matrix; +using AtomUI.Labs.Led.Segment; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace AtomUI.Labs.Led.Performance; + +internal static class FormalGlowPerformanceRunner +{ + private const int WarmupFrames = 100; + private static DrawingGroup? _drawingSink; + + public static int Run(int frameCount, string? markdownOutputPath) + { + PrewarmRuntime(); + var results = new List(); + foreach (var controlKind in new[] { FormalGlowControlKind.Matrix, FormalGlowControlKind.Segment }) + { + foreach (var mode in Enum.GetValues()) + { + results.Add(Measure(controlKind, mode, frameCount)); + } + } + + var disabledPairs = Enum.GetValues() + .Select(kind => MeasureDisabledPair(kind, frameCount)) + .ToArray(); + + var report = RenderReport(results, disabledPairs); + Console.WriteLine(report); + if (!string.IsNullOrWhiteSpace(markdownOutputPath)) + { + var fullPath = Path.GetFullPath(markdownOutputPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, RenderMarkdown(results, disabledPairs), new UTF8Encoding(false)); + Console.WriteLine($"Wrote formal Glow result: {fullPath}"); + } + + return Validate(results, disabledPairs) ? 0 : 1; + } + + private static DisabledPairResult MeasureDisabledPair(FormalGlowControlKind kind, int frameCount) + { + var nullBrush = CreateControl(kind, FormalGlowMode.DisabledNullBrush); + var zeroOpacity = CreateControl(kind, FormalGlowMode.DisabledZeroOpacity); + var viewport = new Size(800, 140); + foreach (var control in new[] { nullBrush, zeroOpacity }) + { + control.Measure(viewport); + control.Arrange(new Rect(viewport)); + } + + for (var i = 0; i < 1_000; i++) + { + _drawingSink = Render(i % 2 == 0 ? nullBrush : zeroOpacity); + _drawingSink = Render(i % 2 == 0 ? zeroOpacity : nullBrush); + } + + long nullTicks = 0; + long zeroTicks = 0; + const int batchSize = 16; + var measuredFrames = 0; + var batch = 0; + while (measuredFrames < frameCount) + { + var count = Math.Min(batchSize, frameCount - measuredFrames); + if (batch % 2 == 0) + { + nullTicks += MeasureRenderTicks(nullBrush, count); + zeroTicks += MeasureRenderTicks(zeroOpacity, count); + } + else + { + zeroTicks += MeasureRenderTicks(zeroOpacity, count); + nullTicks += MeasureRenderTicks(nullBrush, count); + } + + measuredFrames += count; + batch++; + } + + return new DisabledPairResult(kind, frameCount, nullTicks, zeroTicks); + } + + private static long MeasureRenderTicks(Control control, int count) + { + var start = Stopwatch.GetTimestamp(); + for (var i = 0; i < count; i++) + { + _drawingSink = Render(control); + } + + return Stopwatch.GetTimestamp() - start; + } + + private static FormalGlowResult Measure( + FormalGlowControlKind controlKind, + FormalGlowMode mode, + int frameCount) + { + var control = CreateControl(controlKind, mode); + var viewport = new Size(800, 140); + control.Measure(viewport); + control.Arrange(new Rect(viewport)); + for (var frame = 0; frame < WarmupFrames; frame++) + { + Update(mode, control, frame); + _drawingSink = Render(control); + } + + var initialBuilds = GetEffectBuildCount(control); + var initialScopes = GetEffectScopeCount(control); + ForceFullCollection(); + var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + var stopwatch = Stopwatch.StartNew(); + for (var frame = 0; frame < frameCount; frame++) + { + Update(mode, control, frame); + _drawingSink = Render(control); + } + + stopwatch.Stop(); + var allocatedBytes = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; + var drawing = _drawingSink ?? throw new InvalidOperationException("Measured Glow frame was not captured."); + return new FormalGlowResult( + controlKind, + mode, + frameCount, + stopwatch.Elapsed, + allocatedBytes, + GetEffectBuildCount(control) - initialBuilds, + GetEffectScopeCount(control) - initialScopes, + CountGeometryDrawings(drawing)); + } + + private static void PrewarmRuntime() + { + foreach (var controlKind in new[] { FormalGlowControlKind.Matrix, FormalGlowControlKind.Segment }) + { + foreach (var mode in Enum.GetValues()) + { + var control = CreateControl(controlKind, mode); + var viewport = new Size(800, 140); + control.Measure(viewport); + control.Arrange(new Rect(viewport)); + Update(mode, control, 1); + _drawingSink = Render(control); + } + } + + _drawingSink = null; + ForceFullCollection(); + + foreach (var controlKind in new[] { FormalGlowControlKind.Matrix, FormalGlowControlKind.Segment }) + { + foreach (var mode in Enum.GetValues()) + { + _ = Measure(controlKind, mode, 1_000); + } + } + + _drawingSink = null; + ForceFullCollection(); + } + + private static Control CreateControl(FormalGlowControlKind kind, FormalGlowMode mode) + { + var glowBrush = mode == FormalGlowMode.DisabledNullBrush ? null : Brushes.Cyan; + var glowOpacity = mode == FormalGlowMode.DisabledZeroOpacity ? 0 : 0.35; + return kind switch + { + FormalGlowControlKind.Matrix => new MatrixDisplay + { + Text = "TEMP2026RPM1234", + DotSize = 6, + DotSpacing = 2, + CharacterSpacing = 8, + ActiveBrush = Brushes.White, + InactiveBrush = Brushes.Gray, + GlowBrush = glowBrush, + GlowOpacity = glowOpacity, + GlowRadius = 6 + }, + FormalGlowControlKind.Segment => new SegmentDisplay + { + Text = "TEMP2026RPM1234", + CharacterHeight = 72, + SegmentThickness = 8, + SegmentGap = 2, + CharacterSpacing = 8, + ActiveBrush = Brushes.White, + InactiveBrush = Brushes.Gray, + GlowBrush = glowBrush, + GlowOpacity = glowOpacity, + GlowRadius = 6 + }, + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + } + + private static void Update(FormalGlowMode mode, Control control, int frame) + { + switch (mode) + { + case FormalGlowMode.OpacityAnimation: + SetOpacity(control, frame % 120 / 119d); + break; + case FormalGlowMode.RadiusAnimation: + SetRadius(control, frame % 49 <= 24 ? frame % 49 : 48 - frame % 49); + break; + } + } + + private static void SetOpacity(Control control, double value) + { + if (control is MatrixDisplay matrix) + { + matrix.GlowOpacity = value; + } + else + { + ((SegmentDisplay)control).GlowOpacity = value; + } + } + + private static void SetRadius(Control control, double value) + { + if (control is MatrixDisplay matrix) + { + matrix.GlowRadius = value; + } + else + { + ((SegmentDisplay)control).GlowRadius = value; + } + } + + private static int GetEffectBuildCount(Control control) + { + return control is MatrixDisplay matrix + ? matrix.GlowEffectBuildCount + : ((SegmentDisplay)control).GlowEffectBuildCount; + } + + private static int GetEffectScopeCount(Control control) + { + return control is MatrixDisplay matrix + ? matrix.GlowEffectScopeCount + : ((SegmentDisplay)control).GlowEffectScopeCount; + } + + private static DrawingGroup Render(Control control) + { + var drawing = new DrawingGroup(); + using var context = drawing.Open(); + control.Render(context); + return drawing; + } + + private static int CountGeometryDrawings(Drawing drawing) + { + if (drawing is GeometryDrawing) + { + return 1; + } + + return drawing is DrawingGroup group ? group.Children.Sum(CountGeometryDrawings) : 0; + } + + private static void ForceFullCollection() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + private static bool Validate( + IReadOnlyList results, + IReadOnlyList disabledPairs) + { + return results.Where(result => result.Mode is FormalGlowMode.DisabledNullBrush or FormalGlowMode.DisabledZeroOpacity) + .All(result => result.EffectBuilds == 0 && result.EffectScopes == 0) + && results.Where(result => result.Mode == FormalGlowMode.Static) + .All(result => result.EffectBuilds == 0 && result.EffectScopes == result.FrameCount) + && disabledPairs.All(result => result.SlowerToFasterRatio <= 1.05); + } + + private static string RenderReport( + IReadOnlyList results, + IReadOnlyList disabledPairs) + { + var builder = new StringBuilder(); + builder.AppendLine("Formal LED Glow performance (DrawingGroup command submission)"); + builder.AppendLine("Control Mode Frames us/frame bytes/frame Effect builds Effect scopes Commands"); + foreach (var result in results) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"{result.Control,-9}{result.Mode,-22}{result.FrameCount,7}{result.MicrosecondsPerFrame,10:0.00}{result.BytesPerFrame,13:0.0}{result.EffectBuilds,15}{result.EffectScopes,15}{result.GeometryCommands,10}"); + } + + builder.AppendLine("Disabled paired timing"); + builder.AppendLine("Control NullBrush us/frame ZeroOpacity us/frame slower/faster Gate"); + foreach (var pair in disabledPairs) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"{pair.Control,-9}{pair.NullMicrosecondsPerFrame,19:0.00}{pair.ZeroMicrosecondsPerFrame,22:0.00}{pair.SlowerToFasterRatio,15:0.000} {(pair.SlowerToFasterRatio <= 1.05 ? "PASS" : "FAIL")}"); + } + + return builder.ToString(); + } + + private static string RenderMarkdown( + IReadOnlyList results, + IReadOnlyList disabledPairs) + { + var builder = new StringBuilder(); + builder.AppendLine("# Formal LED Glow Performance"); + builder.AppendLine(); + builder.AppendLine("DrawingGroup command submission only; timings are not isolated GPU presentation cost."); + builder.AppendLine(); + builder.AppendLine("| Control | Mode | Frames | us/frame | bytes/frame | Effect builds | Effect scopes | Commands | "); + builder.AppendLine("|---|---|---:|---:|---:|---:|---:|---:|"); + foreach (var result in results) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"| {result.Control} | {result.Mode} | {result.FrameCount} | {result.MicrosecondsPerFrame:0.00} | {result.BytesPerFrame:0.0} | {result.EffectBuilds} | {result.EffectScopes} | {result.GeometryCommands} |"); + } + + builder.AppendLine(); + builder.AppendLine("| Control | NullBrush us/frame | ZeroOpacity us/frame | slower/faster | Gate |"); + builder.AppendLine("|---|---:|---:|---:|---|"); + foreach (var pair in disabledPairs) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"| {pair.Control} | {pair.NullMicrosecondsPerFrame:0.00} | {pair.ZeroMicrosecondsPerFrame:0.00} | {pair.SlowerToFasterRatio:0.000} | {(pair.SlowerToFasterRatio <= 1.05 ? "PASS" : "FAIL")} |"); + } + + return builder.ToString(); + } + + private enum FormalGlowControlKind + { + Matrix, + Segment + } + + private enum FormalGlowMode + { + DisabledNullBrush, + DisabledZeroOpacity, + Static, + OpacityAnimation, + RadiusAnimation + } + + private sealed record FormalGlowResult( + FormalGlowControlKind Control, + FormalGlowMode Mode, + int FrameCount, + TimeSpan Elapsed, + long AllocatedBytes, + int EffectBuilds, + int EffectScopes, + int GeometryCommands) + { + public double MicrosecondsPerFrame => Elapsed.TotalMilliseconds * 1000 / FrameCount; + + public double BytesPerFrame => AllocatedBytes / (double)FrameCount; + } + + private sealed record DisabledPairResult( + FormalGlowControlKind Control, + int FrameCount, + long NullTicks, + long ZeroTicks) + { + public double NullMicrosecondsPerFrame => NullTicks * 1_000_000d / Stopwatch.Frequency / FrameCount; + + public double ZeroMicrosecondsPerFrame => ZeroTicks * 1_000_000d / Stopwatch.Frequency / FrameCount; + + public double SlowerToFasterRatio => Math.Max(NullTicks, ZeroTicks) / (double)Math.Min(NullTicks, ZeroTicks); + } +} diff --git a/tools/performances/AtomUI.Labs.Led.Performance/Glow/GlowPrototypeRunner.cs b/tools/performances/AtomUI.Labs.Led.Performance/Glow/GlowPrototypeRunner.cs new file mode 100644 index 0000000..a69aa04 --- /dev/null +++ b/tools/performances/AtomUI.Labs.Led.Performance/Glow/GlowPrototypeRunner.cs @@ -0,0 +1,892 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Threading; + +namespace AtomUI.Labs.Led.Performance; + +internal static class GlowPrototypeRunner +{ + private const double Width = 180; + private const double Height = 140; + private static DrawingGroup? _drawingSink; + + public static int Run(int frameCount, string? markdownOutputPath) + { + var corpus = CreateCorpus(); + var brushes = CreateBrushes(); + var pixelResults = new List(); + foreach (var glowCase in corpus) + { + foreach (var brushCase in brushes) + { + foreach (var radius in new[] { 6d, 12d, 24d }) + { + foreach (var renderer in CreateRenderers(brushCase.Brush, radius)) + { + foreach (var renderScaling in new[] { 1d, 1.25, 1.5, 2d }) + { + var result = Capture(renderer, glowCase.Geometry, renderScaling); + pixelResults.Add(new GlowPrototypePixelResult( + renderer.Name, + glowCase.Name, + brushCase.Name, + radius, + renderScaling, + result.VisibleOutsideSourcePixels, + result.VisibleInsideSourcePixels)); + } + } + } + } + } + + var validationResults = RunBehaviorValidations(); + + var representative = corpus.First(item => item.Name == "Matrix.Circle").Geometry; + var performanceResults = new List(); + foreach (var renderer in new IGlowPrototypeRenderer[] + { + new NoGlowPrototype(), + new VectorExpansionGlowPrototype(Brushes.Cyan, 0.65, 12), + new ScopedBlurEffectGlowPrototype(Brushes.Cyan, 0.65, 12) + }) + { + var result = Measure(renderer, representative, frameCount); + performanceResults.Add(new GlowPrototypePerformanceResult( + renderer.Name, + frameCount, + result.Elapsed, + result.AllocatedBytes)); + } + + Console.WriteLine(RenderSummary(pixelResults, performanceResults, validationResults)); + if (!string.IsNullOrWhiteSpace(markdownOutputPath)) + { + var fullPath = Path.GetFullPath(markdownOutputPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText( + fullPath, + RenderMarkdown(pixelResults, performanceResults, validationResults), + new UTF8Encoding(false)); + Console.WriteLine($"Wrote Glow prototype result: {fullPath}"); + } + + return pixelResults.All(result => result.VisibleOutsideSourcePixels > 0) + && validationResults.All(result => result.Passed) + ? 0 + : 1; + } + + private static IReadOnlyList CreateCorpus() + { + return new[] + { + new GlowPrototypeCase("Matrix.Circle", new EllipseGeometry(new Rect(70, 50, 40, 40))), + new GlowPrototypeCase("Matrix.Square", new RectangleGeometry(new Rect(70, 50, 40, 40))), + new GlowPrototypeCase( + "Matrix.RoundedSquare", + new RectangleGeometry(new Rect(70, 50, 40, 40), 10, 10)), + new GlowPrototypeCase( + "Segment.Horizontal", + CreatePolygon( + new Point(55, 58), + new Point(65, 50), + new Point(115, 50), + new Point(125, 58), + new Point(115, 66), + new Point(65, 66))), + new GlowPrototypeCase( + "Segment.Vertical", + CreatePolygon( + new Point(74, 35), + new Point(82, 45), + new Point(82, 95), + new Point(74, 105), + new Point(66, 95), + new Point(66, 45))), + new GlowPrototypeCase( + "Segment.Diagonal", + CreatePolygon( + new Point(58, 38), + new Point(70, 38), + new Point(122, 102), + new Point(110, 102))), + new GlowPrototypeCase( + "Segment.ColonDots", + new GeometryGroup + { + Children = + { + new EllipseGeometry(new Rect(84, 46, 12, 12)), + new EllipseGeometry(new Rect(84, 82, 12, 12)) + } + }) + }; + } + + private static IReadOnlyList CreateBrushes() + { + return new[] + { + new GlowBrushCase("Solid", Brushes.Cyan), + new GlowBrushCase("AlphaSolid", new SolidColorBrush(Color.FromArgb(128, 0, 255, 255))), + new GlowBrushCase( + "LinearGradient", + new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0.5, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 0.5, RelativeUnit.Relative), + GradientStops = + { + new GradientStop(Colors.Cyan, 0), + new GradientStop(Colors.Blue, 1) + } + }), + new GlowBrushCase( + "RadialGradient", + new RadialGradientBrush + { + Center = new RelativePoint(0.5, 0.5, RelativeUnit.Relative), + GradientStops = + { + new GradientStop(Colors.White, 0), + new GradientStop(Colors.Cyan, 1) + } + }) + }; + } + + private static StreamGeometry CreatePolygon(params Point[] points) + { + 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], true); + } + + context.EndFigure(true); + return geometry; + } + + private static IReadOnlyList CreateRenderers(IBrush brush, double radius) + { + return new IGlowPrototypeRenderer[] + { + new VectorExpansionGlowPrototype(brush, 0.65, radius), + new ScopedBlurEffectGlowPrototype(brush, 0.65, radius) + }; + } + + private static IReadOnlyList RunBehaviorValidations() + { + var renderer = new ScopedBlurEffectGlowPrototype(Brushes.Cyan, 0.65, 12); + var initialBuildCount = renderer.EffectBuildCount; + renderer.UpdateBrush(Brushes.Magenta); + renderer.UpdateOpacity(0.4); + var brushOpacityReuse = renderer.EffectBuildCount == initialBuildCount; + renderer.UpdateRadius(18); + var radiusReuse = renderer.EffectBuildCount == initialBuildCount + && renderer.RadiusUpdateCount == 1; + + var layerRenderer = new ScopedBlurEffectGlowPrototype(Brushes.Cyan, 0.65, 12); + var layerFrame = CaptureFrame( + layerRenderer, + new EllipseGeometry(new Rect(70, 50, 40, 40)), + 1, + new GlowCaptureOptions(Decorations: true)); + var inactiveColor = layerFrame.GetColor(22, 70); + var borderColor = layerFrame.GetColor(2, 70); + var backgroundColor = layerFrame.GetColor(36, 20); + var layersIsolated = layerFrame.IsColor(22, 70, Colors.Gray) + && layerFrame.IsColor(2, 70, Colors.Yellow) + && layerFrame.IsColor(36, 20, Colors.Black); + + var clipBounds = new Rect(60, 40, 60, 60); + var clipFrame = CaptureFrame( + new ScopedBlurEffectGlowPrototype(Brushes.Cyan, 0.65, 24), + new EllipseGeometry(new Rect(70, 50, 40, 40)), + 1, + new GlowCaptureOptions(ClipBounds: clipBounds)); + var clipIsStrict = clipFrame.CountVisibleOutside(clipBounds) == 0; + + var localGeometry = new EllipseGeometry(new Rect(0, 0, 40, 40)); + var fullScaleFrame = CaptureFrame( + new ScopedBlurEffectGlowPrototype(Brushes.Cyan, 0.65, 12), + localGeometry, + 1, + new GlowCaptureOptions(ContentScale: 1, ContentOffset: new Vector(70, 50))); + var halfScaleFrame = CaptureFrame( + new ScopedBlurEffectGlowPrototype(Brushes.Cyan, 0.65, 12), + localGeometry, + 1, + new GlowCaptureOptions(ContentScale: 0.5, ContentOffset: new Vector(70, 50))); + var fullHalo = CalculateMaximumHalo( + fullScaleFrame.GetVisibleBounds(), + new Rect(70, 50, 40, 40)); + var halfHalo = CalculateMaximumHalo( + halfScaleFrame.GetVisibleBounds(), + new Rect(70, 50, 20, 20)); + var scaleRatio = halfHalo / fullHalo; + var scaleFollowsContent = scaleRatio is >= 0.35 and <= 0.65; + + var results = new List + { + new GlowBehaviorValidationResult( + "ScopedEffect.BrushOpacityReuse", + brushOpacityReuse, + $"EffectBuildCount={renderer.EffectBuildCount}"), + new GlowBehaviorValidationResult( + "ScopedEffect.RadiusUpdatesCurrentEffect", + radiusReuse, + $"EffectBuildCount={renderer.EffectBuildCount}, RadiusUpdateCount={renderer.RadiusUpdateCount}"), + new GlowBehaviorValidationResult( + "ScopedEffect.LayerIsolation", + layersIsolated, + $"Background={backgroundColor}, Inactive={inactiveColor}, Border={borderColor}"), + new GlowBehaviorValidationResult( + "ScopedEffect.StrictClip", + clipIsStrict, + $"VisibleOutsideClip={clipFrame.CountVisibleOutside(clipBounds)}"), + new GlowBehaviorValidationResult( + "ScopedEffect.ScaleFollowsContent", + scaleFollowsContent, + $"FullHalo={fullHalo:0.00}, HalfHalo={halfHalo:0.00}, Ratio={scaleRatio:0.000}") + }; + + foreach (var renderScaling in new[] { 1d, 1.25, 1.5, 2d }) + { + foreach (var radius in new[] { 6d, 12d, 24d }) + { + var groupedFrame = CaptureGroupedFrame(renderScaling, radius); + var sourceBounds = new Rect(52, 48, 84, 24); + var outsidePixels = AnalyzePixels(groupedFrame, renderScaling, sourceBounds) + .VisibleOutsideSourcePixels; + results.Add(new GlowBehaviorValidationResult( + $"ScopedEffect.Grouped.Dpi{renderScaling:0.##}.Radius{radius:0}", + outsidePixels > 0, + $"VisibleOutsideSourcePixels={outsidePixels}")); + } + } + + return results; + } + + private static double CalculateMaximumHalo(Rect visibleBounds, Rect sourceBounds) + { + return new[] + { + sourceBounds.Left - visibleBounds.Left, + visibleBounds.Right - sourceBounds.Right, + sourceBounds.Top - visibleBounds.Top, + visibleBounds.Bottom - sourceBounds.Bottom + }.Max(); + } + + private static GlowPixelResult Capture( + IGlowPrototypeRenderer renderer, + Geometry sourceGeometry, + double renderScaling) + { + var frame = CaptureFrame( + renderer, + sourceGeometry, + renderScaling, + new GlowCaptureOptions(ContentScale: 1)); + return AnalyzePixels(frame, renderScaling, sourceGeometry.Bounds); + } + + private static PixelFrame CaptureFrame( + IGlowPrototypeRenderer renderer, + Geometry sourceGeometry, + double renderScaling, + GlowCaptureOptions options) + { + var control = new GlowPrototypeControl(renderer, sourceGeometry, options) + { + Width = Width, + Height = Height + }; + var window = new Window + { + Width = Width, + Height = Height, + Background = Brushes.Black, + Content = control + }; + + try + { + window.Show(); + window.SetRenderScaling(renderScaling); + Dispatcher.UIThread.RunJobs(); + using var frame = window.CaptureRenderedFrame(); + if (frame is null) + { + throw new InvalidOperationException("Headless renderer did not produce a frame."); + } + + using var framebuffer = frame.Lock(); + 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, + renderScaling, + framebuffer.Format); + } + finally + { + window.Close(); + } + } + + private static PixelFrame CaptureGroupedFrame(double renderScaling, double radius) + { + var control = new GroupedGlowPrototypeControl(radius) + { + Width = Width, + Height = Height + }; + var window = new Window + { + Width = Width, + Height = Height, + Background = Brushes.Black, + Content = control + }; + + try + { + window.Show(); + window.SetRenderScaling(renderScaling); + Dispatcher.UIThread.RunJobs(); + using var frame = window.CaptureRenderedFrame(); + if (frame is null) + { + throw new InvalidOperationException("Headless renderer did not produce a grouped frame."); + } + + using var framebuffer = frame.Lock(); + 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, + renderScaling, + framebuffer.Format); + } + finally + { + window.Close(); + } + } + + private static GlowPixelResult AnalyzePixels( + PixelFrame frame, + double renderScaling, + Rect sourceBounds) + { + var inside = 0; + var outside = 0; + for (var y = 0; y < frame.Height; y++) + { + for (var x = 0; x < frame.Width; x++) + { + if (!frame.IsVisible(x, y)) + { + continue; + } + + var point = new Point((x + 0.5) / renderScaling, (y + 0.5) / renderScaling); + if (sourceBounds.Contains(point)) + { + inside++; + } + else + { + outside++; + } + } + } + + return new GlowPixelResult(outside, inside); + } + + private static GlowPerformanceMeasurement Measure( + IGlowPrototypeRenderer renderer, + Geometry sourceGeometry, + int frameCount) + { + for (var i = 0; i < 20; i++) + { + RenderToDrawingGroup(renderer, sourceGeometry); + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + var stopwatch = Stopwatch.StartNew(); + for (var i = 0; i < frameCount; i++) + { + _drawingSink = RenderToDrawingGroup(renderer, sourceGeometry); + } + + stopwatch.Stop(); + return new GlowPerformanceMeasurement( + stopwatch.Elapsed, + GC.GetAllocatedBytesForCurrentThread() - allocatedBefore); + } + + private static DrawingGroup RenderToDrawingGroup( + IGlowPrototypeRenderer renderer, + Geometry sourceGeometry) + { + var drawing = new DrawingGroup(); + using var context = drawing.Open(); + renderer.Render(context, sourceGeometry); + context.DrawGeometry(Brushes.White, null, sourceGeometry); + return drawing; + } + + private static string RenderSummary( + IReadOnlyList pixelResults, + IReadOnlyList performanceResults, + IReadOnlyList validationResults) + { + var builder = new StringBuilder(); + builder.AppendLine($"Glow prototype pixel cases: {pixelResults.Count}"); + foreach (var route in pixelResults.GroupBy(item => item.Route)) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"{route.Key}: passed={route.Count(item => item.VisibleOutsideSourcePixels > 0)}/{route.Count()}, minOutsidePixels={route.Min(item => item.VisibleOutsideSourcePixels)}"); + } + + builder.AppendLine("Command-submission smoke (40x40 circle, radius 12, opacity 0.65)"); + builder.AppendLine("Route | Frames | us/frame | bytes/frame"); + foreach (var result in performanceResults) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"{result.Route} | {result.FrameCount} | {result.MicrosecondsPerFrame:0.00} | {result.BytesPerFrame:0.0}"); + } + + foreach (var result in validationResults) + { + builder.AppendLine($"{result.Name}: {(result.Passed ? "PASS" : "FAIL")} ({result.Detail})"); + } + + return builder.ToString(); + } + + private static string RenderMarkdown( + IReadOnlyList pixelResults, + IReadOnlyList performanceResults, + IReadOnlyList validationResults) + { + var builder = new StringBuilder(); + builder.AppendLine("# LED Glow Prototype Smoke Evaluation"); + builder.AppendLine(); + builder.AppendLine($"- Date: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}"); + builder.AppendLine("- Scope: Headless pixel feasibility plus CPU-side DrawingGroup command submission"); + builder.AppendLine("- Corpus: 3 Matrix shapes and 4 Segment geometry categories"); + builder.AppendLine("- Matrix: 2 routes x 7 geometries x 4 brushes x 3 radii x 4 RenderScaling values = 672 pixel cases"); + builder.AppendLine("- Warning: command-submission timings are single-process smoke data and exclude real GPU presentation cost"); + builder.AppendLine(); + builder.AppendLine("## Pixel Feasibility"); + builder.AppendLine(); + builder.AppendLine("| Route | Passed | Total | Minimum visible pixels outside source bounds |"); + builder.AppendLine("|---|---:|---:|---:|"); + foreach (var route in pixelResults.GroupBy(item => item.Route)) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"| {route.Key} | {route.Count(item => item.VisibleOutsideSourcePixels > 0)} | {route.Count()} | {route.Min(item => item.VisibleOutsideSourcePixels)} |"); + } + + builder.AppendLine(); + builder.AppendLine("## Command Submission Smoke"); + builder.AppendLine(); + builder.AppendLine("| Route | Frames | us/frame | bytes/frame |"); + builder.AppendLine("|---|---:|---:|---:|"); + foreach (var result in performanceResults) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"| {result.Route} | {result.FrameCount} | {result.MicrosecondsPerFrame:0.00} | {result.BytesPerFrame:0.0} |"); + } + + builder.AppendLine(); + builder.AppendLine("## Behavior Counters"); + builder.AppendLine(); + builder.AppendLine("| Validation | Result | Detail |"); + builder.AppendLine("|---|---|---|"); + foreach (var result in validationResults) + { + builder.AppendLine($"| {result.Name} | {(result.Passed ? "PASS" : "FAIL")} | {result.Detail} |"); + } + + return builder.ToString(); + } + + private sealed class GlowPrototypeControl( + IGlowPrototypeRenderer renderer, + Geometry sourceGeometry, + GlowCaptureOptions options) : Control + { + public override void Render(DrawingContext context) + { + base.Render(context); + context.DrawRectangle(Brushes.Black, null, new Rect(Bounds.Size)); + if (options.Decorations) + { + context.DrawRectangle(Brushes.Gray, null, new Rect(16, 52, 20, 36)); + } + + if (options.ClipBounds is { } clipBounds) + { + using (context.PushClip(clipBounds)) + { + RenderSource(context); + } + } + else + { + RenderSource(context); + } + + if (options.Decorations) + { + context.DrawRectangle( + null, + new Pen(Brushes.Yellow, 4), + new RoundedRect(new Rect(Bounds.Size).Deflate(2))); + } + } + + private void RenderSource(DrawingContext context) + { + using (context.PushTransform( + Avalonia.Matrix.CreateScale(options.ContentScale, options.ContentScale) + * Avalonia.Matrix.CreateTranslation(options.ContentOffset.X, options.ContentOffset.Y))) + { + renderer.Render(context, sourceGeometry); + context.DrawGeometry(Brushes.White, null, sourceGeometry); + } + } + } + + private sealed class GroupedGlowPrototypeControl : Control + { + private static readonly Geometry Source = new EllipseGeometry(new Rect(0, 0, 24, 24)); + private static readonly IReadOnlyList Offsets = + [ + new Point(52, 48), + new Point(82, 48), + new Point(112, 48) + ]; + private readonly ScopedBlurEffectGlowPrototype _renderer; + + public GroupedGlowPrototypeControl(double radius) + { + _renderer = new ScopedBlurEffectGlowPrototype(Brushes.Cyan, 0.65, radius); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + context.DrawRectangle(Brushes.Black, null, new Rect(Bounds.Size)); + _renderer.RenderMany(context, Source, Offsets, 0, Offsets.Count, new Rect(52, 48, 84, 24)); + foreach (var offset in Offsets) + { + using (context.PushTransform(Avalonia.Matrix.CreateTranslation(offset.X, offset.Y))) + { + context.DrawGeometry(Brushes.White, null, Source); + } + } + } + } + + private readonly record struct GlowCaptureOptions( + Rect? ClipBounds = null, + bool Decorations = false, + double ContentScale = 1, + Vector ContentOffset = default); + + private sealed record PixelFrame( + byte[] Bytes, + int Width, + int Height, + int RowBytes, + double RenderScaling, + PixelFormat Format) + { + public bool IsColor(int logicalX, int logicalY, Color expected, int tolerance = 3) + { + var actual = GetColor(logicalX, logicalY); + return Math.Abs(actual.B - expected.B) <= tolerance + && Math.Abs(actual.G - expected.G) <= tolerance + && Math.Abs(actual.R - expected.R) <= tolerance; + } + + public Color GetColor(int logicalX, int logicalY) + { + var x = Math.Clamp((int)(logicalX * RenderScaling), 0, Width - 1); + var y = Math.Clamp((int)(logicalY * RenderScaling), 0, Height - 1); + var offset = y * RowBytes + x * 4; + return Format == PixelFormat.Rgba8888 + ? Color.FromArgb( + Bytes[offset + 3], + Bytes[offset], + Bytes[offset + 1], + Bytes[offset + 2]) + : Color.FromArgb( + Bytes[offset + 3], + Bytes[offset + 2], + Bytes[offset + 1], + Bytes[offset]); + } + + public int CountVisibleOutside(Rect logicalBounds) + { + var count = 0; + for (var y = 0; y < Height; y++) + { + for (var x = 0; x < Width; x++) + { + var point = new Point((x + 0.5) / RenderScaling, (y + 0.5) / RenderScaling); + if (!logicalBounds.Contains(point) && IsVisible(x, y)) + { + count++; + } + } + } + + return count; + } + + public Rect GetVisibleBounds() + { + var minX = Width; + var minY = Height; + var maxX = -1; + var maxY = -1; + for (var y = 0; y < Height; y++) + { + for (var x = 0; x < Width; x++) + { + if (!IsVisible(x, y)) + { + continue; + } + + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x); + maxY = Math.Max(maxY, y); + } + } + + return maxX < minX + ? default + : new Rect( + minX / RenderScaling, + minY / RenderScaling, + (maxX - minX + 1) / RenderScaling, + (maxY - minY + 1) / RenderScaling); + } + + public bool IsVisible(int x, int y) + { + var offset = y * RowBytes + x * 4; + return Bytes[offset] > 8 || Bytes[offset + 1] > 8 || Bytes[offset + 2] > 8; + } + } + + private sealed record GlowPrototypeCase(string Name, Geometry Geometry); + + private sealed record GlowBrushCase(string Name, IBrush Brush); + + private readonly record struct GlowPixelResult( + int VisibleOutsideSourcePixels, + int VisibleInsideSourcePixels); + + private readonly record struct GlowPerformanceMeasurement(TimeSpan Elapsed, long AllocatedBytes); + + private sealed record GlowPrototypePixelResult( + string Route, + string Geometry, + string Brush, + double Radius, + double RenderScaling, + int VisibleOutsideSourcePixels, + int VisibleInsideSourcePixels); + + private sealed record GlowPrototypePerformanceResult( + string Route, + int FrameCount, + TimeSpan Elapsed, + long AllocatedBytes) + { + public double MicrosecondsPerFrame => Elapsed.TotalMilliseconds * 1000 / FrameCount; + + public double BytesPerFrame => AllocatedBytes / (double)FrameCount; + } + + private sealed record GlowBehaviorValidationResult(string Name, bool Passed, string Detail); +} + +internal interface IGlowPrototypeRenderer +{ + string Name { get; } + + void Render(DrawingContext context, Geometry sourceGeometry); +} + +internal sealed class NoGlowPrototype : IGlowPrototypeRenderer +{ + public string Name => "Baseline.NoGlow"; + + public void Render(DrawingContext context, Geometry sourceGeometry) + { + } +} + +internal sealed class OpacityOnlyGlowPrototype : IGlowPrototypeRenderer +{ + private readonly IBrush _brush; + private readonly double _opacity; + + public OpacityOnlyGlowPrototype(IBrush brush, double opacity) + { + _brush = brush; + _opacity = opacity; + } + + public string Name => "OpacityOnly"; + + public void Render(DrawingContext context, Geometry sourceGeometry) + { + using (context.PushOpacity(_opacity)) + { + context.DrawGeometry(_brush, null, sourceGeometry); + } + } +} + +internal sealed class VectorExpansionGlowPrototype : IGlowPrototypeRenderer +{ + private readonly IBrush _brush; + private readonly double _opacity; + private readonly IReadOnlyList _pens; + + public VectorExpansionGlowPrototype(IBrush brush, double opacity, double radius) + { + _brush = brush; + _opacity = opacity; + _pens = new[] + { + new Pen(brush, radius * 2), + new Pen(brush, radius * 1.5), + new Pen(brush, radius), + new Pen(brush, radius * 0.5) + }; + } + + public string Name => "A.VectorExpansion"; + + public void Render(DrawingContext context, Geometry sourceGeometry) + { + for (var i = 0; i < _pens.Count; i++) + { + var layerOpacity = _opacity * (i + 1) / (_pens.Count * 2); + using (context.PushOpacity(layerOpacity)) + { + context.DrawGeometry(null, _pens[i], sourceGeometry); + } + } + + using (context.PushOpacity(_opacity * 0.25)) + { + context.DrawGeometry(_brush, null, sourceGeometry); + } + } +} + +internal sealed class ScopedBlurEffectGlowPrototype : IGlowPrototypeRenderer +{ + private IBrush _brush; + private double _opacity; + private readonly BlurEffect _effect; + + public ScopedBlurEffectGlowPrototype(IBrush brush, double opacity, double radius) + { + _brush = brush; + _opacity = opacity; + _effect = new BlurEffect { Radius = radius }; + EffectBuildCount = 1; + } + + public string Name => "C.ScopedBlurEffect"; + + public int EffectBuildCount { get; } + + public int RadiusUpdateCount { get; private set; } + + public void UpdateBrush(IBrush brush) + { + _brush = brush; + } + + public void UpdateOpacity(double opacity) + { + _opacity = opacity; + } + + public void UpdateRadius(double radius) + { + _effect.Radius = radius; + RadiusUpdateCount++; + } + + public void Render(DrawingContext context, Geometry sourceGeometry) + { + using (context.PushOpacity(_opacity)) + using (context.PushEffect(_effect, sourceGeometry.Bounds)) + { + context.DrawGeometry(_brush, null, sourceGeometry); + } + } + + public void RenderMany( + DrawingContext context, + Geometry sourceGeometry, + IReadOnlyList offsets, + int start, + int count, + Rect effectBounds) + { + using (context.PushOpacity(_opacity)) + using (context.PushEffect(_effect, effectBounds)) + { + for (var i = start; i < start + count; i++) + { + var offset = offsets[i]; + using (context.PushTransform(Avalonia.Matrix.CreateTranslation(offset.X, offset.Y))) + { + context.DrawGeometry(_brush, null, sourceGeometry); + } + } + } + } +} diff --git a/tools/performances/AtomUI.Labs.Led.Performance/Program.cs b/tools/performances/AtomUI.Labs.Led.Performance/Program.cs new file mode 100644 index 0000000..23f9a8d --- /dev/null +++ b/tools/performances/AtomUI.Labs.Led.Performance/Program.cs @@ -0,0 +1,708 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using AtomUI.Labs.Led.Matrix; +using Avalonia; +using Avalonia.Headless; +using Avalonia.Media; + +namespace AtomUI.Labs.Led.Performance; + +internal static class Program +{ + private const int DefaultCount = 20; + private const int DefaultDynamicFrames = 600; + private const int DefaultSoakFrames = 36_000; + private const int DynamicWarmupFrames = 20; + private const int DynamicTextPoolSize = 1_000; + + private static string? _textSink; + private static DrawingGroup? _drawingSink; + + [STAThread] + public static int Main(string[] args) + { + var options = PerformanceOptions.Parse(args); + AppBuilder.Configure() + .UseHeadless(new AvaloniaHeadlessPlatformOptions + { + UseHeadlessDrawing = false + }) + .UseSkia() + .SetupWithoutStarting(); + + if (options.RunGlowPrototypes) + { + return GlowPrototypeRunner.Run(options.Count, options.MarkdownOutputPath); + } + + if (options.RunFormalGlow) + { + return FormalGlowPerformanceRunner.Run(options.DynamicFrames, options.MarkdownOutputPath); + } + + var results = new[] + { + MeasureMatrixRender("Matrix.CachedRender.8.Clip", "12345678", MatrixOverflowMode.Clip, options.Count, false), + MeasureMatrixRender("Matrix.DynamicText.8.Clip", "00000000", MatrixOverflowMode.Clip, options.Count, true), + MeasureMatrixRender("Matrix.CachedRender.1000.Clip", new string('8', 1_000), MatrixOverflowMode.Clip, options.Count, false), + MeasureMatrixRender("Matrix.CachedRender.10000.Clip", new string('8', 10_000), MatrixOverflowMode.Clip, options.Count, false), + MeasureMatrixRender("Matrix.CachedRender.1000.ScaleDown", new string('8', 1_000), MatrixOverflowMode.ScaleDown, options.Count, false) + }; + var dynamicResults = new[] + { + MeasureDynamicLoad(6, false, options.DynamicFrames), + MeasureDynamicLoad(6, true, options.DynamicFrames), + MeasureDynamicLoad(8, false, options.DynamicFrames), + MeasureDynamicLoad(8, true, options.DynamicFrames), + MeasureDynamicLoad(16, false, options.DynamicFrames), + MeasureDynamicLoad(16, true, options.DynamicFrames) + }; + var allocationResults = MeasureAllocationAttribution(options.DynamicFrames); + var soakResult = MeasureDynamicSoak(options.SoakFrames); + + Console.WriteLine(RenderTable(results)); + Console.WriteLine(RenderDynamicLoadTable(dynamicResults)); + Console.WriteLine(RenderAllocationTable(allocationResults)); + Console.WriteLine(RenderSoakTable(soakResult)); + if (!string.IsNullOrWhiteSpace(options.MarkdownOutputPath)) + { + var fullPath = Path.GetFullPath(options.MarkdownOutputPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText( + fullPath, + RenderMarkdown(results, dynamicResults, allocationResults, soakResult), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + Console.WriteLine($"Wrote markdown result: {fullPath}"); + } + + return 0; + } + + private static MatrixDynamicLoadResult MeasureDynamicLoad( + int characterCount, + bool showInactiveDots, + int frameCount) + { + var display = new MatrixDisplay + { + DotSize = 6, + DotSpacing = 2, + CharacterSpacing = 8, + Padding = default, + ActiveBrush = Brushes.White, + InactiveBrush = showInactiveDots ? Brushes.Gray : null, + ShowInactiveDots = showInactiveDots + }; + var viewport = new Size(800, 80); + var bounds = new Rect(viewport); + for (var frame = 0; frame < DynamicWarmupFrames; frame++) + { + UpdateDynamicDisplay(display, viewport, bounds, characterCount, frame); + } + + var geometryBuildCount = display.GeometryBuildCount; + var layoutVersion = display.LayoutCacheVersion; + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + var stopwatch = Stopwatch.StartNew(); + for (var frame = DynamicWarmupFrames; frame < frameCount + DynamicWarmupFrames; frame++) + { + UpdateDynamicDisplay(display, viewport, bounds, characterCount, frame); + } + + stopwatch.Stop(); + var allocatedBytes = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; + var drawing = Render(display); + return new MatrixDynamicLoadResult( + characterCount, + showInactiveDots, + frameCount, + stopwatch.Elapsed, + allocatedBytes, + display.LayoutCacheVersion - layoutVersion, + display.GeometryBuildCount - geometryBuildCount, + display.GeometryCacheCount, + CountGeometryDrawings(drawing)); + } + + private static void UpdateDynamicDisplay( + MatrixDisplay display, + Size viewport, + Rect bounds, + int characterCount, + int frame) + { + UpdateDynamicDisplay(display, viewport, bounds, CreateDynamicText(characterCount, frame)); + } + + private static void UpdateDynamicDisplay( + MatrixDisplay display, + Size viewport, + Rect bounds, + string text) + { + display.Text = text; + display.Measure(viewport); + display.Arrange(bounds); + _drawingSink = Render(display); + } + + private static string CreateDynamicText(int characterCount, int value) + { + return characterCount switch + { + 6 => (value % 1_000_000).ToString("D6", CultureInfo.InvariantCulture), + 8 => (value % 100_000_000).ToString("D8", CultureInfo.InvariantCulture), + 16 => "TEMP" + + (value % 10_000).ToString("D4", CultureInfo.InvariantCulture) + + "RPM" + + (value % 100_000).ToString("D5", CultureInfo.InvariantCulture), + _ => throw new ArgumentOutOfRangeException(nameof(characterCount)) + }; + } + + private static string[] CreateDynamicTextPool(int characterCount, int count) + { + var texts = new string[count]; + for (var i = 0; i < texts.Length; i++) + { + texts[i] = CreateDynamicText(characterCount, i); + } + + return texts; + } + + private static IReadOnlyList MeasureAllocationAttribution(int frameCount) + { + var viewport = new Size(800, 80); + var bounds = new Rect(viewport); + var texts = CreateDynamicTextPool(16, Math.Max(frameCount, DynamicWarmupFrames)); + + var layoutDisplay = CreateDynamicDisplay(true); + WarmDynamicDisplay(layoutDisplay, viewport, bounds, texts); + + var emptyRenderDisplay = CreateDynamicDisplay(false); + emptyRenderDisplay.Text = string.Empty; + emptyRenderDisplay.Measure(viewport); + emptyRenderDisplay.Arrange(bounds); + _drawingSink = Render(emptyRenderDisplay); + + var activeRenderDisplay = CreateDynamicDisplay(false); + WarmDynamicDisplay(activeRenderDisplay, viewport, bounds, texts); + + var dualLayerRenderDisplay = CreateDynamicDisplay(true); + WarmDynamicDisplay(dualLayerRenderDisplay, viewport, bounds, texts); + + var dynamicDisplay = CreateDynamicDisplay(true); + WarmDynamicDisplay(dynamicDisplay, viewport, bounds, texts); + + var endToEndDisplay = CreateDynamicDisplay(true); + WarmDynamicDisplay(endToEndDisplay, viewport, bounds, texts); + + return new[] + { + MeasureAllocationStage( + "Caller.TextFormatting.16", + frameCount, + frame => _textSink = CreateDynamicText(16, frame)), + MeasureAllocationStage( + "Harness.EmptyDrawingGroup", + frameCount, + _ => _drawingSink = RenderEmpty()), + MeasureAllocationStage( + "Matrix.LayoutOnly.Precomputed.16", + frameCount, + frame => + { + layoutDisplay.Text = texts[frame % texts.Length]; + layoutDisplay.Measure(viewport); + layoutDisplay.Arrange(bounds); + }), + MeasureAllocationStage( + "Matrix.CachedRenderOnly.EmptyText", + frameCount, + _ => _drawingSink = Render(emptyRenderDisplay)), + MeasureAllocationStage( + "Matrix.CachedRenderOnly.16.InactiveOff", + frameCount, + _ => _drawingSink = Render(activeRenderDisplay)), + MeasureAllocationStage( + "Matrix.CachedRenderOnly.16.InactiveOn", + frameCount, + _ => _drawingSink = Render(dualLayerRenderDisplay)), + MeasureAllocationStage( + "Matrix.DynamicPrecomputed.16.InactiveOn", + frameCount, + frame => UpdateDynamicDisplay( + dynamicDisplay, + viewport, + bounds, + texts[frame % texts.Length])), + MeasureAllocationStage( + "EndToEnd.DynamicFormatted.16.InactiveOn", + frameCount, + frame => UpdateDynamicDisplay( + endToEndDisplay, + viewport, + bounds, + CreateDynamicText(16, frame))) + }; + } + + private static MatrixAllocationResult MeasureAllocationStage( + string name, + int operationCount, + Action operation) + { + _textSink = null; + _drawingSink = null; + ForceFullCollection(); + + var stopwatch = new Stopwatch(); + var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + stopwatch.Start(); + for (var operationIndex = 0; operationIndex < operationCount; operationIndex++) + { + operation(operationIndex); + } + + stopwatch.Stop(); + return new MatrixAllocationResult( + name, + operationCount, + stopwatch.Elapsed, + GC.GetAllocatedBytesForCurrentThread() - allocatedBefore); + } + + private static MatrixSoakResult MeasureDynamicSoak(int frameCount) + { + var viewport = new Size(800, 80); + var bounds = new Rect(viewport); + var textPool = CreateDynamicTextPool(16, Math.Min(DynamicTextPoolSize, frameCount)); + var display = CreateDynamicDisplay(true); + WarmDynamicDisplay(display, viewport, bounds, textPool); + + var layoutVersion = display.LayoutCacheVersion; + var geometryBuildCount = display.GeometryBuildCount; + var initialGeometryCacheCount = display.GeometryCacheCount; + _textSink = null; + _drawingSink = null; + ForceFullCollection(); + + var retainedBytesBefore = GC.GetTotalMemory(forceFullCollection: false); + var gen0Before = GC.CollectionCount(0); + var gen1Before = GC.CollectionCount(1); + var gen2Before = GC.CollectionCount(2); + var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + var peakManagedBytes = retainedBytesBefore; + var sampleInterval = Math.Max(1, frameCount / 20); + var stopwatch = Stopwatch.StartNew(); + for (var frame = 0; frame < frameCount; frame++) + { + UpdateDynamicDisplay( + display, + viewport, + bounds, + textPool[frame % textPool.Length]); + if ((frame + 1) % sampleInterval == 0) + { + peakManagedBytes = Math.Max(peakManagedBytes, GC.GetTotalMemory(forceFullCollection: false)); + } + } + + stopwatch.Stop(); + var allocatedBytes = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; + var gen0Collections = GC.CollectionCount(0) - gen0Before; + var gen1Collections = GC.CollectionCount(1) - gen1Before; + var gen2Collections = GC.CollectionCount(2) - gen2Before; + _drawingSink = null; + ForceFullCollection(); + var retainedBytesAfter = GC.GetTotalMemory(forceFullCollection: false); + var drawing = Render(display); + + return new MatrixSoakResult( + frameCount, + stopwatch.Elapsed, + allocatedBytes, + gen0Collections, + gen1Collections, + gen2Collections, + retainedBytesBefore, + retainedBytesAfter, + peakManagedBytes, + display.LayoutCacheVersion - layoutVersion, + display.GeometryBuildCount - geometryBuildCount, + initialGeometryCacheCount, + display.GeometryCacheCount, + CountGeometryDrawings(drawing)); + } + + private static MatrixDisplay CreateDynamicDisplay(bool showInactiveDots) + { + return new MatrixDisplay + { + DotSize = 6, + DotSpacing = 2, + CharacterSpacing = 8, + Padding = default, + ActiveBrush = Brushes.White, + InactiveBrush = showInactiveDots ? Brushes.Gray : null, + ShowInactiveDots = showInactiveDots + }; + } + + private static void WarmDynamicDisplay( + MatrixDisplay display, + Size viewport, + Rect bounds, + IReadOnlyList texts) + { + for (var frame = 0; frame < DynamicWarmupFrames; frame++) + { + UpdateDynamicDisplay(display, viewport, bounds, texts[frame % texts.Count]); + } + } + + private static void ForceFullCollection() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + private static MatrixPerformanceResult MeasureMatrixRender( + string name, + string text, + MatrixOverflowMode overflowMode, + int updateCount, + bool updateText) + { + var display = new MatrixDisplay + { + Text = text, + DotSize = 6, + DotSpacing = 2, + CharacterSpacing = 8, + Padding = default, + Width = 320, + Height = 80, + OverflowMode = overflowMode, + ActiveBrush = Brushes.White, + InactiveBrush = null, + ShowInactiveDots = false + }; + var viewport = new Size(320, 80); + var bounds = new Rect(viewport); + display.Measure(viewport); + display.Arrange(bounds); + + for (var i = 0; i < 10; i++) + { + UpdateAndRender(display, viewport, bounds, updateText, i); + } + + var submittedGeometryCommands = CountGeometryDrawings(Render(display)); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + var stopwatch = Stopwatch.StartNew(); + for (var i = 0; i < updateCount; i++) + { + UpdateAndRender(display, viewport, bounds, updateText, i); + } + + stopwatch.Stop(); + return new MatrixPerformanceResult( + name, + text.Length, + updateCount, + stopwatch.Elapsed, + GC.GetAllocatedBytesForCurrentThread() - allocatedBefore, + submittedGeometryCommands); + } + + private static void UpdateAndRender( + MatrixDisplay display, + Size viewport, + Rect bounds, + bool updateText, + int iteration) + { + if (updateText) + { + display.Text = iteration.ToString("D8", CultureInfo.InvariantCulture); + display.Measure(viewport); + display.Arrange(bounds); + } + + Render(display); + } + + private static DrawingGroup Render(MatrixDisplay display) + { + var drawing = new DrawingGroup(); + using var context = drawing.Open(); + display.Render(context); + return drawing; + } + + private static DrawingGroup RenderEmpty() + { + var drawing = new DrawingGroup(); + using var context = drawing.Open(); + return drawing; + } + + private static int CountGeometryDrawings(Drawing drawing) + { + if (drawing is GeometryDrawing) + { + return 1; + } + + return drawing is DrawingGroup group + ? group.Children.Sum(CountGeometryDrawings) + : 0; + } + + private static string RenderTable(IReadOnlyList results) + { + var builder = new StringBuilder(); + builder.AppendLine("Scenario Chars Updates Total ms us/update KB total bytes/update geometry commands"); + builder.AppendLine("-------------------------------------------------------------------------------------------------------------------"); + foreach (var result in results) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"{result.Name,-42}{result.TextLength,7}{result.UpdateCount,9}{result.Elapsed.TotalMilliseconds,10:0.00}{result.MicrosecondsPerUpdate,11:0.00}{result.AllocatedBytes / 1024.0,10:0.0}{result.BytesPerUpdate,14:0.0}{result.SubmittedGeometryCommands,18}"); + } + + return builder.ToString(); + } + + private static string RenderDynamicLoadTable(IReadOnlyList results) + { + var builder = new StringBuilder(); + builder.AppendLine("Dynamic load Inactive Frames Total ms us/frame bytes/frame Layout builds Geometry builds Cache Commands/frame"); + builder.AppendLine("---------------------------------------------------------------------------------------------------------------------"); + foreach (var result in results) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"{result.CharacterCount + " chars",-14}{(result.ShowInactiveDots ? "On" : "Off"),-10}{result.FrameCount,7}{result.Elapsed.TotalMilliseconds,10:0.00}{result.MicrosecondsPerFrame,10:0.00}{result.BytesPerFrame,13:0.0}{result.LayoutBuilds,15}{result.GeometryBuilds,17}{result.GeometryCacheCount,7}{result.GeometryCommandsPerFrame,16}"); + } + + return builder.ToString(); + } + + private static string RenderAllocationTable(IReadOnlyList results) + { + var builder = new StringBuilder(); + builder.AppendLine("Allocation attribution Operations Total ms us/op bytes/op"); + builder.AppendLine("--------------------------------------------------------------------------------------"); + foreach (var result in results) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"{result.Name,-50}{result.OperationCount,11}{result.Elapsed.TotalMilliseconds,10:0.00}{result.MicrosecondsPerOperation,8:0.00}{result.BytesPerOperation,11:0.0}"); + } + + return builder.ToString(); + } + + private static string RenderSoakTable(MatrixSoakResult result) + { + var builder = new StringBuilder(); + builder.AppendLine("Dynamic soak (16 chars, inactive dots on, precomputed text)"); + builder.AppendLine(CultureInfo.InvariantCulture, + $"Frames={result.FrameCount}, Total={result.Elapsed.TotalMilliseconds:0.00} ms, us/frame={result.MicrosecondsPerFrame:0.00}, bytes/frame={result.BytesPerFrame:0.0}"); + builder.AppendLine(CultureInfo.InvariantCulture, + $"Natural GC: Gen0={result.Gen0Collections}, Gen1={result.Gen1Collections}, Gen2={result.Gen2Collections}"); + builder.AppendLine(CultureInfo.InvariantCulture, + $"Managed live after full GC: before={result.RetainedBytesBefore}, after={result.RetainedBytesAfter}, delta={result.RetainedBytesDelta}"); + builder.AppendLine(CultureInfo.InvariantCulture, + $"Sampled managed peak={result.PeakManagedBytes}, Layout builds={result.LayoutBuilds}, Geometry builds={result.GeometryBuilds}, Cache={result.InitialGeometryCacheCount}->{result.FinalGeometryCacheCount}, Commands/frame={result.GeometryCommandsPerFrame}"); + return builder.ToString(); + } + + private static string RenderMarkdown( + IReadOnlyList results, + IReadOnlyList dynamicResults, + IReadOnlyList allocationResults, + MatrixSoakResult soakResult) + { + var builder = new StringBuilder(); + builder.AppendLine("# Matrix Interaction Performance Baseline"); + builder.AppendLine(); + builder.AppendLine($"- Date: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}"); + builder.AppendLine("- Configuration: Release, .NET 10"); + builder.AppendLine("- Runner: `tools/performances/AtomUI.Labs.Led.Performance --count --frames --soak-frames `"); + builder.AppendLine("- Scope: CPU-side layout and DrawingGroup command submission; excludes GPU/platform presentation cost"); + builder.AppendLine(); + builder.AppendLine("| Scenario | Characters | Updates | Total ms | us/update | KB total | bytes/update | Geometry commands |"); + builder.AppendLine("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); + foreach (var result in results) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"| {result.Name} | {result.TextLength} | {result.UpdateCount} | {result.Elapsed.TotalMilliseconds:0.00} | {result.MicrosecondsPerUpdate:0.00} | {result.AllocatedBytes / 1024.0:0.0} | {result.BytesPerUpdate:0.0} | {result.SubmittedGeometryCommands} |"); + } + + builder.AppendLine(); + builder.AppendLine("## Fixed-Length Dynamic Load"); + builder.AppendLine(); + builder.AppendLine("| Characters | Inactive dots | Frames | Total ms | us/frame | bytes/frame | Layout builds | Geometry builds | Final cache | Geometry commands/frame |"); + builder.AppendLine("| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); + foreach (var result in dynamicResults) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"| {result.CharacterCount} | {(result.ShowInactiveDots ? "On" : "Off")} | {result.FrameCount} | {result.Elapsed.TotalMilliseconds:0.00} | {result.MicrosecondsPerFrame:0.00} | {result.BytesPerFrame:0.0} | {result.LayoutBuilds} | {result.GeometryBuilds} | {result.GeometryCacheCount} | {result.GeometryCommandsPerFrame} |"); + } + + builder.AppendLine(); + builder.AppendLine("## Allocation Attribution"); + builder.AppendLine(); + builder.AppendLine("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."); + builder.AppendLine(); + builder.AppendLine("| Stage | Operations | Total ms | us/operation | bytes/operation |"); + builder.AppendLine("| --- | ---: | ---: | ---: | ---: |"); + foreach (var result in allocationResults) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"| {result.Name} | {result.OperationCount} | {result.Elapsed.TotalMilliseconds:0.00} | {result.MicrosecondsPerOperation:0.00} | {result.BytesPerOperation:0.0} |"); + } + + builder.AppendLine(); + builder.AppendLine("## Long-Running Soak"); + builder.AppendLine(); + builder.AppendLine("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."); + builder.AppendLine(); + builder.AppendLine("| 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 |"); + builder.AppendLine("| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: |"); + builder.AppendLine(CultureInfo.InvariantCulture, + $"| {soakResult.FrameCount} | {soakResult.Elapsed.TotalMilliseconds:0.00} | {soakResult.MicrosecondsPerFrame:0.00} | {soakResult.BytesPerFrame:0.0} | {soakResult.Gen0Collections} | {soakResult.Gen1Collections} | {soakResult.Gen2Collections} | {soakResult.RetainedBytesBefore} | {soakResult.RetainedBytesAfter} | {soakResult.RetainedBytesDelta} | {soakResult.PeakManagedBytes} | {soakResult.LayoutBuilds} | {soakResult.GeometryBuilds} | {soakResult.InitialGeometryCacheCount}->{soakResult.FinalGeometryCacheCount} | {soakResult.GeometryCommandsPerFrame} |"); + + return builder.ToString(); + } + + private sealed record MatrixPerformanceResult( + string Name, + int TextLength, + int UpdateCount, + TimeSpan Elapsed, + long AllocatedBytes, + int SubmittedGeometryCommands) + { + public double MicrosecondsPerUpdate => Elapsed.TotalMilliseconds * 1000 / UpdateCount; + + public double BytesPerUpdate => AllocatedBytes / (double)UpdateCount; + } + + private sealed record MatrixDynamicLoadResult( + int CharacterCount, + bool ShowInactiveDots, + int FrameCount, + TimeSpan Elapsed, + long AllocatedBytes, + int LayoutBuilds, + int GeometryBuilds, + int GeometryCacheCount, + int GeometryCommandsPerFrame) + { + public double MicrosecondsPerFrame => Elapsed.TotalMilliseconds * 1000 / FrameCount; + + public double BytesPerFrame => AllocatedBytes / (double)FrameCount; + } + + private sealed record MatrixAllocationResult( + string Name, + int OperationCount, + TimeSpan Elapsed, + long AllocatedBytes) + { + public double MicrosecondsPerOperation => Elapsed.TotalMilliseconds * 1000 / OperationCount; + + public double BytesPerOperation => AllocatedBytes / (double)OperationCount; + } + + private sealed record MatrixSoakResult( + int FrameCount, + TimeSpan Elapsed, + long AllocatedBytes, + int Gen0Collections, + int Gen1Collections, + int Gen2Collections, + long RetainedBytesBefore, + long RetainedBytesAfter, + long PeakManagedBytes, + int LayoutBuilds, + int GeometryBuilds, + int InitialGeometryCacheCount, + int FinalGeometryCacheCount, + int GeometryCommandsPerFrame) + { + public double MicrosecondsPerFrame => Elapsed.TotalMilliseconds * 1000 / FrameCount; + + public double BytesPerFrame => AllocatedBytes / (double)FrameCount; + + public long RetainedBytesDelta => RetainedBytesAfter - RetainedBytesBefore; + } + + private sealed record PerformanceOptions( + int Count, + int DynamicFrames, + int SoakFrames, + string? MarkdownOutputPath, + bool RunGlowPrototypes, + bool RunFormalGlow) + { + public static PerformanceOptions Parse(string[] args) + { + var count = DefaultCount; + var dynamicFrames = DefaultDynamicFrames; + var soakFrames = DefaultSoakFrames; + string? markdownOutputPath = null; + var runGlowPrototypes = false; + var runFormalGlow = false; + for (var i = 0; i < args.Length; i++) + { + if (args[i] == "--count" && i + 1 < args.Length && int.TryParse(args[++i], out var parsedCount)) + { + count = Math.Max(1, parsedCount); + } + else if (args[i] == "--frames" && i + 1 < args.Length && int.TryParse(args[++i], out var parsedFrames)) + { + dynamicFrames = Math.Max(1, parsedFrames); + } + else if (args[i] == "--soak-frames" && i + 1 < args.Length && int.TryParse(args[++i], out var parsedSoakFrames)) + { + soakFrames = Math.Max(1, parsedSoakFrames); + } + else if (args[i] == "--markdown" && i + 1 < args.Length) + { + markdownOutputPath = args[++i]; + } + else if (args[i] == "--glow-prototypes") + { + runGlowPrototypes = true; + } + else if (args[i] == "--formal-glow") + { + runFormalGlow = true; + } + } + + return new PerformanceOptions( + count, + dynamicFrames, + soakFrames, + markdownOutputPath, + runGlowPrototypes, + runFormalGlow); + } + } +} + +internal sealed class PerformanceApplication : Application; From 43b1744787b45d4eddb957ab6ceb4d6a096326d5 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 15:18:21 +0800 Subject: [PATCH 08/33] add Led controls of Segment --- .../Matrix/MatrixValueSanitizer.cs | 46 ++ .../Segment/Character/SegmentCharacterKind.cs | 9 + .../Segment/Character/SegmentCharacterMap.cs | 125 ++++ .../Character/SegmentCharacterPattern.cs | 6 + .../Segment/Character/SegmentParts.cs | 21 + .../Segment/Layout/SegmentCharacterSlot.cs | 8 + .../Segment/Layout/SegmentDisplayLayout.cs | 16 + .../Segment/Layout/SegmentLayoutEngine.cs | 84 +++ .../Segment/Layout/SegmentLayoutOptions.cs | 9 + .../Rendering/SegmentGeometryFactory.cs | 225 ++++++ .../Segment/Rendering/SegmentGeometryItem.cs | 8 + .../Rendering/SegmentGeometryOptions.cs | 7 + .../Segment/Rendering/SegmentGeometrySet.cs | 11 + .../Rendering/SegmentVisibleGeometry.cs | 7 + src/AtomUI.Labs.Led/Segment/SegmentDisplay.cs | 672 ++++++++++++++++++ .../Segment/SegmentDisplayAutomationPeer.cs | 55 ++ .../Segment/SegmentOverflowMode.cs | 7 + .../Segment/SegmentValueSanitizer.cs | 40 ++ .../Segment/Themes/SegmentDisplayTheme.axaml | 19 + .../Segment/Themes/SegmentThemes.axaml | 7 + .../ThemeManagerBuilderExtensions.cs | 12 + 21 files changed, 1394 insertions(+) create mode 100644 src/AtomUI.Labs.Led/Matrix/MatrixValueSanitizer.cs create mode 100644 src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterKind.cs create mode 100644 src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterMap.cs create mode 100644 src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterPattern.cs create mode 100644 src/AtomUI.Labs.Led/Segment/Character/SegmentParts.cs create mode 100644 src/AtomUI.Labs.Led/Segment/Layout/SegmentCharacterSlot.cs create mode 100644 src/AtomUI.Labs.Led/Segment/Layout/SegmentDisplayLayout.cs create mode 100644 src/AtomUI.Labs.Led/Segment/Layout/SegmentLayoutEngine.cs create mode 100644 src/AtomUI.Labs.Led/Segment/Layout/SegmentLayoutOptions.cs create mode 100644 src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryFactory.cs create mode 100644 src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryItem.cs create mode 100644 src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryOptions.cs create mode 100644 src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometrySet.cs create mode 100644 src/AtomUI.Labs.Led/Segment/Rendering/SegmentVisibleGeometry.cs create mode 100644 src/AtomUI.Labs.Led/Segment/SegmentDisplay.cs create mode 100644 src/AtomUI.Labs.Led/Segment/SegmentDisplayAutomationPeer.cs create mode 100644 src/AtomUI.Labs.Led/Segment/SegmentOverflowMode.cs create mode 100644 src/AtomUI.Labs.Led/Segment/SegmentValueSanitizer.cs create mode 100644 src/AtomUI.Labs.Led/Segment/Themes/SegmentDisplayTheme.axaml create mode 100644 src/AtomUI.Labs.Led/Segment/Themes/SegmentThemes.axaml create mode 100644 src/AtomUI.Labs.Led/ThemeManagerBuilderExtensions.cs diff --git a/src/AtomUI.Labs.Led/Matrix/MatrixValueSanitizer.cs b/src/AtomUI.Labs.Led/Matrix/MatrixValueSanitizer.cs new file mode 100644 index 0000000..fbde499 --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/MatrixValueSanitizer.cs @@ -0,0 +1,46 @@ +using Avalonia; + +namespace AtomUI.Labs.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.Led/Segment/Character/SegmentCharacterKind.cs b/src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterKind.cs new file mode 100644 index 0000000..6a7f159 --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterKind.cs @@ -0,0 +1,9 @@ +namespace AtomUI.Labs.Led.Segment.Character; + +internal enum SegmentCharacterKind +{ + Empty, + Segments, + Colon, + Dot +} diff --git a/src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterMap.cs b/src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterMap.cs new file mode 100644 index 0000000..6ec274d --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterMap.cs @@ -0,0 +1,125 @@ +using AtomUI.Labs.Led; + +namespace AtomUI.Labs.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.Led/Segment/Character/SegmentCharacterPattern.cs b/src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterPattern.cs new file mode 100644 index 0000000..04f6d59 --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterPattern.cs @@ -0,0 +1,6 @@ +namespace AtomUI.Labs.Led.Segment.Character; + +internal readonly record struct SegmentCharacterPattern( + char Character, + SegmentCharacterKind Kind, + SegmentParts Parts); diff --git a/src/AtomUI.Labs.Led/Segment/Character/SegmentParts.cs b/src/AtomUI.Labs.Led/Segment/Character/SegmentParts.cs new file mode 100644 index 0000000..073a05c --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/Character/SegmentParts.cs @@ -0,0 +1,21 @@ +namespace AtomUI.Labs.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.Led/Segment/Layout/SegmentCharacterSlot.cs b/src/AtomUI.Labs.Led/Segment/Layout/SegmentCharacterSlot.cs new file mode 100644 index 0000000..96ffe77 --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/Layout/SegmentCharacterSlot.cs @@ -0,0 +1,8 @@ +using Avalonia; +using AtomUI.Labs.Led.Segment.Character; + +namespace AtomUI.Labs.Led.Segment.Layout; + +internal readonly record struct SegmentCharacterSlot( + SegmentCharacterPattern Pattern, + Rect Bounds); diff --git a/src/AtomUI.Labs.Led/Segment/Layout/SegmentDisplayLayout.cs b/src/AtomUI.Labs.Led/Segment/Layout/SegmentDisplayLayout.cs new file mode 100644 index 0000000..670cc12 --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/Layout/SegmentDisplayLayout.cs @@ -0,0 +1,16 @@ +using Avalonia; + +namespace AtomUI.Labs.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.Led/Segment/Layout/SegmentLayoutEngine.cs b/src/AtomUI.Labs.Led/Segment/Layout/SegmentLayoutEngine.cs new file mode 100644 index 0000000..3f62f82 --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/Layout/SegmentLayoutEngine.cs @@ -0,0 +1,84 @@ +using Avalonia; +using AtomUI.Labs.Led.Segment; +using AtomUI.Labs.Led.Segment.Character; + +namespace AtomUI.Labs.Led.Segment.Layout; + +internal static class SegmentLayoutEngine +{ + private const double NarrowSymbolWidthRatio = 0.32; + + public static SegmentDisplayLayout Calculate( + string? text, + SegmentLayoutOptions options, + Size? finalSize = null) + { + 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); + if (finalSize.HasValue && SegmentValueSanitizer.IsFinite(finalSize.Value.Height)) + { + var constrainedHeight = SegmentValueSanitizer.CoerceNonNegative(finalSize.Value.Height - padding.Top - padding.Bottom); + if (constrainedHeight > 0) + { + characterHeight = constrainedHeight; + } + } + + var characterWidth = 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.Led/Segment/Layout/SegmentLayoutOptions.cs b/src/AtomUI.Labs.Led/Segment/Layout/SegmentLayoutOptions.cs new file mode 100644 index 0000000..4f70ddb --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/Layout/SegmentLayoutOptions.cs @@ -0,0 +1,9 @@ +using Avalonia; + +namespace AtomUI.Labs.Led.Segment.Layout; + +internal readonly record struct SegmentLayoutOptions( + double CharacterHeight, + double CharacterAspectRatio, + double CharacterSpacing, + Thickness Padding); diff --git a/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryFactory.cs b/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryFactory.cs new file mode 100644 index 0000000..07ebd5d --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryFactory.cs @@ -0,0 +1,225 @@ +using Avalonia; +using AtomUI.Labs.Led.Segment; +using AtomUI.Labs.Led.Segment.Character; +using Avalonia.Media; + +namespace AtomUI.Labs.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.Led/Segment/Rendering/SegmentGeometryItem.cs b/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryItem.cs new file mode 100644 index 0000000..69bb853 --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryItem.cs @@ -0,0 +1,8 @@ +using AtomUI.Labs.Led.Segment.Character; +using Avalonia.Media; + +namespace AtomUI.Labs.Led.Segment.Rendering; + +internal readonly record struct SegmentGeometryItem( + SegmentParts Part, + Geometry Geometry); diff --git a/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryOptions.cs b/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryOptions.cs new file mode 100644 index 0000000..a3b4dd4 --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryOptions.cs @@ -0,0 +1,7 @@ +namespace AtomUI.Labs.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.Led/Segment/Rendering/SegmentGeometrySet.cs b/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometrySet.cs new file mode 100644 index 0000000..cbc3957 --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometrySet.cs @@ -0,0 +1,11 @@ +namespace AtomUI.Labs.Led.Segment.Rendering; + +internal sealed class SegmentGeometrySet +{ + public SegmentGeometrySet(IReadOnlyList items) + { + Items = items; + } + + public IReadOnlyList Items { get; } +} diff --git a/src/AtomUI.Labs.Led/Segment/Rendering/SegmentVisibleGeometry.cs b/src/AtomUI.Labs.Led/Segment/Rendering/SegmentVisibleGeometry.cs new file mode 100644 index 0000000..d199ee4 --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/Rendering/SegmentVisibleGeometry.cs @@ -0,0 +1,7 @@ +using Avalonia.Media; + +namespace AtomUI.Labs.Led.Segment.Rendering; + +internal sealed record SegmentVisibleGeometry( + Geometry? ActiveGeometry, + Geometry? InactiveGeometry); diff --git a/src/AtomUI.Labs.Led/Segment/SegmentDisplay.cs b/src/AtomUI.Labs.Led/Segment/SegmentDisplay.cs new file mode 100644 index 0000000..a14552a --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/SegmentDisplay.cs @@ -0,0 +1,672 @@ +using Avalonia; +using Avalonia.Automation.Peers; +using Avalonia.Controls; +using Avalonia.Layout; +using AtomUI.Labs.Led; +using AtomUI.Labs.Led.Glow; +using AtomUI.Labs.Led.Segment.Character; +using AtomUI.Labs.Led.Segment.Layout; +using AtomUI.Labs.Led.Segment.Rendering; +using Avalonia.Media; +using AvaloniaMatrix = Avalonia.Matrix; + +namespace AtomUI.Labs.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(null); + 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 effectiveGlowRadius = GetEffectiveGlowRadius(); + if (effectiveGlowRadius > 0) + { + visibleBounds = visibleBounds.Inflate(effectiveGlowRadius); + } + + 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)) + { + 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(Size? finalSize) + { + var key = new SegmentLayoutCacheKey(Text, GetLayoutOptions(), finalSize); + if (_hasLayoutCache && _layoutCacheKey == key && _layoutCache is not null) + { + return _layoutCache; + } + + var layout = SegmentLayoutEngine.Calculate(key.Text, key.Options, key.FinalSize); + _layoutCacheKey = key; + _layoutCache = layout; + _hasLayoutCache = true; + LayoutCacheVersion++; + return layout; + } + + private SegmentDisplayLayout GetPreparedLayout() + { + return GetLayout(Bounds.Size); + } + + 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), 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 double GetEffectiveGlowRadius() + { + return GlowBrush is not null && LedGlowValueSanitizer.CoerceOpacity(GlowOpacity) > 0 + ? LedGlowValueSanitizer.CoerceRadius(GlowRadius) + : 0; + } + + private LedGlowRenderScope PushGlow(DrawingContext context, Rect activeBounds) + { + var glowBrush = GlowBrush; + var opacity = LedGlowValueSanitizer.CoerceOpacity(GlowOpacity); + var radius = LedGlowValueSanitizer.CoerceRadius(GlowRadius); + 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, + Size? FinalSize); + + 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.Led/Segment/SegmentDisplayAutomationPeer.cs b/src/AtomUI.Labs.Led/Segment/SegmentDisplayAutomationPeer.cs new file mode 100644 index 0000000..abba208 --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/SegmentDisplayAutomationPeer.cs @@ -0,0 +1,55 @@ +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using AtomUI.Labs.Led.Segment.Character; + +namespace AtomUI.Labs.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.Led/Segment/SegmentOverflowMode.cs b/src/AtomUI.Labs.Led/Segment/SegmentOverflowMode.cs new file mode 100644 index 0000000..0db1335 --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/SegmentOverflowMode.cs @@ -0,0 +1,7 @@ +namespace AtomUI.Labs.Led.Segment; + +public enum SegmentOverflowMode +{ + Clip, + ScaleDown +} diff --git a/src/AtomUI.Labs.Led/Segment/SegmentValueSanitizer.cs b/src/AtomUI.Labs.Led/Segment/SegmentValueSanitizer.cs new file mode 100644 index 0000000..0bbafd7 --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/SegmentValueSanitizer.cs @@ -0,0 +1,40 @@ +using Avalonia; + +namespace AtomUI.Labs.Led.Segment; + +internal static class SegmentValueSanitizer +{ + public static double CoerceNonNegative(double value) + { + return IsFinite(value) ? Math.Max(0, value) : 0; + } + + public static double CoerceAtLeast(double value, double minimum) + { + return IsFinite(value) ? Math.Max(minimum, value) : 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 bool IsFinite(double value) + { + return !double.IsNaN(value) && !double.IsInfinity(value); + } +} diff --git a/src/AtomUI.Labs.Led/Segment/Themes/SegmentDisplayTheme.axaml b/src/AtomUI.Labs.Led/Segment/Themes/SegmentDisplayTheme.axaml new file mode 100644 index 0000000..80fda36 --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/Themes/SegmentDisplayTheme.axaml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/src/AtomUI.Labs.Led/Segment/Themes/SegmentThemes.axaml b/src/AtomUI.Labs.Led/Segment/Themes/SegmentThemes.axaml new file mode 100644 index 0000000..396b21e --- /dev/null +++ b/src/AtomUI.Labs.Led/Segment/Themes/SegmentThemes.axaml @@ -0,0 +1,7 @@ + + + + + diff --git a/src/AtomUI.Labs.Led/ThemeManagerBuilderExtensions.cs b/src/AtomUI.Labs.Led/ThemeManagerBuilderExtensions.cs new file mode 100644 index 0000000..9250780 --- /dev/null +++ b/src/AtomUI.Labs.Led/ThemeManagerBuilderExtensions.cs @@ -0,0 +1,12 @@ +using AtomUI.Theme; + +namespace AtomUI.Labs.Led; + +public static class LedThemeManagerBuilderExtensions +{ + public static IThemeManagerBuilder UseLed(this IThemeManagerBuilder themeManagerBuilder) + { + themeManagerBuilder.AddControlThemesProvider(new LedThemesProvider()); + return themeManagerBuilder; + } +} From f6f3d00b2f4e93337e10162a199a89948ba5f994 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 15:19:00 +0800 Subject: [PATCH 09/33] add tests of Led controls Segment --- .../Segment/SegmentCharacterMapTests.cs | 121 ++++ .../Segment/SegmentDisplayAutomationTests.cs | 91 +++ .../Segment/SegmentDisplayContractTests.cs | 141 +++++ .../SegmentDisplayGeometryLifecycleTests.cs | 90 +++ .../Segment/SegmentDisplayMeasureTests.cs | 75 +++ .../Segment/SegmentDisplayRenderTests.cs | 540 ++++++++++++++++++ .../Segment/SegmentGeometryFactoryTests.cs | 192 +++++++ .../Segment/SegmentLayoutEngineTests.cs | 129 +++++ 8 files changed, 1379 insertions(+) create mode 100644 tests/AtomUI.Labs.Led.Tests/Segment/SegmentCharacterMapTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayAutomationTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayContractTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayGeometryLifecycleTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayMeasureTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayRenderTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Segment/SegmentGeometryFactoryTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Segment/SegmentLayoutEngineTests.cs diff --git a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentCharacterMapTests.cs b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentCharacterMapTests.cs new file mode 100644 index 0000000..495bcf3 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentCharacterMapTests.cs @@ -0,0 +1,121 @@ +using AtomUI.Labs.Led.Segment.Character; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Segment; + +public class SegmentCharacterMapTests +{ + [Theory] + [InlineData('0', (int)(SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.UpperRight | SegmentParts.LowerLeft | SegmentParts.LowerRight | SegmentParts.Bottom))] + [InlineData('1', (int)(SegmentParts.UpperRight | SegmentParts.LowerRight))] + [InlineData('2', (int)(SegmentParts.Top | SegmentParts.UpperRight | SegmentParts.MiddleLeft | SegmentParts.MiddleRight | SegmentParts.LowerLeft | SegmentParts.Bottom))] + [InlineData('3', (int)(SegmentParts.Top | SegmentParts.UpperRight | SegmentParts.MiddleLeft | SegmentParts.MiddleRight | SegmentParts.LowerRight | SegmentParts.Bottom))] + [InlineData('4', (int)(SegmentParts.UpperLeft | SegmentParts.UpperRight | SegmentParts.MiddleLeft | SegmentParts.MiddleRight | SegmentParts.LowerRight))] + [InlineData('5', (int)(SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.MiddleLeft | SegmentParts.MiddleRight | SegmentParts.LowerRight | SegmentParts.Bottom))] + [InlineData('6', (int)(SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.MiddleLeft | SegmentParts.MiddleRight | SegmentParts.LowerLeft | SegmentParts.LowerRight | SegmentParts.Bottom))] + [InlineData('7', (int)(SegmentParts.Top | SegmentParts.UpperRight | SegmentParts.LowerRight))] + [InlineData('8', (int)(SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.UpperRight | SegmentParts.MiddleLeft | SegmentParts.MiddleRight | SegmentParts.LowerLeft | SegmentParts.LowerRight | SegmentParts.Bottom))] + [InlineData('9', (int)(SegmentParts.Top | SegmentParts.UpperLeft | SegmentParts.UpperRight | SegmentParts.MiddleLeft | SegmentParts.MiddleRight | SegmentParts.LowerRight | SegmentParts.Bottom))] + public void GetPattern_ShouldMapDigitsToSegmentParts(char character, int expectedPartsValue) + { + var pattern = SegmentCharacterMap.GetPattern(character); + + pattern.Character.ShouldBe(character); + pattern.Kind.ShouldBe(SegmentCharacterKind.Segments); + pattern.Parts.ShouldBe((SegmentParts)expectedPartsValue); + } + + [Theory] + [InlineData('A')] + [InlineData('B')] + [InlineData('C')] + [InlineData('D')] + [InlineData('E')] + [InlineData('F')] + [InlineData('G')] + [InlineData('H')] + [InlineData('I')] + [InlineData('J')] + [InlineData('K')] + [InlineData('L')] + [InlineData('M')] + [InlineData('N')] + [InlineData('O')] + [InlineData('P')] + [InlineData('Q')] + [InlineData('R')] + [InlineData('S')] + [InlineData('T')] + [InlineData('U')] + [InlineData('V')] + [InlineData('W')] + [InlineData('X')] + [InlineData('Y')] + [InlineData('Z')] + public void GetPattern_ShouldSupportUppercaseLetters(char character) + { + var pattern = SegmentCharacterMap.GetPattern(character); + + pattern.Character.ShouldBe(character); + pattern.Kind.ShouldBe(SegmentCharacterKind.Segments); + pattern.Parts.ShouldNotBe(SegmentParts.None); + } + + [Theory] + [InlineData('a', 'A')] + [InlineData('z', 'Z')] + public void GetPattern_ShouldNormalizeLowercaseLetters(char character, char expectedCharacter) + { + var pattern = SegmentCharacterMap.GetPattern(character); + + pattern.Character.ShouldBe(expectedCharacter); + pattern.Kind.ShouldBe(SegmentCharacterKind.Segments); + pattern.Parts.ShouldNotBe(SegmentParts.None); + } + + [Theory] + [InlineData('-', (int)(SegmentParts.MiddleLeft | SegmentParts.MiddleRight))] + [InlineData('_', (int)SegmentParts.Bottom)] + public void GetPattern_ShouldMapSupportedSymbolsToSegmentParts(char character, int expectedPartsValue) + { + var pattern = SegmentCharacterMap.GetPattern(character); + + pattern.Character.ShouldBe(character); + pattern.Kind.ShouldBe(SegmentCharacterKind.Segments); + pattern.Parts.ShouldBe((SegmentParts)expectedPartsValue); + } + + [Fact] + public void GetPattern_ShouldMapColonToDedicatedKind() + { + var pattern = SegmentCharacterMap.GetPattern(':'); + + pattern.Character.ShouldBe(':'); + pattern.Kind.ShouldBe(SegmentCharacterKind.Colon); + pattern.Parts.ShouldBe(SegmentParts.None); + } + + [Fact] + public void GetPattern_ShouldMapDotToDedicatedKind() + { + var pattern = SegmentCharacterMap.GetPattern('.'); + + pattern.Character.ShouldBe('.'); + pattern.Kind.ShouldBe(SegmentCharacterKind.Dot); + pattern.Parts.ShouldBe(SegmentParts.None); + } + + [Theory] + [InlineData(' ')] + [InlineData('?')] + [InlineData('中')] + public void GetPattern_ShouldFallbackToEmptyForUnsupportedCharacters(char character) + { + var pattern = SegmentCharacterMap.GetPattern(character); + + pattern.Character.ShouldBe(' '); + pattern.Kind.ShouldBe(SegmentCharacterKind.Empty); + pattern.Parts.ShouldBe(SegmentParts.None); + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayAutomationTests.cs b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayAutomationTests.cs new file mode 100644 index 0000000..2fbdb3b --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayAutomationTests.cs @@ -0,0 +1,91 @@ +using AtomUI.Labs.Led.Segment; +using AtomUI.Labs.Led.Segment.Character; +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Segment; + +public class SegmentDisplayAutomationTests +{ + static SegmentDisplayAutomationTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData("12:ab", "12:AB")] + [InlineData("A?中Z", "A Z")] + [InlineData("A\nZ", "A Z")] + public void GetDisplayText_ShouldMatchRenderedCharacterSemantics(string? text, string expected) + { + SegmentCharacterMap.GetDisplayText(text).ShouldBe(expected); + } + + [Fact] + public void AutomationPeer_ShouldExposeDisplayAsTextContent() + { + var display = new SegmentDisplay { Text = "12:ab" }; + + var peer = ControlAutomationPeer.CreatePeerForElement(display); + + peer.ShouldBeOfType(); + peer.GetClassName().ShouldBe(nameof(SegmentDisplay)); + peer.GetAutomationControlType().ShouldBe(AutomationControlType.Text); + peer.GetName().ShouldBe("12:AB"); + peer.IsContentElement().ShouldBeTrue(); + } + + [Fact] + public void AutomationPeer_ShouldPreferExplicitAutomationName() + { + var display = new SegmentDisplay { Text = "12:34" }; + AutomationProperties.SetName(display, "Elapsed time"); + + var peer = ControlAutomationPeer.CreatePeerForElement(display); + + peer.GetName().ShouldBe("Elapsed time"); + } + + [Fact] + public void AutomationPeer_ShouldRespectExplicitEmptyAutomationName() + { + var display = new SegmentDisplay { Text = "12:34" }; + AutomationProperties.SetName(display, string.Empty); + + var peer = ControlAutomationPeer.CreatePeerForElement(display); + + peer.GetName().ShouldBeEmpty(); + } + + [Fact] + public void AutomationPeer_ShouldReflectTextChanges() + { + var display = new SegmentDisplay { Text = "abc" }; + var peer = ControlAutomationPeer.CreatePeerForElement(display); + var propertyChangeCount = 0; + peer.PropertyChanged += (_, _) => propertyChangeCount++; + + display.Text = "98:xy"; + + peer.GetName().ShouldBe("98:XY"); + propertyChangeCount.ShouldBe(1); + } + + [Fact] + public void AutomationPeer_ShouldNotRaiseNameChangeWhenRenderedTextIsEquivalent() + { + var display = new SegmentDisplay { Text = "abc" }; + var peer = ControlAutomationPeer.CreatePeerForElement(display); + var propertyChangeCount = 0; + peer.PropertyChanged += (_, _) => propertyChangeCount++; + + display.Text = "ABC"; + + peer.GetName().ShouldBe("ABC"); + propertyChangeCount.ShouldBe(0); + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayContractTests.cs b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayContractTests.cs new file mode 100644 index 0000000..96f9109 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayContractTests.cs @@ -0,0 +1,141 @@ +using AtomUI.Labs.Led.Segment; +using Avalonia; +using Avalonia.Layout; +using Avalonia.Media; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Segment; + +public class SegmentDisplayContractTests +{ + [Fact] + public void DefaultValues_ShouldRemainStable() + { + var display = new SegmentDisplay(); + + display.Text.ShouldBeNull(); + display.CharacterHeight.ShouldBe(72); + display.CharacterAspectRatio.ShouldBe(0.58); + display.CharacterSpacing.ShouldBe(8); + display.SegmentThickness.ShouldBe(8); + display.SegmentGap.ShouldBe(2); + display.SegmentBevelRatio.ShouldBe(0.5); + display.DotScale.ShouldBe(0.72); + display.Padding.ShouldBe(default); + display.HorizontalContentAlignment.ShouldBe(HorizontalAlignment.Left); + display.VerticalContentAlignment.ShouldBe(VerticalAlignment.Top); + display.OverflowMode.ShouldBe(SegmentOverflowMode.Clip); + display.Background.ShouldBeNull(); + display.CornerRadius.ShouldBe(default); + display.ActiveBrush.ShouldBeNull(); + display.InactiveBrush.ShouldBeNull(); + display.GlowBrush.ShouldBeNull(); + display.GlowOpacity.ShouldBe(0.35); + display.GlowRadius.ShouldBe(6); + display.ShowInactiveSegments.ShouldBeTrue(); + } + + [Fact] + public void StyledProperties_ShouldKeepRegisteredNames() + { + SegmentDisplay.TextProperty.Name.ShouldBe(nameof(SegmentDisplay.Text)); + SegmentDisplay.CharacterHeightProperty.Name.ShouldBe(nameof(SegmentDisplay.CharacterHeight)); + SegmentDisplay.CharacterAspectRatioProperty.Name.ShouldBe(nameof(SegmentDisplay.CharacterAspectRatio)); + SegmentDisplay.CharacterSpacingProperty.Name.ShouldBe(nameof(SegmentDisplay.CharacterSpacing)); + SegmentDisplay.SegmentThicknessProperty.Name.ShouldBe(nameof(SegmentDisplay.SegmentThickness)); + SegmentDisplay.SegmentGapProperty.Name.ShouldBe(nameof(SegmentDisplay.SegmentGap)); + SegmentDisplay.SegmentBevelRatioProperty.Name.ShouldBe(nameof(SegmentDisplay.SegmentBevelRatio)); + SegmentDisplay.DotScaleProperty.Name.ShouldBe(nameof(SegmentDisplay.DotScale)); + SegmentDisplay.PaddingProperty.Name.ShouldBe(nameof(SegmentDisplay.Padding)); + SegmentDisplay.HorizontalContentAlignmentProperty.Name.ShouldBe(nameof(SegmentDisplay.HorizontalContentAlignment)); + SegmentDisplay.VerticalContentAlignmentProperty.Name.ShouldBe(nameof(SegmentDisplay.VerticalContentAlignment)); + SegmentDisplay.OverflowModeProperty.Name.ShouldBe(nameof(SegmentDisplay.OverflowMode)); + SegmentDisplay.BackgroundProperty.Name.ShouldBe(nameof(SegmentDisplay.Background)); + SegmentDisplay.CornerRadiusProperty.Name.ShouldBe(nameof(SegmentDisplay.CornerRadius)); + SegmentDisplay.ActiveBrushProperty.Name.ShouldBe(nameof(SegmentDisplay.ActiveBrush)); + SegmentDisplay.InactiveBrushProperty.Name.ShouldBe(nameof(SegmentDisplay.InactiveBrush)); + SegmentDisplay.GlowBrushProperty.Name.ShouldBe(nameof(SegmentDisplay.GlowBrush)); + SegmentDisplay.GlowOpacityProperty.Name.ShouldBe(nameof(SegmentDisplay.GlowOpacity)); + SegmentDisplay.GlowRadiusProperty.Name.ShouldBe(nameof(SegmentDisplay.GlowRadius)); + SegmentDisplay.ShowInactiveSegmentsProperty.Name.ShouldBe(nameof(SegmentDisplay.ShowInactiveSegments)); + } + + [Fact] + public void StyledProperties_ShouldKeepRegisteredDefaults() + { + SegmentDisplay.TextProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBeNull(); + SegmentDisplay.CharacterHeightProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBe(72); + SegmentDisplay.CharacterAspectRatioProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBe(0.58); + SegmentDisplay.CharacterSpacingProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBe(8); + SegmentDisplay.SegmentThicknessProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBe(8); + SegmentDisplay.SegmentGapProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBe(2); + SegmentDisplay.SegmentBevelRatioProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBe(0.5); + SegmentDisplay.DotScaleProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBe(0.72); + SegmentDisplay.PaddingProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBe(default); + SegmentDisplay.HorizontalContentAlignmentProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBe(HorizontalAlignment.Left); + SegmentDisplay.VerticalContentAlignmentProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBe(VerticalAlignment.Top); + SegmentDisplay.OverflowModeProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBe(SegmentOverflowMode.Clip); + SegmentDisplay.BackgroundProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBeNull(); + SegmentDisplay.CornerRadiusProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBe(default); + SegmentDisplay.ActiveBrushProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBeNull(); + SegmentDisplay.InactiveBrushProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBeNull(); + SegmentDisplay.GlowBrushProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBeNull(); + SegmentDisplay.GlowOpacityProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBe(0.35); + SegmentDisplay.GlowRadiusProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBe(6); + SegmentDisplay.ShowInactiveSegmentsProperty.GetMetadata(typeof(SegmentDisplay)).DefaultValue.ShouldBe(true); + } + + [Fact] + public void StyledProperties_ShouldAcceptConfiguredValues() + { + var activeBrush = Brushes.Red; + var inactiveBrush = Brushes.Gray; + var glowBrush = Brushes.Yellow; + var background = Brushes.Black; + var display = new SegmentDisplay + { + Text = "A-01", + CharacterHeight = 90, + CharacterAspectRatio = 0.6, + CharacterSpacing = 10, + SegmentThickness = 12, + SegmentGap = 3, + SegmentBevelRatio = 0.25, + DotScale = 0.6, + Padding = new Thickness(4), + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Bottom, + OverflowMode = SegmentOverflowMode.ScaleDown, + Background = background, + CornerRadius = new CornerRadius(6), + ActiveBrush = activeBrush, + InactiveBrush = inactiveBrush, + GlowBrush = glowBrush, + GlowOpacity = 0.4, + GlowRadius = 12, + ShowInactiveSegments = false + }; + + display.Text.ShouldBe("A-01"); + display.CharacterHeight.ShouldBe(90); + display.CharacterAspectRatio.ShouldBe(0.6); + display.CharacterSpacing.ShouldBe(10); + display.SegmentThickness.ShouldBe(12); + display.SegmentGap.ShouldBe(3); + display.SegmentBevelRatio.ShouldBe(0.25); + display.DotScale.ShouldBe(0.6); + display.Padding.ShouldBe(new Thickness(4)); + display.HorizontalContentAlignment.ShouldBe(HorizontalAlignment.Center); + display.VerticalContentAlignment.ShouldBe(VerticalAlignment.Bottom); + display.OverflowMode.ShouldBe(SegmentOverflowMode.ScaleDown); + display.Background.ShouldBeSameAs(background); + display.CornerRadius.ShouldBe(new CornerRadius(6)); + display.ActiveBrush.ShouldBeSameAs(activeBrush); + display.InactiveBrush.ShouldBeSameAs(inactiveBrush); + display.GlowBrush.ShouldBeSameAs(glowBrush); + display.GlowOpacity.ShouldBe(0.4); + display.GlowRadius.ShouldBe(12); + display.ShowInactiveSegments.ShouldBeFalse(); + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayGeometryLifecycleTests.cs b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayGeometryLifecycleTests.cs new file mode 100644 index 0000000..3b55330 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayGeometryLifecycleTests.cs @@ -0,0 +1,90 @@ +using System.Runtime.CompilerServices; +using AtomUI.Labs.Led.Segment; +using Avalonia; +using Avalonia.Media; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Segment; + +public class SegmentDisplayGeometryLifecycleTests +{ + static SegmentDisplayGeometryLifecycleTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + [Fact] + public void PatternChange_ShouldReleasePreviousActiveGeometryAndReuseInactiveGeometry() + { + var display = CreateDisplay("1111"); + Render(display); + var inactive = display.VisibleInactiveGeometry; + var previousActive = CaptureActiveAndChangeText(display, "8888"); + + ForceFullCollection(); + + previousActive.IsAlive.ShouldBeFalse(); + display.VisibleInactiveGeometry.ShouldBeSameAs(inactive); + } + + [Fact] + public void GeometryOptionChange_ShouldReleasePreviousInactiveGeometry() + { + var display = CreateDisplay("8888"); + Render(display); + var previousInactive = CaptureInactiveAndChangeThickness(display); + + ForceFullCollection(); + + previousInactive.IsAlive.ShouldBeFalse(); + display.VisibleInactiveGeometry.ShouldNotBeNull(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CaptureActiveAndChangeText(SegmentDisplay display, string text) + { + var reference = new WeakReference(display.VisibleActiveGeometry!); + display.Text = text; + Render(display); + return reference; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CaptureInactiveAndChangeThickness(SegmentDisplay display) + { + var reference = new WeakReference(display.VisibleInactiveGeometry!); + display.SegmentThickness += 1; + Render(display); + return reference; + } + + private static SegmentDisplay CreateDisplay(string text) + { + var display = new SegmentDisplay + { + Text = text, + CharacterHeight = 72, + ActiveBrush = Brushes.Red, + InactiveBrush = Brushes.DarkGray + }; + display.Measure(new Size(400, 120)); + display.Arrange(new Rect(0, 0, 400, 120)); + return display; + } + + private static void Render(SegmentDisplay display) + { + using var context = new DrawingGroup().Open(); + display.Render(context); + } + + private static void ForceFullCollection() + { + for (var i = 0; i < 3; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayMeasureTests.cs b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayMeasureTests.cs new file mode 100644 index 0000000..c613a90 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayMeasureTests.cs @@ -0,0 +1,75 @@ +using AtomUI.Labs.Led.Segment; +using Avalonia; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Segment; + +public class SegmentDisplayMeasureTests +{ + [Theory] + [InlineData(null, 0, 0)] + [InlineData("", 0, 0)] + [InlineData("88.8", 178, 100)] + [InlineData("12:45", 232, 100)] + public void Measure_ShouldUseSegmentLayoutEngineSemantics(string? text, double expectedWidth, double expectedHeight) + { + var display = CreateDisplay(text); + + display.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + + display.DesiredSize.ShouldBe(new Size(expectedWidth, expectedHeight)); + } + + [Fact] + public void Measure_ShouldApplyPadding() + { + var display = CreateDisplay("88"); + display.Padding = new Thickness(2, 3, 4, 5); + + display.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + + display.DesiredSize.ShouldBe(new Size(110, 108)); + } + + [Fact] + public void Measure_ShouldCoerceInvalidNumericInputs() + { + var display = CreateDisplay("88"); + display.CharacterHeight = double.NaN; + display.CharacterAspectRatio = double.PositiveInfinity; + display.CharacterSpacing = double.NegativeInfinity; + display.Padding = new Thickness(double.NaN, -1, double.PositiveInfinity, 2); + + display.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + + display.DesiredSize.ShouldBe(new Size(0, 2)); + } + + [Fact] + public void Measure_ShouldNotDependOnSegmentThicknessOrGap() + { + var display = CreateDisplay("88.8"); + display.SegmentThickness = 1; + display.SegmentGap = 0; + display.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + var desiredSize = display.DesiredSize; + + display.SegmentThickness = 200; + display.SegmentGap = 200; + display.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + + display.DesiredSize.ShouldBe(desiredSize); + } + + private static SegmentDisplay CreateDisplay(string? text) + { + return new SegmentDisplay + { + Text = text, + CharacterHeight = 100, + CharacterAspectRatio = 0.5, + CharacterSpacing = 4 + }; + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayRenderTests.cs b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayRenderTests.cs new file mode 100644 index 0000000..6ff44c8 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayRenderTests.cs @@ -0,0 +1,540 @@ +using AtomUI.Labs.Led.Segment; +using Avalonia; +using Avalonia.Layout; +using Avalonia.Media; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Segment; + +public class SegmentDisplayRenderTests +{ + static SegmentDisplayRenderTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + [Theory] + [InlineData("HELLO")] + [InlineData("12:45")] + [InlineData("88.8")] + [InlineData("")] + [InlineData("?中")] + public void Render_ShouldNotThrowForSupportedAndUnsupportedText(string text) + { + var display = CreateDisplay(text); + + var drawingGroup = RenderToDrawingGroup(display); + + drawingGroup.Children.ShouldNotBeNull(); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(1, 1)] + [InlineData(12, 4)] + public void Render_ShouldNotThrowForSmallArrangedSizes(double width, double height) + { + var display = CreateDisplay("88.8"); + display.Measure(new Size(width, height)); + display.Arrange(new Rect(0, 0, width, height)); + + var drawingGroup = RenderToDrawingGroup(display); + + drawingGroup.Children.ShouldNotBeNull(); + } + + [Fact] + public void Render_ShouldNotThrowWhenActiveBrushIsNull() + { + var display = CreateDisplay("HELLO"); + display.ActiveBrush = null; + + var drawingGroup = RenderToDrawingGroup(display); + + drawingGroup.Children.ShouldNotBeNull(); + } + + [Fact] + public void Render_ShouldDrawInactiveAndActiveSegmentsForSegmentCharacters() + { + var display = CreateDisplay("1"); + + var drawings = RenderToGeometryDrawings(display).ToList(); + + drawings.Count.ShouldBe(3); + CountBrush(drawings, Colors.Black).ShouldBe(1); + CountBrush(drawings, Colors.DarkGray).ShouldBe(1); + CountBrush(drawings, Colors.Red).ShouldBe(1); + } + + [Fact] + public void Render_ShouldSkipInactiveSegmentsWhenDisabled() + { + var display = CreateDisplay("1"); + display.ShowInactiveSegments = false; + + var drawings = RenderToGeometryDrawings(display).ToList(); + + drawings.Count.ShouldBe(2); + CountBrush(drawings, Colors.Black).ShouldBe(1); + CountBrush(drawings, Colors.DarkGray).ShouldBe(0); + CountBrush(drawings, Colors.Red).ShouldBe(1); + } + + [Theory] + [InlineData(":", 2, 1)] + [InlineData(".", 2, 1)] + public void Render_ShouldDrawColonAndDotWithoutInactiveSegments(string text, int expectedTotal, int expectedActiveDots) + { + var display = CreateDisplay(text); + + var drawings = RenderToGeometryDrawings(display).ToList(); + + drawings.Count.ShouldBe(expectedTotal); + CountBrush(drawings, Colors.Black).ShouldBe(1); + CountBrush(drawings, Colors.DarkGray).ShouldBe(0); + CountBrush(drawings, Colors.Red).ShouldBe(expectedActiveDots); + } + + [Fact] + public void Render_ShouldDrawOnlyBackgroundWhenActiveBrushIsNull() + { + var display = CreateDisplay("1"); + display.ActiveBrush = null; + + var drawings = RenderToGeometryDrawings(display).ToList(); + + drawings.Count.ShouldBe(1); + CountBrush(drawings, Colors.Black).ShouldBe(1); + } + + [Fact] + public void Render_ShouldDrawGlowLayerForActiveSegmentsWhenGlowBrushIsSet() + { + var display = CreateDisplay("1"); + display.GlowBrush = Brushes.Yellow; + display.GlowOpacity = 0.4; + + var drawings = RenderToGeometryDrawings(display).ToList(); + + drawings.Count.ShouldBe(4); + CountBrush(drawings, Colors.Black).ShouldBe(1); + CountBrush(drawings, Colors.DarkGray).ShouldBe(1); + CountBrush(drawings, Colors.Yellow).ShouldBe(1); + CountBrush(drawings, Colors.Red).ShouldBe(1); + } + + [Fact] + public void Render_ShouldSkipGlowLayerWhenGlowOpacityIsZero() + { + var display = CreateDisplay("1"); + display.GlowBrush = Brushes.Yellow; + display.GlowOpacity = 0; + + var drawings = RenderToGeometryDrawings(display).ToList(); + + drawings.Count.ShouldBe(3); + CountBrush(drawings, Colors.Yellow).ShouldBe(0); + } + + [Fact] + public void Render_ShouldCullLongTextBeforeSubmittingSingleGlowEffect() + { + var display = CreateDisplay(new string('8', 1000)); + display.GlowBrush = Brushes.Cyan; + display.GlowRadius = 24; + var thousandCharacterCommands = RenderToGeometryDrawings(display).Count(); + display.GlowEffectScopeCount.ShouldBe(1); + + display.Text = new string('8', 100); + var hundredCharacterCommands = RenderToGeometryDrawings(display).Count(); + + thousandCharacterCommands.ShouldBe(hundredCharacterCommands); + thousandCharacterCommands.ShouldBeLessThan(250); + display.GlowEffectScopeCount.ShouldBe(2); + } + + [Fact] + public void Render_ShouldCoerceInvalidNumericInputs() + { + var display = CreateDisplay("88"); + display.CharacterHeight = double.NaN; + display.CharacterAspectRatio = double.PositiveInfinity; + display.CharacterSpacing = double.NegativeInfinity; + display.SegmentThickness = double.NaN; + display.SegmentGap = double.PositiveInfinity; + display.Measure(new Size(400, 120)); + display.Arrange(new Rect(0, 0, 400, 120)); + + var drawings = RenderToGeometryDrawings(display).ToList(); + + drawings.ShouldNotBeEmpty(); + } + + [Fact] + public void Render_ShouldReuseSegmentGeometriesForRepeatedRenderWithSameLayout() + { + var display = CreateDisplay("12"); + + RenderToDrawingGroup(display); + var firstVersion = display.GeometryCacheVersion; + RenderToDrawingGroup(display); + var secondVersion = display.GeometryCacheVersion; + + secondVersion.ShouldBe(firstVersion); + } + + [Fact] + public void Render_ShouldReuseVisibleGeometryForRepeatedRenderAndBrushChanges() + { + var display = CreateDisplay("12:45"); + display.GlowBrush = Brushes.Cyan; + RenderToDrawingGroup(display); + var buildCount = display.VisibleGeometryBuildCount; + + RenderToDrawingGroup(display); + display.ActiveBrush = Brushes.Blue; + display.InactiveBrush = Brushes.Gray; + display.GlowBrush = Brushes.Magenta; + display.GlowOpacity = 0.6; + display.GlowRadius = 12; + RenderToDrawingGroup(display); + + display.VisibleGeometryBuildCount.ShouldBe(buildCount); + } + + [Fact] + public void Render_ShouldRebuildVisibleGeometryForPatternChangeButReuseSegmentGeometry() + { + var display = CreateDisplay("1111"); + RenderToDrawingGroup(display); + var geometryVersion = display.GeometryCacheVersion; + var visibleBuildCount = display.VisibleGeometryBuildCount; + + display.Text = "8888"; + RenderToDrawingGroup(display); + + display.GeometryCacheVersion.ShouldBe(geometryVersion); + display.VisibleGeometryBuildCount.ShouldBe(visibleBuildCount + 1); + } + + [Fact] + public void Render_ShouldReuseSegmentGeometriesWhenOnlyBrushChanges() + { + var display = CreateDisplay("12"); + + RenderToDrawingGroup(display); + var firstVersion = display.GeometryCacheVersion; + display.ActiveBrush = Brushes.Blue; + display.InactiveBrush = Brushes.Gray; + RenderToDrawingGroup(display); + var secondVersion = display.GeometryCacheVersion; + + secondVersion.ShouldBe(firstVersion); + } + + [Fact] + public void Render_ShouldReuseSegmentGeometriesWhenOnlyGlowSettingsChange() + { + var display = CreateDisplay("12"); + + RenderToDrawingGroup(display); + var firstVersion = display.GeometryCacheVersion; + display.GlowBrush = Brushes.Yellow; + display.GlowOpacity = 0.4; + RenderToDrawingGroup(display); + var secondVersion = display.GeometryCacheVersion; + + secondVersion.ShouldBe(firstVersion); + } + + [Fact] + public void Render_ShouldReuseSegmentGeometriesWhenOnlyAlignmentOrOverflowChanges() + { + var display = CreateDisplay("12"); + + RenderToDrawingGroup(display); + var firstVersion = display.GeometryCacheVersion; + display.HorizontalContentAlignment = HorizontalAlignment.Center; + display.VerticalContentAlignment = VerticalAlignment.Bottom; + display.OverflowMode = SegmentOverflowMode.ScaleDown; + RenderToDrawingGroup(display); + var secondVersion = display.GeometryCacheVersion; + + secondVersion.ShouldBe(firstVersion); + } + + [Fact] + public void Render_ShouldApplyTransformForCenteredScaleDownContent() + { + var display = CreateDisplay("888888"); + display.Background = null; + display.HorizontalContentAlignment = HorizontalAlignment.Center; + display.VerticalContentAlignment = VerticalAlignment.Center; + display.OverflowMode = SegmentOverflowMode.ScaleDown; + display.Measure(new Size(120, 40)); + display.Arrange(new Rect(0, 0, 120, 40)); + + var drawingGroup = RenderToDrawingGroup(display); + + var transform = EnumerateDrawingGroups(drawingGroup) + .Select(group => group.Transform) + .OfType() + .FirstOrDefault(); + transform.ShouldNotBeNull(); + transform.Value.M11.ShouldBeLessThan(1); + transform.Value.M22.ShouldBeLessThan(1); + } + + [Fact] + public void Render_ShouldKeepIdentityScaleForClipOverflowWhenContentIsConstrained() + { + var display = CreateDisplay("888888"); + display.Background = null; + display.OverflowMode = SegmentOverflowMode.Clip; + display.Measure(new Size(120, 40)); + display.Arrange(new Rect(0, 0, 120, 40)); + + var drawingGroup = RenderToDrawingGroup(display); + + var transform = FindLayoutTransform(drawingGroup); + transform.ShouldNotBeNull(); + transform.Value.M11.ShouldBe(1); + transform.Value.M22.ShouldBe(1); + } + + [Fact] + public void Render_ShouldNotScaleUpWhenScaleDownContentHasExtraSpace() + { + var display = CreateDisplay("12"); + display.Background = null; + display.OverflowMode = SegmentOverflowMode.ScaleDown; + display.HorizontalContentAlignment = HorizontalAlignment.Center; + display.VerticalContentAlignment = VerticalAlignment.Center; + display.Measure(new Size(800, 120)); + display.Arrange(new Rect(0, 0, 800, 120)); + + var drawingGroup = RenderToDrawingGroup(display); + + var transform = FindLayoutTransform(drawingGroup); + transform.ShouldNotBeNull(); + transform.Value.M11.ShouldBe(1); + transform.Value.M22.ShouldBe(1); + transform.Value.M31.ShouldBeGreaterThan(0); + } + + [Fact] + public void Render_ShouldScaleDownForExtremelySmallBoundsWithoutThrowing() + { + var display = CreateDisplay("888888"); + display.Background = null; + display.OverflowMode = SegmentOverflowMode.ScaleDown; + display.Measure(new Size(1, 1)); + display.Arrange(new Rect(0, 0, 1, 1)); + + var drawingGroup = RenderToDrawingGroup(display); + + var transform = FindLayoutTransform(drawingGroup); + transform.ShouldNotBeNull(); + transform.Value.M11.ShouldBeGreaterThanOrEqualTo(0); + transform.Value.M11.ShouldBeLessThanOrEqualTo(1); + transform.Value.M22.ShouldBeGreaterThanOrEqualTo(0); + transform.Value.M22.ShouldBeLessThanOrEqualTo(1); + } + + [Fact] + public void Render_ShouldReuseSegmentGeometriesWhenTextChangesWithSameTopology() + { + var display = CreateDisplay("12"); + + RenderToDrawingGroup(display); + var firstVersion = display.GeometryCacheVersion; + display.Text = "34"; + RenderToDrawingGroup(display); + var secondVersion = display.GeometryCacheVersion; + + secondVersion.ShouldBe(firstVersion); + } + + [Fact] + public void Render_ShouldUseCurrentPatternWhenTextReusesSegmentGeometries() + { + var display = CreateDisplay("1"); + + RenderToDrawingGroup(display); + var firstVersion = display.GeometryCacheVersion; + display.Text = "8"; + var drawings = RenderToGeometryDrawings(display).ToList(); + + display.GeometryCacheVersion.ShouldBe(firstVersion); + CountBrush(drawings, Colors.Red).ShouldBe(1); + } + + [Theory] + [InlineData("12:34", "12.34")] + [InlineData("1234", "12:34")] + [InlineData("12", "1 ")] + public void Render_ShouldRefreshSegmentGeometriesWhenTextTopologyChanges(string initialText, string updatedText) + { + var display = CreateDisplay(initialText); + + RenderToDrawingGroup(display); + var firstVersion = display.GeometryCacheVersion; + display.Text = updatedText; + RenderToDrawingGroup(display); + + display.GeometryCacheVersion.ShouldBe(firstVersion + 1); + } + + [Fact] + public void Render_ShouldRebuildLayoutButReuseGeometryDuringHighFrequencyNumericUpdates() + { + const int updateCount = 2000; + var display = CreateDisplay("0000"); + RenderToDrawingGroup(display); + var initialLayoutVersion = display.LayoutCacheVersion; + var initialGeometryVersion = display.GeometryCacheVersion; + + for (var i = 1; i <= updateCount; i++) + { + display.Text = (i % 10000).ToString("D4"); + RenderToDrawingGroup(display); + } + + display.LayoutCacheVersion.ShouldBe(initialLayoutVersion + updateCount); + display.GeometryCacheVersion.ShouldBe(initialGeometryVersion); + + display.Text = "12:34"; + RenderToDrawingGroup(display); + display.GeometryCacheVersion.ShouldBe(initialGeometryVersion + 1); + + display.SegmentThickness = 10; + RenderToDrawingGroup(display); + display.GeometryCacheVersion.ShouldBe(initialGeometryVersion + 2); + } + + [Fact] + public void Render_ShouldRefreshSegmentGeometriesWhenGeometryOptionsChange() + { + var display = CreateDisplay("12"); + + RenderToDrawingGroup(display); + var firstVersion = display.GeometryCacheVersion; + var firstLayoutVersion = display.LayoutCacheVersion; + display.SegmentThickness = 12; + RenderToDrawingGroup(display); + var secondVersion = display.GeometryCacheVersion; + + secondVersion.ShouldBe(firstVersion + 1); + display.LayoutCacheVersion.ShouldBe(firstLayoutVersion); + } + + [Fact] + public void Render_ShouldRefreshLayoutAndGeometriesWhenCharacterSpacingChanges() + { + var display = CreateDisplay("1234"); + + RenderToDrawingGroup(display); + var firstLayoutVersion = display.LayoutCacheVersion; + var firstGeometryVersion = display.GeometryCacheVersion; + display.CharacterSpacing = 12; + RenderToDrawingGroup(display); + + display.LayoutCacheVersion.ShouldBe(firstLayoutVersion + 1); + display.GeometryCacheVersion.ShouldBe(firstGeometryVersion + 1); + } + + [Fact] + public void Render_ShouldRefreshSegmentGeometriesWhenShapeOptionsChange() + { + var display = CreateDisplay("12:45"); + + RenderToDrawingGroup(display); + var firstVersion = display.GeometryCacheVersion; + display.SegmentBevelRatio = 0.25; + RenderToDrawingGroup(display); + var secondVersion = display.GeometryCacheVersion; + display.DotScale = 0.5; + RenderToDrawingGroup(display); + var thirdVersion = display.GeometryCacheVersion; + + secondVersion.ShouldBe(firstVersion + 1); + thirdVersion.ShouldBe(secondVersion + 1); + } + + private static SegmentDisplay CreateDisplay(string text) + { + var display = new SegmentDisplay + { + Text = text, + CharacterHeight = 72, + CharacterAspectRatio = 0.58, + CharacterSpacing = 8, + SegmentThickness = 8, + SegmentGap = 2, + ActiveBrush = Brushes.Red, + InactiveBrush = Brushes.DarkGray, + Background = Brushes.Black + }; + display.Measure(new Size(400, 120)); + display.Arrange(new Rect(0, 0, 400, 120)); + return display; + } + + private static DrawingGroup RenderToDrawingGroup(SegmentDisplay display) + { + var drawingGroup = new DrawingGroup(); + using (var context = drawingGroup.Open()) + { + display.Render(context); + } + + return drawingGroup; + } + + private static IEnumerable RenderToGeometryDrawings(SegmentDisplay display) + { + return EnumerateGeometryDrawings(RenderToDrawingGroup(display)); + } + + private static IEnumerable EnumerateGeometryDrawings(Drawing drawing) + { + if (drawing is GeometryDrawing geometryDrawing) + { + yield return geometryDrawing; + } + else if (drawing is DrawingGroup drawingGroup) + { + foreach (var child in drawingGroup.Children.SelectMany(EnumerateGeometryDrawings)) + { + yield return child; + } + } + } + + private static IEnumerable EnumerateDrawingGroups(Drawing drawing) + { + if (drawing is DrawingGroup drawingGroup) + { + yield return drawingGroup; + foreach (var child in drawingGroup.Children.SelectMany(EnumerateDrawingGroups)) + { + yield return child; + } + } + } + + private static MatrixTransform? FindLayoutTransform(Drawing drawing) + { + return EnumerateDrawingGroups(drawing) + .Select(group => group.Transform) + .OfType() + .FirstOrDefault(); + } + + private static int CountBrush(IEnumerable drawings, Color color) + { + return drawings.Count(drawing => drawing.Brush is ISolidColorBrush brush && brush.Color == color); + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentGeometryFactoryTests.cs b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentGeometryFactoryTests.cs new file mode 100644 index 0000000..e130322 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentGeometryFactoryTests.cs @@ -0,0 +1,192 @@ +using AtomUI.Labs.Led.Segment.Character; +using AtomUI.Labs.Led.Segment.Rendering; +using Avalonia; +using Avalonia.Media; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Segment; + +public class SegmentGeometryFactoryTests +{ + static SegmentGeometryFactoryTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + [Fact] + public void Create_ShouldReturnOneGeometryForEachSegmentPart() + { + var geometrySet = SegmentGeometryFactory.Create( + new Rect(10, 20, 80, 140), + new SegmentGeometryOptions(8, 2)); + + geometrySet.Items.Count.ShouldBe(14); + geometrySet.Items.Select(item => item.Part) + .ShouldBe(Enum.GetValues().Where(part => part != SegmentParts.None), ignoreOrder: true); + } + + [Fact] + public void Create_ShouldKeepGeometriesInsideCharacterBounds() + { + var bounds = new Rect(10, 20, 80, 140); + var geometrySet = SegmentGeometryFactory.Create(bounds, new SegmentGeometryOptions(8, 2)); + + foreach (var item in geometrySet.Items) + { + item.Geometry.Bounds.ShouldSatisfyAllConditions( + geometryBounds => geometryBounds.X.ShouldBeGreaterThanOrEqualTo(bounds.X), + geometryBounds => geometryBounds.Y.ShouldBeGreaterThanOrEqualTo(bounds.Y), + geometryBounds => geometryBounds.Right.ShouldBeLessThanOrEqualTo(bounds.Right), + geometryBounds => geometryBounds.Bottom.ShouldBeLessThanOrEqualTo(bounds.Bottom)); + } + } + + [Fact] + public void Create_ShouldKeepFractionalGeometriesFiniteAndInsideBounds() + { + var bounds = new Rect(0.25, 0.75, 37.5, 63.25); + var geometrySet = SegmentGeometryFactory.Create( + bounds, + new SegmentGeometryOptions(3.75, 0.625, 0.35, 0.68)); + + foreach (var item in geometrySet.Items) + { + AssertFinite(item.Geometry.Bounds); + item.Geometry.Bounds.X.ShouldBeGreaterThanOrEqualTo(bounds.X); + item.Geometry.Bounds.Y.ShouldBeGreaterThanOrEqualTo(bounds.Y); + item.Geometry.Bounds.Right.ShouldBeLessThanOrEqualTo(bounds.Right); + item.Geometry.Bounds.Bottom.ShouldBeLessThanOrEqualTo(bounds.Bottom); + } + } + + [Theory] + [InlineData(2, 2, 20, 20)] + [InlineData(1, 100, 50, 50)] + [InlineData(100, 1, 50, 50)] + [InlineData(40, 80, 1000, 1000)] + public void Create_ShouldClampExtremeThicknessAndGapWithoutInvalidGeometry( + double width, + double height, + double thickness, + double gap) + { + var bounds = new Rect(0, 0, width, height); + var geometrySet = SegmentGeometryFactory.Create(bounds, new SegmentGeometryOptions(thickness, gap)); + + geometrySet.Items.Count.ShouldBe(14); + foreach (var item in geometrySet.Items) + { + AssertFinite(item.Geometry.Bounds); + item.Geometry.Bounds.X.ShouldBeGreaterThanOrEqualTo(bounds.X); + item.Geometry.Bounds.Y.ShouldBeGreaterThanOrEqualTo(bounds.Y); + item.Geometry.Bounds.Right.ShouldBeLessThanOrEqualTo(bounds.Right); + item.Geometry.Bounds.Bottom.ShouldBeLessThanOrEqualTo(bounds.Bottom); + } + } + + [Theory] + [InlineData(0, 10)] + [InlineData(10, 0)] + [InlineData(-1, 10)] + [InlineData(10, -1)] + public void Create_ShouldReturnEmptyGeometriesForNonPositiveBounds(double width, double height) + { + var geometrySet = SegmentGeometryFactory.Create( + new Rect(0, 0, width, height), + new SegmentGeometryOptions(8, 2)); + + geometrySet.Items.Count.ShouldBe(14); + geometrySet.Items.ShouldAllBe(item => item.Geometry.Bounds == default); + } + + [Theory] + [InlineData(double.NaN, 2)] + [InlineData(double.PositiveInfinity, 2)] + [InlineData(8, double.NaN)] + [InlineData(8, double.PositiveInfinity)] + public void Create_ShouldHandleNonFiniteOptions(double thickness, double gap) + { + var geometrySet = SegmentGeometryFactory.Create( + new Rect(0, 0, 80, 120), + new SegmentGeometryOptions(thickness, gap)); + + geometrySet.Items.Count.ShouldBe(14); + foreach (var item in geometrySet.Items) + { + AssertFinite(item.Geometry.Bounds); + } + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(0.5)] + [InlineData(1)] + [InlineData(2)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void Create_ShouldClampBevelRatioWithoutInvalidGeometry(double bevelRatio) + { + var bounds = new Rect(0, 0, 80, 120); + var geometrySet = SegmentGeometryFactory.Create(bounds, new SegmentGeometryOptions(8, 2, bevelRatio)); + + geometrySet.Items.Count.ShouldBe(14); + foreach (var item in geometrySet.Items) + { + AssertFinite(item.Geometry.Bounds); + item.Geometry.Bounds.X.ShouldBeGreaterThanOrEqualTo(bounds.X); + item.Geometry.Bounds.Y.ShouldBeGreaterThanOrEqualTo(bounds.Y); + item.Geometry.Bounds.Right.ShouldBeLessThanOrEqualTo(bounds.Right); + item.Geometry.Bounds.Bottom.ShouldBeLessThanOrEqualTo(bounds.Bottom); + } + } + + [Fact] + public void CreateColon_ShouldReturnTwoDotGeometriesInsideBounds() + { + var bounds = new Rect(10, 20, 20, 80); + var geometries = SegmentGeometryFactory.CreateColon(bounds, new SegmentGeometryOptions(8, 2)); + + geometries.Count.ShouldBe(2); + foreach (var geometry in geometries) + { + geometry.Bounds.X.ShouldBeGreaterThanOrEqualTo(bounds.X); + geometry.Bounds.Y.ShouldBeGreaterThanOrEqualTo(bounds.Y); + geometry.Bounds.Right.ShouldBeLessThanOrEqualTo(bounds.Right); + geometry.Bounds.Bottom.ShouldBeLessThanOrEqualTo(bounds.Bottom); + } + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(0.72)] + [InlineData(1)] + [InlineData(2)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void CreateDot_ShouldClampDotScaleWithoutInvalidGeometry(double dotScale) + { + var bounds = new Rect(10, 20, 20, 80); + var geometry = SegmentGeometryFactory.CreateDot(bounds, new SegmentGeometryOptions(8, 2, DotScale: dotScale), false); + + AssertFinite(geometry.Bounds); + geometry.Bounds.X.ShouldBeGreaterThanOrEqualTo(0); + geometry.Bounds.Y.ShouldBeGreaterThanOrEqualTo(0); + geometry.Bounds.Right.ShouldBeLessThanOrEqualTo(bounds.Right); + geometry.Bounds.Bottom.ShouldBeLessThanOrEqualTo(bounds.Bottom); + } + + private static void AssertFinite(Rect bounds) + { + bounds.X.ShouldNotBe(double.NaN); + bounds.Y.ShouldNotBe(double.NaN); + bounds.Width.ShouldNotBe(double.NaN); + bounds.Height.ShouldNotBe(double.NaN); + double.IsInfinity(bounds.X).ShouldBeFalse(); + double.IsInfinity(bounds.Y).ShouldBeFalse(); + double.IsInfinity(bounds.Width).ShouldBeFalse(); + double.IsInfinity(bounds.Height).ShouldBeFalse(); + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentLayoutEngineTests.cs b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentLayoutEngineTests.cs new file mode 100644 index 0000000..00c0449 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentLayoutEngineTests.cs @@ -0,0 +1,129 @@ +using AtomUI.Labs.Led.Segment.Character; +using AtomUI.Labs.Led.Segment.Layout; +using Avalonia; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Segment; + +public class SegmentLayoutEngineTests +{ + [Fact] + public void Calculate_ShouldReturnPaddingOnlySizeForEmptyText() + { + var layout = SegmentLayoutEngine.Calculate(null, CreateOptions(padding: new Thickness(1, 2, 3, 4))); + + layout.DesiredSize.ShouldBe(new Size(4, 6)); + layout.Slots.ShouldBeEmpty(); + } + + [Fact] + public void Calculate_ShouldCreateSlotForEveryInputCharacter() + { + var layout = SegmentLayoutEngine.Calculate("12:45", CreateOptions()); + + layout.Slots.Count.ShouldBe(5); + layout.Slots[0].Pattern.Character.ShouldBe('1'); + layout.Slots[1].Pattern.Character.ShouldBe('2'); + layout.Slots[2].Pattern.Kind.ShouldBe(SegmentCharacterKind.Colon); + layout.Slots[3].Pattern.Character.ShouldBe('4'); + layout.Slots[4].Pattern.Character.ShouldBe('5'); + } + + [Fact] + public void Calculate_ShouldUseNarrowWidthForColonAndDot() + { + var layout = SegmentLayoutEngine.Calculate("8:8.8", CreateOptions()); + + layout.Slots[0].Bounds.Width.ShouldBe(50); + layout.Slots[1].Bounds.Width.ShouldBe(16); + layout.Slots[2].Bounds.Width.ShouldBe(50); + layout.Slots[3].Bounds.Width.ShouldBe(16); + layout.Slots[4].Bounds.Width.ShouldBe(50); + } + + [Fact] + public void Calculate_ShouldApplyPaddingAndSpacingToDesiredSizeAndSlotPositions() + { + var layout = SegmentLayoutEngine.Calculate( + "88", + CreateOptions( + characterHeight: 100, + characterAspectRatio: 0.5, + characterSpacing: 6, + padding: new Thickness(2, 3, 4, 5))); + + layout.DesiredSize.ShouldBe(new Size(112, 108)); + layout.Slots[0].Bounds.ShouldBe(new Rect(2, 3, 50, 100)); + layout.Slots[1].Bounds.ShouldBe(new Rect(58, 3, 50, 100)); + } + + [Fact] + public void Calculate_ShouldUseFinalHeightWhenAvailable() + { + var layout = SegmentLayoutEngine.Calculate( + "8", + CreateOptions( + characterHeight: 40, + characterAspectRatio: 0.5, + padding: new Thickness(2, 3, 4, 5)), + new Size(200, 128)); + + layout.Slots[0].Bounds.Height.ShouldBe(120); + layout.Slots[0].Bounds.Width.ShouldBe(60); + layout.DesiredSize.ShouldBe(new Size(66, 128)); + } + + [Fact] + public void Calculate_ShouldKeepConfiguredHeightWhenFinalHeightIsInfinity() + { + var layout = SegmentLayoutEngine.Calculate( + "8", + CreateOptions(characterHeight: 40, characterAspectRatio: 0.5), + new Size(200, double.PositiveInfinity)); + + layout.Slots[0].Bounds.Height.ShouldBe(40); + layout.Slots[0].Bounds.Width.ShouldBe(20); + } + + [Fact] + public void Calculate_ShouldClampNegativeLayoutInputs() + { + var layout = SegmentLayoutEngine.Calculate( + "88", + CreateOptions(characterHeight: -10, characterAspectRatio: -1, characterSpacing: -2)); + + layout.DesiredSize.ShouldBe(new Size(0, 0)); + layout.Slots[0].Bounds.ShouldBe(new Rect(0, 0, 0, 0)); + layout.Slots[1].Bounds.ShouldBe(new Rect(0, 0, 0, 0)); + } + + [Fact] + public void Calculate_ShouldCoerceNonFiniteLayoutInputs() + { + var layout = SegmentLayoutEngine.Calculate( + "88", + CreateOptions( + characterHeight: double.NaN, + characterAspectRatio: double.PositiveInfinity, + characterSpacing: double.NegativeInfinity, + padding: new Thickness(double.NaN, -1, double.PositiveInfinity, 2))); + + layout.DesiredSize.ShouldBe(new Size(0, 2)); + layout.Slots[0].Bounds.ShouldBe(new Rect(0, 0, 0, 0)); + layout.Slots[1].Bounds.ShouldBe(new Rect(0, 0, 0, 0)); + } + + private static SegmentLayoutOptions CreateOptions( + double characterHeight = 100, + double characterAspectRatio = 0.5, + double characterSpacing = 4, + Thickness? padding = null) + { + return new SegmentLayoutOptions( + characterHeight, + characterAspectRatio, + characterSpacing, + padding ?? default); + } +} From 0a75f945c8823b54d0333f72ea8499c6bf6cdcea Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 15:20:43 +0800 Subject: [PATCH 10/33] add Led controls Matrix --- src/AtomUI.Labs.Led/Glow/LedGlowRenderer.cs | 76 ++ .../Glow/LedGlowValueSanitizer.cs | 18 + src/AtomUI.Labs.Led/LedCharacterNormalizer.cs | 11 + src/AtomUI.Labs.Led/LedDisplayLayoutMath.cs | 53 + src/AtomUI.Labs.Led/LedThemesProvider.axaml | 10 + src/AtomUI.Labs.Led/LedThemesProvider.cs | 12 + src/AtomUI.Labs.Led/Marquee/IMarqueeMotion.cs | 6 + .../Marquee/LedMarqueeController.cs | 110 +++ .../Marquee/LedMarqueeValueSanitizer.cs | 29 + .../Marquee/LeftThroughMarqueeMotion.cs | 11 + .../Marquee/MarqueeMotionContext.cs | 6 + .../Marquee/MarqueeRenderPlan.cs | 17 + .../Matrix/Character/MatrixCharacterMap.cs | 70 ++ .../Character/MatrixCharacterPattern.cs | 5 + .../Character/MatrixFiveBySevenGlyphMap.cs | 108 ++ .../Matrix/Character/MatrixGlyph.cs | 18 + .../Matrix/Layout/MatrixDisplayLayout.cs | 19 + .../Matrix/Layout/MatrixGlyphSlot.cs | 8 + .../Matrix/Layout/MatrixLayoutEngine.cs | 52 + .../Matrix/Layout/MatrixLayoutOptions.cs | 9 + src/AtomUI.Labs.Led/Matrix/MatrixDisplay.cs | 923 ++++++++++++++++++ .../Matrix/MatrixDisplayAutomationPeer.cs | 55 ++ src/AtomUI.Labs.Led/Matrix/MatrixDotShape.cs | 8 + .../Matrix/MatrixOverflowMode.cs | 7 + .../Rendering/MatrixDotShapeResolver.cs | 21 + .../Matrix/Rendering/MatrixGlyphGeometry.cs | 26 + .../Rendering/MatrixGlyphGeometryCacheKey.cs | 8 + .../Rendering/MatrixGlyphGeometryFactory.cs | 147 +++ .../MatrixPanelBorderGeometryFactory.cs | 154 +++ .../Matrix/Themes/MatrixDisplayTheme.axaml | 19 + .../Matrix/Themes/MatrixThemes.axaml | 7 + src/AtomUI.Labs.Led/Themes/LedThemes.axaml | 8 + 32 files changed, 2031 insertions(+) create mode 100644 src/AtomUI.Labs.Led/Glow/LedGlowRenderer.cs create mode 100644 src/AtomUI.Labs.Led/Glow/LedGlowValueSanitizer.cs create mode 100644 src/AtomUI.Labs.Led/LedCharacterNormalizer.cs create mode 100644 src/AtomUI.Labs.Led/LedDisplayLayoutMath.cs create mode 100644 src/AtomUI.Labs.Led/LedThemesProvider.axaml create mode 100644 src/AtomUI.Labs.Led/LedThemesProvider.cs create mode 100644 src/AtomUI.Labs.Led/Marquee/IMarqueeMotion.cs create mode 100644 src/AtomUI.Labs.Led/Marquee/LedMarqueeController.cs create mode 100644 src/AtomUI.Labs.Led/Marquee/LedMarqueeValueSanitizer.cs create mode 100644 src/AtomUI.Labs.Led/Marquee/LeftThroughMarqueeMotion.cs create mode 100644 src/AtomUI.Labs.Led/Marquee/MarqueeMotionContext.cs create mode 100644 src/AtomUI.Labs.Led/Marquee/MarqueeRenderPlan.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/Character/MatrixCharacterMap.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/Character/MatrixCharacterPattern.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/Character/MatrixFiveBySevenGlyphMap.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/Character/MatrixGlyph.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/Layout/MatrixDisplayLayout.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/Layout/MatrixGlyphSlot.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/Layout/MatrixLayoutEngine.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/Layout/MatrixLayoutOptions.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/MatrixDisplay.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/MatrixDisplayAutomationPeer.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/MatrixDotShape.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/MatrixOverflowMode.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/Rendering/MatrixDotShapeResolver.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometry.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometryCacheKey.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometryFactory.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/Rendering/MatrixPanelBorderGeometryFactory.cs create mode 100644 src/AtomUI.Labs.Led/Matrix/Themes/MatrixDisplayTheme.axaml create mode 100644 src/AtomUI.Labs.Led/Matrix/Themes/MatrixThemes.axaml create mode 100644 src/AtomUI.Labs.Led/Themes/LedThemes.axaml diff --git a/src/AtomUI.Labs.Led/Glow/LedGlowRenderer.cs b/src/AtomUI.Labs.Led/Glow/LedGlowRenderer.cs new file mode 100644 index 0000000..181ab51 --- /dev/null +++ b/src/AtomUI.Labs.Led/Glow/LedGlowRenderer.cs @@ -0,0 +1,76 @@ +using Avalonia; +using Avalonia.Media; + +namespace AtomUI.Labs.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.Led/Glow/LedGlowValueSanitizer.cs b/src/AtomUI.Labs.Led/Glow/LedGlowValueSanitizer.cs new file mode 100644 index 0000000..1d59394 --- /dev/null +++ b/src/AtomUI.Labs.Led/Glow/LedGlowValueSanitizer.cs @@ -0,0 +1,18 @@ +namespace AtomUI.Labs.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.Led/LedCharacterNormalizer.cs b/src/AtomUI.Labs.Led/LedCharacterNormalizer.cs new file mode 100644 index 0000000..adeea13 --- /dev/null +++ b/src/AtomUI.Labs.Led/LedCharacterNormalizer.cs @@ -0,0 +1,11 @@ +namespace AtomUI.Labs.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.Led/LedDisplayLayoutMath.cs b/src/AtomUI.Labs.Led/LedDisplayLayoutMath.cs new file mode 100644 index 0000000..ed17ede --- /dev/null +++ b/src/AtomUI.Labs.Led/LedDisplayLayoutMath.cs @@ -0,0 +1,53 @@ +using Avalonia; +using Avalonia.Layout; + +namespace AtomUI.Labs.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.Led/LedThemesProvider.axaml b/src/AtomUI.Labs.Led/LedThemesProvider.axaml new file mode 100644 index 0000000..d5e29d1 --- /dev/null +++ b/src/AtomUI.Labs.Led/LedThemesProvider.axaml @@ -0,0 +1,10 @@ + + + + + diff --git a/src/AtomUI.Labs.Led/LedThemesProvider.cs b/src/AtomUI.Labs.Led/LedThemesProvider.cs new file mode 100644 index 0000000..edb0741 --- /dev/null +++ b/src/AtomUI.Labs.Led/LedThemesProvider.cs @@ -0,0 +1,12 @@ +using AtomUI.Theme; +using Avalonia.Markup.Xaml; + +namespace AtomUI.Labs.Led; + +internal class LedThemesProvider : ControlThemesProvider +{ + public LedThemesProvider() + { + AvaloniaXamlLoader.Load(this); + } +} diff --git a/src/AtomUI.Labs.Led/Marquee/IMarqueeMotion.cs b/src/AtomUI.Labs.Led/Marquee/IMarqueeMotion.cs new file mode 100644 index 0000000..838d87f --- /dev/null +++ b/src/AtomUI.Labs.Led/Marquee/IMarqueeMotion.cs @@ -0,0 +1,6 @@ +namespace AtomUI.Labs.Led.Marquee; + +internal interface IMarqueeMotion +{ + MarqueeRenderPlan Calculate(in MarqueeMotionContext context); +} diff --git a/src/AtomUI.Labs.Led/Marquee/LedMarqueeController.cs b/src/AtomUI.Labs.Led/Marquee/LedMarqueeController.cs new file mode 100644 index 0000000..8921d0c --- /dev/null +++ b/src/AtomUI.Labs.Led/Marquee/LedMarqueeController.cs @@ -0,0 +1,110 @@ +using Avalonia; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Styling; + +namespace AtomUI.Labs.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.Led/Marquee/LedMarqueeValueSanitizer.cs b/src/AtomUI.Labs.Led/Marquee/LedMarqueeValueSanitizer.cs new file mode 100644 index 0000000..ddede10 --- /dev/null +++ b/src/AtomUI.Labs.Led/Marquee/LedMarqueeValueSanitizer.cs @@ -0,0 +1,29 @@ +namespace AtomUI.Labs.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.Led/Marquee/LeftThroughMarqueeMotion.cs b/src/AtomUI.Labs.Led/Marquee/LeftThroughMarqueeMotion.cs new file mode 100644 index 0000000..6c9479c --- /dev/null +++ b/src/AtomUI.Labs.Led/Marquee/LeftThroughMarqueeMotion.cs @@ -0,0 +1,11 @@ +namespace AtomUI.Labs.Led.Marquee; + +internal sealed class LeftThroughMarqueeMotion : IMarqueeMotion +{ + public MarqueeRenderPlan Calculate(in MarqueeMotionContext context) + { + var progress = double.IsNaN(context.Progress) ? 0 : Math.Clamp(context.Progress, 0, 1); + var distance = Math.Max(0, context.ViewportWidth) + Math.Max(0, context.ContentWidth); + return new MarqueeRenderPlan(1, Math.Max(0, context.ViewportWidth) - distance * progress); + } +} diff --git a/src/AtomUI.Labs.Led/Marquee/MarqueeMotionContext.cs b/src/AtomUI.Labs.Led/Marquee/MarqueeMotionContext.cs new file mode 100644 index 0000000..4f3a571 --- /dev/null +++ b/src/AtomUI.Labs.Led/Marquee/MarqueeMotionContext.cs @@ -0,0 +1,6 @@ +namespace AtomUI.Labs.Led.Marquee; + +internal readonly record struct MarqueeMotionContext( + double ViewportWidth, + double ContentWidth, + double Progress); diff --git a/src/AtomUI.Labs.Led/Marquee/MarqueeRenderPlan.cs b/src/AtomUI.Labs.Led/Marquee/MarqueeRenderPlan.cs new file mode 100644 index 0000000..f169551 --- /dev/null +++ b/src/AtomUI.Labs.Led/Marquee/MarqueeRenderPlan.cs @@ -0,0 +1,17 @@ +namespace AtomUI.Labs.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.Led/Matrix/Character/MatrixCharacterMap.cs b/src/AtomUI.Labs.Led/Matrix/Character/MatrixCharacterMap.cs new file mode 100644 index 0000000..e2b512c --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/Character/MatrixCharacterMap.cs @@ -0,0 +1,70 @@ +using AtomUI.Labs.Led; +using System.Text; + +namespace AtomUI.Labs.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.Led/Matrix/Character/MatrixCharacterPattern.cs b/src/AtomUI.Labs.Led/Matrix/Character/MatrixCharacterPattern.cs new file mode 100644 index 0000000..03e8284 --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/Character/MatrixCharacterPattern.cs @@ -0,0 +1,5 @@ +namespace AtomUI.Labs.Led.Matrix.Character; + +internal readonly record struct MatrixCharacterPattern( + char Character, + MatrixGlyph Glyph); diff --git a/src/AtomUI.Labs.Led/Matrix/Character/MatrixFiveBySevenGlyphMap.cs b/src/AtomUI.Labs.Led/Matrix/Character/MatrixFiveBySevenGlyphMap.cs new file mode 100644 index 0000000..5eb8d0c --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/Character/MatrixFiveBySevenGlyphMap.cs @@ -0,0 +1,108 @@ +namespace AtomUI.Labs.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.Led/Matrix/Character/MatrixGlyph.cs b/src/AtomUI.Labs.Led/Matrix/Character/MatrixGlyph.cs new file mode 100644 index 0000000..c2849e1 --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/Character/MatrixGlyph.cs @@ -0,0 +1,18 @@ +namespace AtomUI.Labs.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.Led/Matrix/Layout/MatrixDisplayLayout.cs b/src/AtomUI.Labs.Led/Matrix/Layout/MatrixDisplayLayout.cs new file mode 100644 index 0000000..38516ea --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/Layout/MatrixDisplayLayout.cs @@ -0,0 +1,19 @@ +using Avalonia; + +namespace AtomUI.Labs.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.Led/Matrix/Layout/MatrixGlyphSlot.cs b/src/AtomUI.Labs.Led/Matrix/Layout/MatrixGlyphSlot.cs new file mode 100644 index 0000000..fcd4868 --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/Layout/MatrixGlyphSlot.cs @@ -0,0 +1,8 @@ +using AtomUI.Labs.Led.Matrix.Character; +using Avalonia; + +namespace AtomUI.Labs.Led.Matrix.Layout; + +internal readonly record struct MatrixGlyphSlot( + MatrixCharacterPattern Pattern, + Point Origin); diff --git a/src/AtomUI.Labs.Led/Matrix/Layout/MatrixLayoutEngine.cs b/src/AtomUI.Labs.Led/Matrix/Layout/MatrixLayoutEngine.cs new file mode 100644 index 0000000..56f798b --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/Layout/MatrixLayoutEngine.cs @@ -0,0 +1,52 @@ +using AtomUI.Labs.Led.Matrix.Character; +using Avalonia; +using System.Text; + +namespace AtomUI.Labs.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.Led/Matrix/Layout/MatrixLayoutOptions.cs b/src/AtomUI.Labs.Led/Matrix/Layout/MatrixLayoutOptions.cs new file mode 100644 index 0000000..0e66b9b --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/Layout/MatrixLayoutOptions.cs @@ -0,0 +1,9 @@ +using Avalonia; + +namespace AtomUI.Labs.Led.Matrix.Layout; + +internal readonly record struct MatrixLayoutOptions( + double DotSize, + double DotSpacing, + double CharacterSpacing, + Thickness Padding); diff --git a/src/AtomUI.Labs.Led/Matrix/MatrixDisplay.cs b/src/AtomUI.Labs.Led/Matrix/MatrixDisplay.cs new file mode 100644 index 0000000..aea455c --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/MatrixDisplay.cs @@ -0,0 +1,923 @@ +using AtomUI.Labs.Led; +using AtomUI.Labs.Led.Glow; +using AtomUI.Labs.Led.Marquee; +using AtomUI.Labs.Led.Matrix.Character; +using AtomUI.Labs.Led.Matrix.Layout; +using AtomUI.Labs.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.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; + + #endregion + + private bool _hasLayoutCache; + private static readonly IMarqueeMotion MarqueeMotion = new LeftThroughMarqueeMotion(); + private LedGlowRenderer? _glowRenderer; + private LedMarqueeController? _marqueeController; + private Size _arrangedSize; + private bool _isAttachedToVisualTree; + 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; + UpdateMarqueeAnimation(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + _isAttachedToVisualTree = false; + 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 == 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(); + if (IsMarqueeEnabled && layout.Slots.Count > 0) + { + RenderMarqueeContent(context, layout, options, contentViewport, activeBrush); + 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 = GetEffectiveGlowRadius(); + if (effectiveGlowRadius > 0) + { + visibleBounds = visibleBounds.Inflate(effectiveGlowRadius); + } + using (context.PushClip(contentViewport)) + using (PushLayoutTransform(context, scale, offset)) + { + RenderVisibleGlyphs(context, layout, visibleBounds, options, activeBrush); + } + } + + private void RenderMarqueeContent( + DrawingContext context, + MatrixDisplayLayout layout, + MatrixLayoutOptions options, + Rect contentViewport, + IBrush activeBrush) + { + if (contentViewport.Width <= 0 || contentViewport.Height <= 0) + { + return; + } + + var verticalOffset = LedDisplayLayoutMath.CalculateAlignmentOffset( + layout.DesiredSize, + contentViewport.Size, + 1, + HorizontalAlignment.Left, + VerticalContentAlignment).Y; + var plan = MarqueeMotion.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 = GetEffectiveGlowRadius(); + if (effectiveGlowRadius > 0) + { + visibleBounds = visibleBounds.Inflate(effectiveGlowRadius); + } + + using (PushLayoutTransform(context, 1, offset)) + { + RenderVisibleGlyphs(context, layout, visibleBounds, options, activeBrush); + } + } + } + } + + private void UpdateMarqueeAnimation() + { + if (!_isAttachedToVisualTree || !IsVisible || !IsMarqueeEnabled || 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 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 double GetEffectiveGlowRadius() + { + return GlowBrush is not null && LedGlowValueSanitizer.CoerceOpacity(GlowOpacity) > 0 + ? LedGlowValueSanitizer.CoerceRadius(GlowRadius) + : 0; + } + + private LedGlowRenderScope PushGlow(DrawingContext context, Rect activeBounds) + { + var glowBrush = GlowBrush; + var opacity = LedGlowValueSanitizer.CoerceOpacity(GlowOpacity); + var radius = LedGlowValueSanitizer.CoerceRadius(GlowRadius); + if (glowBrush is null || opacity <= 0 || radius <= 0) + { + return default; + } + + _glowRenderer ??= new LedGlowRenderer(); + return _glowRenderer.Push(context, glowBrush, opacity, radius, activeBounds); + } + + private void RenderVisibleGlyphs( + DrawingContext context, + MatrixDisplayLayout layout, + Rect visibleBounds, + MatrixLayoutOptions options, + IBrush activeBrush) + { + 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)) + { + if (glowScope.IsActive) + { + RenderActiveGlyphs(context, layout, options, firstVisibleIndex, lastVisibleIndex, GlowBrush!); + } + } + } + + RenderActiveGlyphs(context, layout, options, firstVisibleIndex, lastVisibleIndex, activeBrush); + } + + 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.Led/Matrix/MatrixDisplayAutomationPeer.cs b/src/AtomUI.Labs.Led/Matrix/MatrixDisplayAutomationPeer.cs new file mode 100644 index 0000000..69c7bcd --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/MatrixDisplayAutomationPeer.cs @@ -0,0 +1,55 @@ +using AtomUI.Labs.Led.Matrix.Character; +using Avalonia.Automation; +using Avalonia.Automation.Peers; + +namespace AtomUI.Labs.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.Led/Matrix/MatrixDotShape.cs b/src/AtomUI.Labs.Led/Matrix/MatrixDotShape.cs new file mode 100644 index 0000000..dcb8d7a --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/MatrixDotShape.cs @@ -0,0 +1,8 @@ +namespace AtomUI.Labs.Led.Matrix; + +public enum MatrixDotShape +{ + Circle, + Square, + RoundedSquare +} diff --git a/src/AtomUI.Labs.Led/Matrix/MatrixOverflowMode.cs b/src/AtomUI.Labs.Led/Matrix/MatrixOverflowMode.cs new file mode 100644 index 0000000..91d0929 --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/MatrixOverflowMode.cs @@ -0,0 +1,7 @@ +namespace AtomUI.Labs.Led.Matrix; + +public enum MatrixOverflowMode +{ + Clip, + ScaleDown +} diff --git a/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixDotShapeResolver.cs b/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixDotShapeResolver.cs new file mode 100644 index 0000000..ff377b8 --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixDotShapeResolver.cs @@ -0,0 +1,21 @@ +namespace AtomUI.Labs.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.Led/Matrix/Rendering/MatrixGlyphGeometry.cs b/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometry.cs new file mode 100644 index 0000000..e5dfab6 --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometry.cs @@ -0,0 +1,26 @@ +using Avalonia.Media; + +namespace AtomUI.Labs.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.Led/Matrix/Rendering/MatrixGlyphGeometryCacheKey.cs b/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometryCacheKey.cs new file mode 100644 index 0000000..1c51c77 --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometryCacheKey.cs @@ -0,0 +1,8 @@ +namespace AtomUI.Labs.Led.Matrix.Rendering; + +internal readonly record struct MatrixGlyphGeometryCacheKey( + ulong Bits, + double DotSize, + double DotSpacing, + MatrixDotShape DotShape, + double DotCornerRadiusRatio); diff --git a/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometryFactory.cs b/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometryFactory.cs new file mode 100644 index 0000000..f1e7e73 --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometryFactory.cs @@ -0,0 +1,147 @@ +using AtomUI.Labs.Led.Matrix.Character; +using Avalonia; +using Avalonia.Media; + +namespace AtomUI.Labs.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.Led/Matrix/Rendering/MatrixPanelBorderGeometryFactory.cs b/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixPanelBorderGeometryFactory.cs new file mode 100644 index 0000000..1bc7b5a --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixPanelBorderGeometryFactory.cs @@ -0,0 +1,154 @@ +using Avalonia; +using Avalonia.Media; + +namespace AtomUI.Labs.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.Led/Matrix/Themes/MatrixDisplayTheme.axaml b/src/AtomUI.Labs.Led/Matrix/Themes/MatrixDisplayTheme.axaml new file mode 100644 index 0000000..c927a6c --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/Themes/MatrixDisplayTheme.axaml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/src/AtomUI.Labs.Led/Matrix/Themes/MatrixThemes.axaml b/src/AtomUI.Labs.Led/Matrix/Themes/MatrixThemes.axaml new file mode 100644 index 0000000..57f4459 --- /dev/null +++ b/src/AtomUI.Labs.Led/Matrix/Themes/MatrixThemes.axaml @@ -0,0 +1,7 @@ + + + + + diff --git a/src/AtomUI.Labs.Led/Themes/LedThemes.axaml b/src/AtomUI.Labs.Led/Themes/LedThemes.axaml new file mode 100644 index 0000000..4e4e1db --- /dev/null +++ b/src/AtomUI.Labs.Led/Themes/LedThemes.axaml @@ -0,0 +1,8 @@ + + + + + + From a70688ae3d4e89dc3521b2806c2783da74c7e2c0 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 15:21:21 +0800 Subject: [PATCH 11/33] add tests of Led controls Matrix --- .../Matrix/MatrixAxamlHost.axaml | 17 + .../Matrix/MatrixAxamlHost.axaml.cs | 15 + .../Matrix/MatrixCharacterMapTests.cs | 85 +++ .../Matrix/MatrixDisplayAutomationTests.cs | 81 +++ .../Matrix/MatrixDisplayContractTests.cs | 139 +++++ .../Matrix/MatrixDisplayInvalidationTests.cs | 112 ++++ .../Matrix/MatrixDisplayMeasureTests.cs | 126 ++++ .../Matrix/MatrixDisplayPixelTests.cs | 390 ++++++++++++ .../Matrix/MatrixDisplayRenderTests.cs | 586 ++++++++++++++++++ .../Matrix/MatrixDisplayThemeTests.cs | 184 ++++++ .../Matrix/MatrixDynamicLoadTests.cs | 102 +++ .../Matrix/MatrixGlyphGeometryCacheTests.cs | 209 +++++++ .../Matrix/MatrixGlyphGeometryFactoryTests.cs | 94 +++ .../MatrixGlyphGeometryLifecycleTests.cs | 170 +++++ .../Matrix/MatrixGlyphMapTests.cs | 88 +++ .../Matrix/MatrixLayoutEngineTests.cs | 111 ++++ .../Matrix/MatrixMarqueeLifecycleTests.cs | 124 ++++ .../Matrix/MatrixMarqueeMotionTests.cs | 45 ++ .../MatrixMarqueeValueSanitizerTests.cs | 29 + .../MatrixPanelBorderGeometryFactoryTests.cs | 54 ++ .../Matrix/MatrixUnicodeStressTests.cs | 72 +++ 21 files changed, 2833 insertions(+) create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixAxamlHost.axaml create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixAxamlHost.axaml.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixCharacterMapTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayAutomationTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayContractTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayInvalidationTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayMeasureTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayPixelTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayRenderTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayThemeTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDynamicLoadTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixGlyphGeometryCacheTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixGlyphGeometryFactoryTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixGlyphGeometryLifecycleTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixGlyphMapTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixLayoutEngineTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeLifecycleTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeMotionTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeValueSanitizerTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixPanelBorderGeometryFactoryTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Matrix/MatrixUnicodeStressTests.cs diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixAxamlHost.axaml b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixAxamlHost.axaml new file mode 100644 index 0000000..4ab7f46 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixAxamlHost.axaml @@ -0,0 +1,17 @@ + + + diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixAxamlHost.axaml.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixAxamlHost.axaml.cs new file mode 100644 index 0000000..3d53695 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixAxamlHost.axaml.cs @@ -0,0 +1,15 @@ +using AtomUI.Labs.Led.Matrix; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace AtomUI.Labs.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.Led.Tests/Matrix/MatrixCharacterMapTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixCharacterMapTests.cs new file mode 100644 index 0000000..2e4dce9 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixCharacterMapTests.cs @@ -0,0 +1,85 @@ +using AtomUI.Labs.Led.Matrix.Character; +using System.Text; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.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.Led.Tests/Matrix/MatrixDisplayAutomationTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayAutomationTests.cs new file mode 100644 index 0000000..0dbb8dd --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayAutomationTests.cs @@ -0,0 +1,81 @@ +using AtomUI.Labs.Led.Matrix; +using AtomUI.Labs.Led.Matrix.Character; +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.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.Led.Tests/Matrix/MatrixDisplayContractTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayContractTests.cs new file mode 100644 index 0000000..86b9a0e --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayContractTests.cs @@ -0,0 +1,139 @@ +using AtomUI.Labs.Led.Matrix; +using Avalonia; +using Avalonia.Layout; +using Avalonia.Media; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.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.Led.Tests/Matrix/MatrixDisplayInvalidationTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayInvalidationTests.cs new file mode 100644 index 0000000..1bf3ab1 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayInvalidationTests.cs @@ -0,0 +1,112 @@ +using AtomUI.Labs.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.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.Led.Tests/Matrix/MatrixDisplayMeasureTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayMeasureTests.cs new file mode 100644 index 0000000..02a75ee --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayMeasureTests.cs @@ -0,0 +1,126 @@ +using AtomUI.Labs.Led.Matrix; +using Avalonia; +using Avalonia.Media; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.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.Led.Tests/Matrix/MatrixDisplayPixelTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayPixelTests.cs new file mode 100644 index 0000000..1343b3f --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayPixelTests.cs @@ -0,0 +1,390 @@ +using System.Runtime.InteropServices; +using AtomUI.Labs.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.Led.Tests.Matrix; + +public class MatrixDisplayPixelTests +{ + static MatrixDisplayPixelTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + public static IEnumerable DotShapeScalingCases() + { + var shapes = new[] + { + MatrixDotShape.Circle, + MatrixDotShape.Square, + MatrixDotShape.RoundedSquare + }; + var scales = new[] { 1.0, 1.25, 1.5, 2.0 }; + return shapes.SelectMany(shape => scales.Select(scale => new object[] { shape, scale })); + } + + [Theory] + [InlineData("A", 18)] + [InlineData("M", 18)] + [InlineData("0", 19)] + [InlineData("8", 17)] + [InlineData("?", 9)] + [InlineData(":", 8)] + [InlineData(" ", 0)] + public void RepresentativeGlyphs_ShouldRenderOneDisconnectedRegionPerActiveDot(string text, int expectedRegions) + { + var analysis = Capture(CreateDisplay(text), 1); + + analysis.ConnectedRegions.ShouldBe(expectedRegions); + } + + [Theory] + [InlineData(1.0)] + [InlineData(1.25)] + [InlineData(1.5)] + [InlineData(2.0)] + public void GlyphPhysicalBounds_ShouldTrackRenderScaling(double renderScaling) + { + var analysis = Capture(CreateDisplay("8"), renderScaling); + + analysis.HasContent.ShouldBeTrue(); + (analysis.ContentWidth / renderScaling).ShouldBe(32, 1.5); + (analysis.ContentHeight / renderScaling).ShouldBe(46, 1.5); + } + + [Fact] + public void Clip_ShouldKeepContentPixelsInsideControlBounds() + { + var display = CreateDisplay("8888"); + display.Width = 40; + display.Height = 60; + display.OverflowMode = MatrixOverflowMode.Clip; + + var analysis = Capture(display, 1.5); + + analysis.HasContent.ShouldBeTrue(); + analysis.MaxX.ShouldBeLessThan(60); + analysis.MaxY.ShouldBeLessThan(90); + } + + [Fact] + public void ScaleDown_ShouldKeepCompleteLongTextInsideControlBounds() + { + var display = CreateDisplay("8888"); + display.Width = 80; + display.Height = 30; + display.OverflowMode = MatrixOverflowMode.ScaleDown; + + var analysis = Capture(display, 1); + + analysis.HasContent.ShouldBeTrue(); + analysis.MinX.ShouldBeGreaterThanOrEqualTo(0); + analysis.MinY.ShouldBeGreaterThanOrEqualTo(0); + analysis.MaxX.ShouldBeLessThan(80); + analysis.MaxY.ShouldBeLessThan(30); + } + + [Fact] + public void ContentAlignment_ShouldMovePixelsWithoutChangingTheirSize() + { + var left = CreateDisplay("8"); + left.Width = 200; + left.Height = 80; + + var center = CreateDisplay("8"); + center.Width = 200; + center.Height = 80; + center.HorizontalContentAlignment = HorizontalAlignment.Center; + center.VerticalContentAlignment = VerticalAlignment.Center; + + var right = CreateDisplay("8"); + right.Width = 200; + right.Height = 80; + right.HorizontalContentAlignment = HorizontalAlignment.Right; + right.VerticalContentAlignment = VerticalAlignment.Bottom; + + var leftAnalysis = Capture(left, 1); + var centerAnalysis = Capture(center, 1); + var rightAnalysis = Capture(right, 1); + + leftAnalysis.ContentWidth.ShouldBe(centerAnalysis.ContentWidth); + leftAnalysis.ContentWidth.ShouldBe(rightAnalysis.ContentWidth); + leftAnalysis.ContentHeight.ShouldBe(centerAnalysis.ContentHeight); + leftAnalysis.ContentHeight.ShouldBe(rightAnalysis.ContentHeight); + leftAnalysis.MinX.ShouldBeLessThan(centerAnalysis.MinX); + centerAnalysis.MinX.ShouldBeLessThan(rightAnalysis.MinX); + leftAnalysis.MinY.ShouldBeLessThan(centerAnalysis.MinY); + centerAnalysis.MinY.ShouldBeLessThan(rightAnalysis.MinY); + } + + [Fact] + public void InactiveDots_ShouldAddVisiblePixelsWithoutChangingActiveDots() + { + var activeOnly = CreateDisplay("A"); + var withInactive = CreateDisplay("A"); + withInactive.ShowInactiveDots = true; + withInactive.InactiveBrush = new SolidColorBrush(Color.FromRgb(72, 72, 72)); + + var activeOnlyAnalysis = Capture(activeOnly, 1); + var withInactiveAnalysis = Capture(withInactive, 1); + + withInactiveAnalysis.VisiblePixelCount.ShouldBeGreaterThan(activeOnlyAnalysis.VisiblePixelCount); + activeOnlyAnalysis.ConnectedRegions.ShouldBe(18); + withInactiveAnalysis.ConnectedRegions.ShouldBe(35); + } + + [Theory] + [MemberData(nameof(DotShapeScalingCases))] + public void EveryDotShape_ShouldKeepGlyphDotsDisconnectedAcrossRenderScaling( + MatrixDotShape dotShape, + double renderScaling) + { + var display = CreateDisplay("8"); + display.DotShape = dotShape; + + var analysis = Capture(display, renderScaling); + + analysis.ConnectedRegions.ShouldBe(17); + } + + [Fact] + public void DotShape_ShouldIncreaseVisualDensityFromCircleToRoundedSquareToSquare() + { + var circle = CreateDisplay("8"); + var roundedSquare = CreateDisplay("8"); + roundedSquare.DotShape = MatrixDotShape.RoundedSquare; + roundedSquare.DotCornerRadiusRatio = 0.25; + var square = CreateDisplay("8"); + square.DotShape = MatrixDotShape.Square; + circle.DotSize = roundedSquare.DotSize = square.DotSize = 10; + circle.DotSpacing = roundedSquare.DotSpacing = square.DotSpacing = 4; + + var circleAnalysis = Capture(circle, 2); + var roundedSquareAnalysis = Capture(roundedSquare, 2); + var squareAnalysis = Capture(square, 2); + + roundedSquareAnalysis.VisiblePixelCount.ShouldBeGreaterThan(circleAnalysis.VisiblePixelCount); + squareAnalysis.VisiblePixelCount.ShouldBeGreaterThan(roundedSquareAnalysis.VisiblePixelCount); + } + + [Theory] + [InlineData(1.0)] + [InlineData(1.25)] + [InlineData(1.5)] + [InlineData(2.0)] + public void AsymmetricRoundedBorder_ShouldRemainOneConnectedRegionAcrossRenderScaling(double renderScaling) + { + var display = CreateDisplay(""); + display.Width = 100; + display.Height = 60; + display.BorderBrush = Brushes.White; + display.BorderThickness = new Thickness(2, 4, 6, 8); + display.CornerRadius = new CornerRadius(12, 8, 16, 4); + + var analysis = Capture(display, renderScaling); + + analysis.ConnectedRegions.ShouldBe(1); + analysis.MinX.ShouldBe(0); + analysis.MinY.ShouldBe(0); + analysis.MaxX.ShouldBeLessThanOrEqualTo((int)Math.Ceiling(100 * renderScaling) - 1); + analysis.MaxY.ShouldBeLessThanOrEqualTo((int)Math.Ceiling(60 * renderScaling) - 1); + } + + [Fact] + public void SquareAsymmetricBorder_ShouldUseConfiguredSideThicknesses() + { + var display = CreateDisplay(""); + display.Width = 100; + display.Height = 60; + display.BorderBrush = Brushes.White; + display.BorderThickness = new Thickness(2, 4, 6, 8); + + var pixels = CapturePixels(display, 1); + + pixels.IsVisible(1, 30).ShouldBeTrue(); + pixels.IsVisible(3, 30).ShouldBeFalse(); + pixels.IsVisible(50, 2).ShouldBeTrue(); + pixels.IsVisible(50, 6).ShouldBeFalse(); + pixels.IsVisible(96, 30).ShouldBeTrue(); + pixels.IsVisible(92, 30).ShouldBeFalse(); + pixels.IsVisible(50, 56).ShouldBeTrue(); + pixels.IsVisible(50, 50).ShouldBeFalse(); + } + + private static MatrixDisplay CreateDisplay(string text) + { + return new MatrixDisplay + { + Text = text, + Width = 220, + Height = 100, + DotSize = 4, + DotSpacing = 3, + CharacterSpacing = 8, + Padding = default, + Background = Brushes.Black, + ActiveBrush = Brushes.White, + InactiveBrush = null, + ShowInactiveDots = false, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top + }; + } + + private static PixelAnalysis Capture(MatrixDisplay display, double renderScaling) + { + var pixels = CapturePixels(display, renderScaling); + return Analyze(pixels.Bytes, pixels.Width, pixels.Height, pixels.RowBytes); + } + + private static PixelFrame CapturePixels(MatrixDisplay display, double renderScaling) + { + var window = new Window + { + Width = 240, + Height = 120, + Background = Brushes.Black, + Content = display + }; + + 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 PixelAnalysis Analyze(byte[] bytes, int width, int height, int rowBytes) + { + var visible = new bool[width * height]; + var visibleCount = 0; + var minX = width; + var minY = height; + var maxX = -1; + var maxY = -1; + + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + var offset = y * rowBytes + x * 4; + var isVisible = bytes[offset] > 8 || bytes[offset + 1] > 8 || bytes[offset + 2] > 8; + if (!isVisible) + { + continue; + } + + visible[y * width + x] = true; + visibleCount++; + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x); + maxY = Math.Max(maxY, y); + } + } + + return new PixelAnalysis( + visibleCount, + CountConnectedRegions(visible, width, height), + minX, + minY, + maxX, + maxY); + } + + private static int CountConnectedRegions(bool[] visible, int width, int height) + { + var visited = new bool[visible.Length]; + var queue = new Queue(); + var regionCount = 0; + var directions = new[] { -1, 0, 1 }; + + for (var index = 0; index < visible.Length; index++) + { + if (!visible[index] || visited[index]) + { + continue; + } + + regionCount++; + visited[index] = true; + queue.Enqueue(index); + while (queue.Count > 0) + { + var current = queue.Dequeue(); + var x = current % width; + var y = current / width; + foreach (var dy in directions) + { + foreach (var dx in directions) + { + if (dx == 0 && dy == 0) + { + continue; + } + + var nextX = x + dx; + var nextY = y + dy; + if ((uint)nextX >= width || (uint)nextY >= height) + { + continue; + } + + var next = nextY * width + nextX; + if (visible[next] && !visited[next]) + { + visited[next] = true; + queue.Enqueue(next); + } + } + } + } + } + + return regionCount; + } + + private readonly record struct PixelAnalysis( + int VisiblePixelCount, + int ConnectedRegions, + int MinX, + int MinY, + int MaxX, + int MaxY) + { + public bool HasContent => VisiblePixelCount > 0; + + public int ContentWidth => HasContent ? MaxX - MinX + 1 : 0; + + public int ContentHeight => HasContent ? MaxY - MinY + 1 : 0; + } + + private readonly record struct PixelFrame(byte[] Bytes, int Width, int Height, int RowBytes) + { + public bool IsVisible(int x, int y) + { + var offset = y * RowBytes + x * 4; + return Bytes[offset] > 8 || Bytes[offset + 1] > 8 || Bytes[offset + 2] > 8; + } + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayRenderTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayRenderTests.cs new file mode 100644 index 0000000..bdae1f1 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayRenderTests.cs @@ -0,0 +1,586 @@ +using AtomUI.Labs.Led.Matrix; +using Avalonia; +using Avalonia.Layout; +using Avalonia.Media; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Matrix; + +public class MatrixDisplayRenderTests +{ + static MatrixDisplayRenderTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + [Theory] + [InlineData("MATRIX")] + [InlineData("labs 2026")] + [InlineData("?-.:_+=/")] + [InlineData("A中Z")] + [InlineData("")] + public void Render_ShouldNotThrowForSupportedFallbackAndEmptyText(string text) + { + var display = CreateDisplay(text); + + var drawing = RenderToDrawingGroup(display); + + drawing.Children.ShouldNotBeNull(); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(1, 1)] + [InlineData(12, 4)] + public void Render_ShouldNotThrowForSmallBounds(double width, double height) + { + var display = CreateDisplay("88"); + display.Measure(new Size(width, height)); + display.Arrange(new Rect(0, 0, width, height)); + + var drawing = RenderToDrawingGroup(display); + + drawing.Children.ShouldNotBeNull(); + } + + [Fact] + public void Render_ShouldBatchActiveAndInactiveDotsIntoTwoGeometryCommands() + { + var display = CreateDisplay("A"); + + var dots = RenderToGlyphLayerDrawings(display).ToList(); + + dots.Count.ShouldBe(2); + CountBrush(dots, Colors.Red).ShouldBe(1); + CountBrush(dots, Colors.DarkGray).ShouldBe(1); + } + + [Theory] + [InlineData(MatrixDotShape.Circle)] + [InlineData(MatrixDotShape.Square)] + [InlineData(MatrixDotShape.RoundedSquare)] + public void Render_ShouldKeepTwoGeometryCommandsForEveryDotShape(MatrixDotShape dotShape) + { + var display = CreateDisplay("A"); + display.DotShape = dotShape; + + var dots = RenderToGlyphLayerDrawings(display).ToList(); + + dots.Count.ShouldBe(2); + } + + [Fact] + public void Render_ShouldSkipInactiveDotsWhenDisabled() + { + var display = CreateDisplay("A"); + display.ShowInactiveDots = false; + + var dots = RenderToGlyphLayerDrawings(display).ToList(); + + dots.Count.ShouldBe(1); + CountBrush(dots, Colors.Red).ShouldBe(1); + CountBrush(dots, Colors.DarkGray).ShouldBe(0); + } + + [Fact] + public void Render_ShouldSkipInactiveDotsWhenInactiveBrushIsNull() + { + var display = CreateDisplay("A"); + display.InactiveBrush = null; + + var dots = RenderToGlyphLayerDrawings(display).ToList(); + + dots.Count.ShouldBe(1); + } + + [Fact] + public void Render_ShouldDrawOnlyBackgroundWhenActiveBrushIsNull() + { + var display = CreateDisplay("A"); + display.ActiveBrush = null; + + var drawings = EnumerateGeometryDrawings(RenderToDrawingGroup(display)).ToList(); + + drawings.Count.ShouldBe(1); + CountBrush(drawings, Colors.Black).ShouldBe(1); + } + + [Fact] + public void Render_ShouldDrawUniformBorderOnlyWhenBrushAndThicknessArePresent() + { + var display = CreateDisplay("A"); + display.BorderBrush = Brushes.Blue; + display.BorderThickness = new Thickness(2); + + var drawings = EnumerateGeometryDrawings(RenderToDrawingGroup(display)).ToList(); + + drawings.Count(drawing => HasPenBrush(drawing, Colors.Blue)).ShouldBe(1); + + display.BorderBrush = null; + drawings = EnumerateGeometryDrawings(RenderToDrawingGroup(display)).ToList(); + drawings.Count(drawing => HasPenBrush(drawing, Colors.Blue)).ShouldBe(0); + + display.BorderBrush = Brushes.Blue; + display.BorderThickness = default; + drawings = EnumerateGeometryDrawings(RenderToDrawingGroup(display)).ToList(); + drawings.Count(drawing => HasPenBrush(drawing, Colors.Blue)).ShouldBe(0); + } + + [Fact] + public void Render_ShouldDrawAsymmetricBorderAsSingleGeometry() + { + var display = CreateDisplay("A"); + display.BorderBrush = Brushes.Blue; + display.BorderThickness = new Thickness(1, 2, 3, 4); + display.CornerRadius = new CornerRadius(12); + + var drawings = EnumerateGeometryDrawings(RenderToDrawingGroup(display)).ToList(); + + CountBrush(drawings, Colors.Blue).ShouldBe(1); + display.BorderGeometryBuildCount.ShouldBe(1); + } + + [Fact] + public void Render_ShouldKeepBorderWhenActiveBrushIsNull() + { + var display = CreateDisplay("A"); + display.ActiveBrush = null; + display.BorderBrush = Brushes.Blue; + display.BorderThickness = new Thickness(1, 2, 3, 4); + + var drawings = EnumerateGeometryDrawings(RenderToDrawingGroup(display)).ToList(); + + CountBrush(drawings, Colors.Black).ShouldBe(1); + CountBrush(drawings, Colors.Blue).ShouldBe(1); + } + + [Fact] + public void Render_ShouldReuseComplexBorderGeometryForBrushChanges() + { + var display = CreateDisplay("A"); + display.BorderBrush = Brushes.Blue; + display.BorderThickness = new Thickness(1, 2, 3, 4); + RenderToDrawingGroup(display); + var buildCount = display.BorderGeometryBuildCount; + + display.BorderBrush = Brushes.Green; + RenderToDrawingGroup(display); + + display.BorderGeometryBuildCount.ShouldBe(buildCount); + } + + [Fact] + public void Render_ShouldAlignContentInsideBorderViewport() + { + var display = CreateDisplay("A"); + display.Background = null; + display.BorderBrush = Brushes.Blue; + display.BorderThickness = new Thickness(10, 12, 14, 16); + display.HorizontalContentAlignment = HorizontalAlignment.Left; + display.VerticalContentAlignment = VerticalAlignment.Top; + + var transform = FindLayoutTransform(RenderToDrawingGroup(display)); + + transform.ShouldNotBeNull(); + transform.Value.M31.ShouldBe(10); + transform.Value.M32.ShouldBe(12); + } + + [Fact] + public void Render_ShouldScaleContentButKeepBorderPenThickness() + { + var display = CreateDisplay("1234567890"); + display.Background = null; + display.BorderBrush = Brushes.Blue; + display.BorderThickness = new Thickness(4); + display.OverflowMode = MatrixOverflowMode.ScaleDown; + display.Measure(new Size(120, 40)); + display.Arrange(new Rect(0, 0, 120, 40)); + + var drawing = RenderToDrawingGroup(display); + var transform = FindLayoutTransform(drawing); + var border = EnumerateGeometryDrawings(drawing).Single(item => HasPenBrush(item, Colors.Blue)); + + transform.ShouldNotBeNull(); + transform.Value.M11.ShouldBeLessThan(1); + border.Pen!.Thickness.ShouldBe(4); + } + + [Fact] + public void Render_ShouldCoerceInvalidBorderValuesWithoutThrowing() + { + var display = CreateDisplay("A"); + display.BorderBrush = Brushes.Blue; + display.BorderThickness = new Thickness(double.NaN, -1, double.PositiveInfinity, 2); + display.CornerRadius = new CornerRadius(double.PositiveInfinity, -1, double.NaN, 4); + + var drawings = EnumerateGeometryDrawings(RenderToDrawingGroup(display)).ToList(); + + drawings.All(drawing => drawing.Geometry is null + || MatrixValueSanitizer.IsFinite(drawing.Geometry.Bounds.Width)).ShouldBeTrue(); + } + + [Fact] + public void Render_ShouldReuseLayoutForRepeatedRenderAndVisualOnlyChanges() + { + var display = CreateDisplay("AB"); + + RenderToDrawingGroup(display); + var firstVersion = display.LayoutCacheVersion; + RenderToDrawingGroup(display); + display.ActiveBrush = Brushes.Blue; + display.InactiveBrush = Brushes.Gray; + display.HorizontalContentAlignment = HorizontalAlignment.Center; + display.VerticalContentAlignment = VerticalAlignment.Bottom; + display.OverflowMode = MatrixOverflowMode.ScaleDown; + display.DotShape = MatrixDotShape.RoundedSquare; + display.DotCornerRadiusRatio = 0.35; + RenderToDrawingGroup(display); + + display.LayoutCacheVersion.ShouldBe(firstVersion); + } + + [Fact] + public void Render_ShouldRefreshLayoutForTextAndSizeChanges() + { + var display = CreateDisplay("AB"); + + RenderToDrawingGroup(display); + var firstVersion = display.LayoutCacheVersion; + display.Text = "CD"; + RenderToDrawingGroup(display); + var secondVersion = display.LayoutCacheVersion; + display.DotSize = 9; + RenderToDrawingGroup(display); + + secondVersion.ShouldBe(firstVersion + 1); + display.LayoutCacheVersion.ShouldBe(secondVersion + 1); + } + + [Fact] + public void Render_ShouldCenterContentInLargerBounds() + { + var display = CreateDisplay("A"); + display.Background = null; + display.HorizontalContentAlignment = HorizontalAlignment.Center; + display.VerticalContentAlignment = VerticalAlignment.Center; + display.Measure(new Size(300, 120)); + display.Arrange(new Rect(0, 0, 300, 120)); + + var transform = FindLayoutTransform(RenderToDrawingGroup(display)); + + transform.ShouldNotBeNull(); + transform.Value.M11.ShouldBe(1); + transform.Value.M22.ShouldBe(1); + transform.Value.M31.ShouldBeGreaterThan(0); + transform.Value.M32.ShouldBeGreaterThan(0); + } + + [Fact] + public void Render_ShouldScaleDownConstrainedContentWithoutScalingUp() + { + var display = CreateDisplay("1234567890"); + display.Background = null; + display.OverflowMode = MatrixOverflowMode.ScaleDown; + display.HorizontalContentAlignment = HorizontalAlignment.Center; + display.VerticalContentAlignment = VerticalAlignment.Center; + display.Measure(new Size(120, 30)); + display.Arrange(new Rect(0, 0, 120, 30)); + + var transform = FindLayoutTransform(RenderToDrawingGroup(display)); + + transform.ShouldNotBeNull(); + transform.Value.M11.ShouldBeGreaterThanOrEqualTo(0); + transform.Value.M11.ShouldBeLessThan(1); + transform.Value.M22.ShouldBe(transform.Value.M11); + } + + [Fact] + public void Render_ShouldKeepIdentityScaleForClipOverflow() + { + var display = CreateDisplay("1234567890"); + display.Background = null; + display.OverflowMode = MatrixOverflowMode.Clip; + display.Measure(new Size(120, 30)); + display.Arrange(new Rect(0, 0, 120, 30)); + + var transform = FindLayoutTransform(RenderToDrawingGroup(display)); + + transform.ShouldNotBeNull(); + transform.Value.M11.ShouldBe(1); + transform.Value.M22.ShouldBe(1); + } + + [Theory] + [InlineData(0)] + [InlineData(0.25)] + [InlineData(0.5)] + [InlineData(0.75)] + [InlineData(1)] + public void Render_ShouldApplyMarqueePositionWithoutScaleDown(double progress) + { + var display = CreateDisplay("MATRIX"); + display.IsMarqueeEnabled = true; + display.OverflowMode = MatrixOverflowMode.ScaleDown; + display.HorizontalContentAlignment = HorizontalAlignment.Right; + display.MarqueeProgress = progress; + var contentWidth = display.DesiredSize.Width; + + var transform = FindLayoutTransform(RenderToDrawingGroup(display)); + + transform.ShouldNotBeNull(); + transform.Value.M11.ShouldBe(1); + transform.Value.M22.ShouldBe(1); + transform.Value.M31.ShouldBe(600 - (600 + contentWidth) * progress, 0.0001); + } + + [Fact] + public void Render_ShouldKeepLongMarqueeWorkBoundedByViewport() + { + var hundred = CreateDisplay(new string('A', 100)); + hundred.IsMarqueeEnabled = true; + hundred.MarqueeProgress = 0.5; + hundred.Measure(new Size(120, 60)); + hundred.Arrange(new Rect(0, 0, 120, 60)); + + var tenThousand = CreateDisplay(new string('A', 10_000)); + tenThousand.IsMarqueeEnabled = true; + tenThousand.MarqueeProgress = 0.5; + tenThousand.Measure(new Size(120, 60)); + tenThousand.Arrange(new Rect(0, 0, 120, 60)); + + var hundredCommands = RenderToGlyphLayerDrawings(hundred).Count(); + var tenThousandCommands = RenderToGlyphLayerDrawings(tenThousand).Count(); + + hundredCommands.ShouldBeGreaterThan(0); + tenThousandCommands.ShouldBeInRange(hundredCommands - 2, hundredCommands + 2); + } + + [Fact] + public void MarqueeProgress_ShouldReuseLayoutAndGlyphGeometry() + { + var display = CreateDisplay("MATRIX 2026"); + display.IsMarqueeEnabled = true; + display.MarqueeProgress = 0.5; + RenderToDrawingGroup(display); + var layoutVersion = display.LayoutCacheVersion; + var geometryBuildCount = display.GeometryBuildCount; + + for (var frame = 0; frame < 600; frame++) + { + display.MarqueeProgress = frame / 599d; + RenderToDrawingGroup(display); + } + + display.LayoutCacheVersion.ShouldBe(layoutVersion); + display.GeometryBuildCount.ShouldBe(geometryBuildCount); + } + + [Fact] + public void Render_ShouldCullGlyphsOutsideClipViewport() + { + var display = CreateDisplay(new string('A', 100)); + display.Measure(new Size(46, 60)); + display.Arrange(new Rect(0, 0, 46, 60)); + + var dots = RenderToGlyphLayerDrawings(display).ToList(); + + dots.Count.ShouldBe(2); + } + + [Fact] + public void Render_ShouldKeepAllGlyphsWhenScaleDownFitsThemIntoViewport() + { + var display = CreateDisplay(new string('A', 10)); + display.OverflowMode = MatrixOverflowMode.ScaleDown; + display.Measure(new Size(120, 60)); + display.Arrange(new Rect(0, 0, 120, 60)); + + var dots = RenderToGlyphLayerDrawings(display).ToList(); + + dots.Count.ShouldBe(20); + } + + [Theory] + [InlineData(0, 60)] + [InlineData(60, 0)] + public void Render_ShouldSkipDotsForZeroAreaBounds(double width, double height) + { + var display = CreateDisplay("A"); + display.Measure(new Size(width, height)); + display.Arrange(new Rect(0, 0, width, height)); + + RenderToGlyphLayerDrawings(display).ShouldBeEmpty(); + } + + [Fact] + public void Render_ShouldCoerceInvalidNumericInputs() + { + var display = CreateDisplay("A"); + display.DotSize = double.NaN; + display.DotSpacing = double.PositiveInfinity; + display.CharacterSpacing = double.NegativeInfinity; + display.Padding = new Thickness(double.NaN, -1, double.PositiveInfinity, 2); + display.Measure(new Size(40, 20)); + display.Arrange(new Rect(0, 0, 40, 20)); + + var dots = RenderToGlyphLayerDrawings(display).ToList(); + + dots.Count.ShouldBe(2); + dots.ShouldAllBe(drawing => + MatrixValueSanitizer.IsFinite(drawing.Geometry!.Bounds.X) + && MatrixValueSanitizer.IsFinite(drawing.Geometry.Bounds.Y) + && MatrixValueSanitizer.IsFinite(drawing.Geometry.Bounds.Width) + && MatrixValueSanitizer.IsFinite(drawing.Geometry.Bounds.Height)); + } + + [Fact] + public void Render_ShouldKeepGeometryFiniteForExtremelyLargeFiniteValues() + { + var display = CreateDisplay("A"); + display.DotSize = double.MaxValue; + display.DotSpacing = double.MaxValue; + display.CharacterSpacing = double.MaxValue; + display.Padding = default; + display.OverflowMode = MatrixOverflowMode.ScaleDown; + display.Measure(new Size(40, 20)); + display.Arrange(new Rect(0, 0, 40, 20)); + + var dots = RenderToGlyphLayerDrawings(display).ToList(); + + dots.Count.ShouldBe(2); + dots.ShouldAllBe(drawing => + MatrixValueSanitizer.IsFinite(drawing.Geometry!.Bounds.X) + && MatrixValueSanitizer.IsFinite(drawing.Geometry.Bounds.Y) + && MatrixValueSanitizer.IsFinite(drawing.Geometry.Bounds.Width) + && MatrixValueSanitizer.IsFinite(drawing.Geometry.Bounds.Height)); + } + + [Theory] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + public void Render_ShouldNotThrowForInvalidDotCornerRadiusRatio(double ratio) + { + var display = CreateDisplay("A"); + display.DotShape = MatrixDotShape.RoundedSquare; + display.DotCornerRadiusRatio = ratio; + + var dots = RenderToGlyphLayerDrawings(display).ToList(); + + dots.Count.ShouldBe(2); + } + + [Fact] + public void Render_ShouldFallbackUnsupportedDotShapeToCircle() + { + var display = CreateDisplay("A"); + display.DotShape = (MatrixDotShape)999; + + var dots = RenderToGlyphLayerDrawings(display).ToList(); + + dots.Count.ShouldBe(2); + } + + [Theory] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + public void Render_ShouldNotThrowForInvalidCornerRadius(double radius) + { + var display = CreateDisplay("A"); + display.CornerRadius = new CornerRadius(radius); + + var drawing = RenderToDrawingGroup(display); + + drawing.Children.ShouldNotBeNull(); + } + + private static MatrixDisplay CreateDisplay(string text) + { + var display = new MatrixDisplay + { + Text = text, + DotSize = 6, + DotSpacing = 2, + CharacterSpacing = 8, + Padding = new Thickness(4), + Background = Brushes.Black, + ActiveBrush = Brushes.Red, + InactiveBrush = Brushes.DarkGray + }; + display.Measure(new Size(600, 160)); + display.Arrange(new Rect(0, 0, 600, 160)); + return display; + } + + private static DrawingGroup RenderToDrawingGroup(MatrixDisplay display) + { + var drawingGroup = new DrawingGroup(); + using (var context = drawingGroup.Open()) + { + display.Render(context); + } + + return drawingGroup; + } + + private static IEnumerable RenderToGlyphLayerDrawings(MatrixDisplay display) + { + return EnumerateGeometryDrawings(RenderToDrawingGroup(display)) + .Where(drawing => HasBrush(drawing, Colors.Red) || HasBrush(drawing, Colors.DarkGray)); + } + + private static IEnumerable EnumerateGeometryDrawings(Drawing drawing) + { + if (drawing is GeometryDrawing geometryDrawing) + { + yield return geometryDrawing; + } + else if (drawing is DrawingGroup drawingGroup) + { + foreach (var child in drawingGroup.Children.SelectMany(EnumerateGeometryDrawings)) + { + yield return child; + } + } + } + + private static IEnumerable EnumerateDrawingGroups(Drawing drawing) + { + if (drawing is DrawingGroup drawingGroup) + { + yield return drawingGroup; + foreach (var child in drawingGroup.Children.SelectMany(EnumerateDrawingGroups)) + { + yield return child; + } + } + } + + private static MatrixTransform? FindLayoutTransform(Drawing drawing) + { + return EnumerateDrawingGroups(drawing) + .Select(group => group.Transform) + .OfType() + .FirstOrDefault(); + } + + private static int CountBrush(IEnumerable drawings, Color color) + { + return drawings.Count(drawing => HasBrush(drawing, color)); + } + + private static bool HasBrush(GeometryDrawing drawing, Color color) + { + return drawing.Brush is ISolidColorBrush brush && brush.Color == color; + } + + private static bool HasPenBrush(GeometryDrawing drawing, Color color) + { + return drawing.Pen?.Brush is ISolidColorBrush brush && brush.Color == color; + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayThemeTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayThemeTests.cs new file mode 100644 index 0000000..74ac37c --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayThemeTests.cs @@ -0,0 +1,184 @@ +using AtomUI.Labs.Led.Matrix; +using AtomUI.Theme; +using AtomUI.Theme.Styling; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.Threading; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Matrix; + +public class MatrixDisplayThemeTests +{ + static MatrixDisplayThemeTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + [Fact] + public void AxamlHost_ShouldCreateMatrixThroughLabsXmlNamespace() + { + var host = new MatrixAxamlHost(); + + host.Display.Text.ShouldBe("axaml 2026"); + host.Display.DotSize.ShouldBe(7); + host.Display.DotShape.ShouldBe(MatrixDotShape.RoundedSquare); + host.Display.DotCornerRadiusRatio.ShouldBe(0.3); + GetBrushColor(host.Display.BorderBrush).ShouldBe(Colors.Blue); + host.Display.BorderThickness.ShouldBe(new Thickness(1, 2, 3, 4)); + host.Display.IsMarqueeEnabled.ShouldBeTrue(); + host.Display.MarqueeSpeed.ShouldBe(64); + host.Display.MarqueeRepeatDelay.ShouldBe(TimeSpan.FromSeconds(1)); + host.Display.HorizontalContentAlignment.ShouldBe(HorizontalAlignment.Center); + host.Display.ShowInactiveDots.ShouldBeFalse(); + } + + [Fact] + public void ControlTheme_ShouldResolveSharedTokenDefaults() + { + var host = new MatrixAxamlHost(); + ShowInWindow(host, () => + { + BrushShouldHaveSameColor(host.Display.Background, GetThemeResource(SharedTokenKind.ColorBgContainer)); + BrushShouldHaveSameColor(host.Display.ActiveBrush, GetThemeResource(SharedTokenKind.ColorPrimary)); + BrushShouldHaveSameColor(host.Display.InactiveBrush, GetThemeResource(SharedTokenKind.ColorFillTertiary)); + host.Display.CornerRadius.ShouldBe(GetThemeResource(SharedTokenKind.BorderRadiusLG)); + host.Display.Padding.ShouldBe(GetThemeResource(SharedTokenKind.PaddingLG)); + }); + } + + [Fact] + public void ThemeChange_ShouldRefreshTokenDefaultsAndPreserveLocalValue() + { + var application = Application.Current; + application.ShouldNotBeNull(); + var previousVariant = application!.RequestedThemeVariant; + var host = new MatrixAxamlHost(); + + try + { + ShowInWindow(host, () => + { + var initialBackground = GetBrushColor(host.Display.Background); + host.Display.ActiveBrush = Brushes.Magenta; + + application.RequestedThemeVariant = new ThemeVariant($"{IThemeManager.DEFAULT_THEME_ID}-Dark", null); + Dispatcher.UIThread.RunJobs(); + + GetBrushColor(host.Display.Background).ShouldNotBe(initialBackground); + BrushShouldHaveSameColor(host.Display.Background, GetThemeResource(SharedTokenKind.ColorBgContainer)); + host.Display.ActiveBrush.ShouldBeSameAs(Brushes.Magenta); + }); + } + finally + { + application.RequestedThemeVariant = previousVariant; + Dispatcher.UIThread.RunJobs(); + } + } + + [Fact] + public void CompactThemeChange_ShouldRefreshSharedTokenPadding() + { + var application = Application.Current; + application.ShouldNotBeNull(); + var previousVariant = application!.RequestedThemeVariant; + var host = new MatrixAxamlHost(); + + try + { + ShowInWindow(host, () => + { + var initialPadding = host.Display.Padding; + + application.RequestedThemeVariant = new ThemeVariant($"{IThemeManager.DEFAULT_THEME_ID}-Compact", null); + Dispatcher.UIThread.RunJobs(); + + host.Display.Padding.ShouldBe(GetThemeResource(SharedTokenKind.PaddingLG)); + host.Display.Padding.ShouldNotBe(initialPadding); + }); + } + finally + { + application.RequestedThemeVariant = previousVariant; + Dispatcher.UIThread.RunJobs(); + } + } + + [Fact] + public void ExplicitNullBrushes_ShouldOverrideThemeDefaultsAcrossThemeChange() + { + var application = Application.Current; + application.ShouldNotBeNull(); + var previousVariant = application!.RequestedThemeVariant; + var host = new MatrixAxamlHost(); + + try + { + ShowInWindow(host, () => + { + host.Display.ActiveBrush = null; + host.Display.InactiveBrush = null; + + host.Display.ActiveBrush.ShouldBeNull(); + host.Display.InactiveBrush.ShouldBeNull(); + + application.RequestedThemeVariant = new ThemeVariant($"{IThemeManager.DEFAULT_THEME_ID}-Dark", null); + Dispatcher.UIThread.RunJobs(); + + host.Display.ActiveBrush.ShouldBeNull(); + host.Display.InactiveBrush.ShouldBeNull(); + }); + } + finally + { + application.RequestedThemeVariant = previousVariant; + Dispatcher.UIThread.RunJobs(); + } + } + + private static T GetThemeResource(object key) + { + var application = Application.Current; + application.ShouldNotBeNull(); + application!.TryGetResource(key, application.ActualThemeVariant, out var value).ShouldBeTrue(); + value.ShouldBeAssignableTo(); + return (T)value!; + } + + private static void BrushShouldHaveSameColor(IBrush? actual, IBrush expected) + { + GetBrushColor(actual).ShouldBe(GetBrushColor(expected)); + } + + private static Color GetBrushColor(IBrush? brush) + { + brush.ShouldBeAssignableTo(); + return ((ISolidColorBrush)brush!).Color; + } + + private static void ShowInWindow(Control content, Action assertion) + { + var window = new Window + { + Width = 420, + Height = 160, + Content = content + }; + + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + assertion(); + } + finally + { + window.Close(); + } + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDynamicLoadTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDynamicLoadTests.cs new file mode 100644 index 0000000..c556c3b --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDynamicLoadTests.cs @@ -0,0 +1,102 @@ +using System.Globalization; +using AtomUI.Labs.Led.Matrix; +using Avalonia; +using Avalonia.Media; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Matrix; + +public class MatrixDynamicLoadTests +{ + static MatrixDynamicLoadTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + [Theory] + [InlineData(6, false)] + [InlineData(6, true)] + [InlineData(8, false)] + [InlineData(8, true)] + [InlineData(16, false)] + [InlineData(16, true)] + public void FixedLengthDynamicText_ShouldReuseGeometryForSixHundredFrames( + int characterCount, + bool showInactiveDots) + { + var display = CreateDisplay(showInactiveDots); + for (var frame = 0; frame < 20; frame++) + { + UpdateAndRender(display, CreateText(characterCount, frame)); + } + + var geometryBuildCount = display.GeometryBuildCount; + var geometryCacheCount = display.GeometryCacheCount; + var layoutVersion = display.LayoutCacheVersion; + + DrawingGroup? finalDrawing = null; + for (var frame = 20; frame < 620; frame++) + { + finalDrawing = UpdateAndRender(display, CreateText(characterCount, frame)); + } + + display.GeometryBuildCount.ShouldBe(geometryBuildCount); + display.GeometryCacheCount.ShouldBe(geometryCacheCount); + display.LayoutCacheVersion.ShouldBe(layoutVersion + 600); + CountGeometryDrawings(finalDrawing!).ShouldBe(characterCount * (showInactiveDots ? 2 : 1)); + } + + private static MatrixDisplay CreateDisplay(bool showInactiveDots) + { + return new MatrixDisplay + { + DotSize = 6, + DotSpacing = 2, + CharacterSpacing = 8, + Padding = default, + ActiveBrush = Brushes.White, + InactiveBrush = showInactiveDots ? Brushes.Gray : null, + ShowInactiveDots = showInactiveDots + }; + } + + private static DrawingGroup UpdateAndRender(MatrixDisplay display, string text) + { + var viewport = new Size(800, 80); + display.Text = text; + display.Measure(viewport); + display.Arrange(new Rect(viewport)); + + var drawing = new DrawingGroup(); + using var context = drawing.Open(); + display.Render(context); + return drawing; + } + + private static string CreateText(int characterCount, int value) + { + return characterCount switch + { + 6 => (value % 1_000_000).ToString("D6", CultureInfo.InvariantCulture), + 8 => (value % 100_000_000).ToString("D8", CultureInfo.InvariantCulture), + 16 => "TEMP" + + (value % 10_000).ToString("D4", CultureInfo.InvariantCulture) + + "RPM" + + (value % 100_000).ToString("D5", CultureInfo.InvariantCulture), + _ => throw new ArgumentOutOfRangeException(nameof(characterCount)) + }; + } + + private static int CountGeometryDrawings(Drawing drawing) + { + if (drawing is GeometryDrawing) + { + return 1; + } + + return drawing is DrawingGroup group + ? group.Children.Sum(CountGeometryDrawings) + : 0; + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixGlyphGeometryCacheTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixGlyphGeometryCacheTests.cs new file mode 100644 index 0000000..93de356 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixGlyphGeometryCacheTests.cs @@ -0,0 +1,209 @@ +using AtomUI.Labs.Led.Matrix; +using AtomUI.Labs.Led.Matrix.Character; +using Avalonia; +using Avalonia.Media; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Matrix; + +public class MatrixGlyphGeometryCacheTests +{ + static MatrixGlyphGeometryCacheTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + [Fact] + public void Render_ShouldBuildOneGeometryPerDistinctGlyphShape() + { + var display = CreateDisplay("ABBA"); + + Render(display); + + display.GeometryBuildCount.ShouldBe(2); + display.GeometryCacheCount.ShouldBe(2); + } + + [Fact] + public void TextAndVisualChanges_ShouldReuseGeometryCache() + { + var display = CreateDisplay("AB"); + Render(display); + var buildCount = display.GeometryBuildCount; + + display.Text = "BAAB"; + display.ActiveBrush = Brushes.Blue; + display.InactiveBrush = Brushes.Gray; + display.CharacterSpacing = 12; + display.Padding = new Thickness(4); + Render(display); + + display.GeometryBuildCount.ShouldBe(buildCount); + display.GeometryCacheCount.ShouldBe(2); + } + + [Fact] + public void DotMetrics_ShouldClearAndRebuildGeometryCache() + { + var display = CreateDisplay("AB"); + Render(display); + var buildCount = display.GeometryBuildCount; + + display.DotSize = 8; + display.GeometryCacheCount.ShouldBe(0); + Render(display); + + display.GeometryBuildCount.ShouldBe(buildCount + 2); + display.GeometryCacheCount.ShouldBe(2); + + display.DotSpacing = 3; + display.GeometryCacheCount.ShouldBe(0); + Render(display); + + display.GeometryBuildCount.ShouldBe(buildCount + 4); + display.GeometryCacheCount.ShouldBe(2); + } + + [Fact] + public void DotShape_ShouldClearAndRebuildGeometryCache() + { + var display = CreateDisplay("AB"); + Render(display); + var buildCount = display.GeometryBuildCount; + + display.DotShape = MatrixDotShape.Square; + display.GeometryCacheCount.ShouldBe(0); + Render(display); + + display.GeometryBuildCount.ShouldBe(buildCount + 2); + display.GeometryCacheCount.ShouldBe(2); + + display.DotShape = MatrixDotShape.RoundedSquare; + display.GeometryCacheCount.ShouldBe(0); + Render(display); + + display.GeometryBuildCount.ShouldBe(buildCount + 4); + display.GeometryCacheCount.ShouldBe(2); + } + + [Fact] + public void CornerRadiusRatio_ShouldOnlyRebuildRoundedSquareGeometry() + { + var display = CreateDisplay("A"); + Render(display); + var buildCount = display.GeometryBuildCount; + + display.DotCornerRadiusRatio = 0.4; + display.GeometryCacheCount.ShouldBe(1); + Render(display); + display.GeometryBuildCount.ShouldBe(buildCount); + + display.DotShape = MatrixDotShape.Square; + Render(display); + buildCount = display.GeometryBuildCount; + display.DotCornerRadiusRatio = 0.1; + display.GeometryCacheCount.ShouldBe(1); + Render(display); + display.GeometryBuildCount.ShouldBe(buildCount); + + display.DotShape = MatrixDotShape.RoundedSquare; + Render(display); + buildCount = display.GeometryBuildCount; + display.DotCornerRadiusRatio = 0.4; + display.GeometryCacheCount.ShouldBe(0); + Render(display); + display.GeometryBuildCount.ShouldBe(buildCount + 1); + } + + [Fact] + public void AllSupportedTextAndHistory_ShouldKeepCacheBoundedByDistinctGlyphShapes() + { + var allSupportedText = MatrixFiveBySevenGlyphMap.SupportedCharacters; + var expectedShapeCount = allSupportedText + .Select(character => MatrixCharacterMap.GetPattern(character).Glyph.Bits) + .Distinct() + .Count(); + var display = CreateDisplay(allSupportedText); + display.Measure(new Size(10_000, 120)); + display.Arrange(new Rect(0, 0, 10_000, 120)); + Render(display); + + display.GeometryCacheCount.ShouldBe(expectedShapeCount); + display.GeometryCacheCount.ShouldBeLessThanOrEqualTo(45); + + var random = new Random(0x45); + for (var iteration = 0; iteration < 200; iteration++) + { + display.Text = new string( + Enumerable.Range(0, 32) + .Select(_ => allSupportedText[random.Next(allSupportedText.Length)]) + .ToArray()); + Render(display); + display.GeometryCacheCount.ShouldBeLessThanOrEqualTo(expectedShapeCount); + } + + display.GeometryCacheCount.ShouldBe(expectedShapeCount); + } + + [Fact] + public void RepeatedDotMetricChanges_ShouldRetainOnlyCurrentGeometrySet() + { + var display = CreateDisplay("A"); + + for (var iteration = 0; iteration < 200; iteration++) + { + display.DotSize = 4 + iteration % 7; + display.DotSpacing = iteration % 4; + Render(display); + + display.GeometryCacheCount.ShouldBe(1); + } + + display.GeometryBuildCount.ShouldBe(200); + } + + [Fact] + public void RepeatedShapeChanges_ShouldRetainOnlyCurrentGeometrySet() + { + var display = CreateDisplay("A"); + var shapes = new[] + { + MatrixDotShape.Circle, + MatrixDotShape.Square, + MatrixDotShape.RoundedSquare + }; + + for (var iteration = 0; iteration < 120; iteration++) + { + display.DotShape = shapes[iteration % shapes.Length]; + display.DotCornerRadiusRatio = iteration % 6 / 10.0; + Render(display); + + display.GeometryCacheCount.ShouldBe(1); + } + } + + private static MatrixDisplay CreateDisplay(string text) + { + var display = new MatrixDisplay + { + Text = text, + DotSize = 6, + DotSpacing = 2, + CharacterSpacing = 8, + 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) + { + var drawing = new DrawingGroup(); + using var context = drawing.Open(); + display.Render(context); + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixGlyphGeometryFactoryTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixGlyphGeometryFactoryTests.cs new file mode 100644 index 0000000..6727a9f --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixGlyphGeometryFactoryTests.cs @@ -0,0 +1,94 @@ +using System.Numerics; +using AtomUI.Labs.Led.Matrix; +using AtomUI.Labs.Led.Matrix.Character; +using AtomUI.Labs.Led.Matrix.Rendering; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Matrix; + +public class MatrixGlyphGeometryFactoryTests +{ + static MatrixGlyphGeometryFactoryTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + [Theory] + [InlineData(MatrixDotShape.Circle)] + [InlineData(MatrixDotShape.Square)] + [InlineData(MatrixDotShape.RoundedSquare)] + public void Create_ShouldPartitionEveryGlyphIntoExactlyThirtyFiveDots(MatrixDotShape dotShape) + { + foreach (var character in MatrixFiveBySevenGlyphMap.SupportedCharacters) + { + MatrixFiveBySevenGlyphMap.TryGetGlyph(character, out var glyph).ShouldBeTrue(); + + var geometry = MatrixGlyphGeometryFactory.Create(glyph, 6, 2, dotShape, 0.25); + var expectedActiveCount = BitOperations.PopCount(glyph.Bits); + + geometry.ActiveDotCount.ShouldBe(expectedActiveCount); + geometry.InactiveDotCount.ShouldBe(MatrixGlyph.Width * MatrixGlyph.Height - expectedActiveCount); + (geometry.ActiveDotCount + geometry.InactiveDotCount).ShouldBe(35); + } + } + + [Fact] + public void Create_ShouldKeepGeometryBoundsFiniteForSanitizedValues() + { + MatrixFiveBySevenGlyphMap.TryGetGlyph('8', out var glyph).ShouldBeTrue(); + + var geometry = MatrixGlyphGeometryFactory.Create( + glyph, + double.MaxValue, + double.PositiveInfinity, + MatrixDotShape.RoundedSquare, + double.NaN); + + MatrixValueSanitizer.IsFinite(geometry.ActiveGeometry.Bounds.X).ShouldBeTrue(); + MatrixValueSanitizer.IsFinite(geometry.ActiveGeometry.Bounds.Y).ShouldBeTrue(); + MatrixValueSanitizer.IsFinite(geometry.ActiveGeometry.Bounds.Width).ShouldBeTrue(); + MatrixValueSanitizer.IsFinite(geometry.ActiveGeometry.Bounds.Height).ShouldBeTrue(); + MatrixValueSanitizer.IsFinite(geometry.InactiveGeometry.Bounds.X).ShouldBeTrue(); + MatrixValueSanitizer.IsFinite(geometry.InactiveGeometry.Bounds.Y).ShouldBeTrue(); + MatrixValueSanitizer.IsFinite(geometry.InactiveGeometry.Bounds.Width).ShouldBeTrue(); + MatrixValueSanitizer.IsFinite(geometry.InactiveGeometry.Bounds.Height).ShouldBeTrue(); + } + + [Theory] + [InlineData(MatrixDotShape.Circle)] + [InlineData(MatrixDotShape.Square)] + [InlineData(MatrixDotShape.RoundedSquare)] + public void Create_ShouldKeepTheSameOuterBoundsForEveryShape(MatrixDotShape dotShape) + { + var allDots = new MatrixGlyph((1UL << (MatrixGlyph.Width * MatrixGlyph.Height)) - 1); + + var geometry = MatrixGlyphGeometryFactory.Create(allDots, 6, 2, dotShape, 0.25); + + geometry.ActiveGeometry.Bounds.ShouldBe(new Avalonia.Rect(0, 0, 38, 54)); + } + + [Theory] + [InlineData(-1, 0)] + [InlineData(0, 0)] + [InlineData(0.25, 0.25)] + [InlineData(0.5, 0.5)] + [InlineData(1, 0.5)] + [InlineData(double.NaN, 0)] + [InlineData(double.PositiveInfinity, 0)] + public void RoundedCornerRatio_ShouldClampToSupportedRange(double value, double expected) + { + MatrixDotShapeResolver + .GetEffectiveCornerRadiusRatio(MatrixDotShape.RoundedSquare, value) + .ShouldBe(expected); + } + + [Fact] + public void UnsupportedShape_ShouldFallbackToCircleAndIgnoreCornerRatio() + { + var unsupported = (MatrixDotShape)999; + + MatrixDotShapeResolver.CoerceShape(unsupported).ShouldBe(MatrixDotShape.Circle); + MatrixDotShapeResolver.GetEffectiveCornerRadiusRatio(unsupported, 0.3).ShouldBe(0); + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixGlyphGeometryLifecycleTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixGlyphGeometryLifecycleTests.cs new file mode 100644 index 0000000..dd90d2b --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixGlyphGeometryLifecycleTests.cs @@ -0,0 +1,170 @@ +using System.Runtime.CompilerServices; +using AtomUI.Labs.Led.Matrix; +using Avalonia; +using Avalonia.Media; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Matrix; + +public class MatrixGlyphGeometryLifecycleTests +{ + static MatrixGlyphGeometryLifecycleTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + [Fact] + public void DotMetricChange_ShouldReleaseOldGeometrySet() + { + var display = CreateDisplay(); + var geometryReferences = CaptureAndClearGeometryCache(display); + + ForceGarbageCollection(); + + geometryReferences.ShouldAllBe(reference => !reference.IsAlive); + display.GeometryCacheCount.ShouldBe(0); + GC.KeepAlive(display); + } + + [Fact] + public void DotShapeChange_ShouldReleaseOldGeometrySet() + { + var display = CreateDisplay(); + var geometryReferences = CaptureAndChangeDotShape(display); + + ForceGarbageCollection(); + + geometryReferences.ShouldAllBe(reference => !reference.IsAlive); + display.GeometryCacheCount.ShouldBe(0); + GC.KeepAlive(display); + } + + [Fact] + public void RoundedCornerRatioChange_ShouldReleaseOldGeometrySet() + { + var display = CreateDisplay(); + var geometryReferences = CaptureAndChangeRoundedCornerRatio(display); + + ForceGarbageCollection(); + + geometryReferences.ShouldAllBe(reference => !reference.IsAlive); + display.GeometryCacheCount.ShouldBe(0); + GC.KeepAlive(display); + } + + [Fact] + public void UnreferencedDisplayAndGeometryCache_ShouldBeCollectible() + { + var displayReference = CreatePopulatedDisplayReference(); + + ForceGarbageCollection(); + + displayReference.IsAlive.ShouldBeFalse(); + } + + [Fact] + public void BorderMetricChange_ShouldReleaseOldComplexGeometry() + { + var display = CreateDisplay(); + var geometryReference = CaptureAndChangeBorderThickness(display); + + ForceGarbageCollection(); + + geometryReference.IsAlive.ShouldBeFalse(); + display.BorderGeometryCache.ShouldBeNull(); + GC.KeepAlive(display); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference[] CaptureAndClearGeometryCache(MatrixDisplay display) + { + Render(display); + var references = display.GeometryCacheValues + .Select(geometry => new WeakReference(geometry)) + .ToArray(); + + display.DotSize = 9; + return references; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CreatePopulatedDisplayReference() + { + var display = CreateDisplay(); + Render(display); + display.GeometryCacheCount.ShouldBeGreaterThan(0); + return new WeakReference(display); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CaptureAndChangeBorderThickness(MatrixDisplay display) + { + display.BorderBrush = Brushes.Blue; + display.BorderThickness = new Thickness(1, 2, 3, 4); + display.CornerRadius = new CornerRadius(8); + Render(display); + var reference = new WeakReference(display.BorderGeometryCache!); + + display.BorderThickness = new Thickness(2, 3, 4, 5); + return reference; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference[] CaptureAndChangeDotShape(MatrixDisplay display) + { + Render(display); + var references = display.GeometryCacheValues + .Select(geometry => new WeakReference(geometry)) + .ToArray(); + + display.DotShape = MatrixDotShape.Square; + return references; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference[] CaptureAndChangeRoundedCornerRatio(MatrixDisplay display) + { + display.DotShape = MatrixDotShape.RoundedSquare; + Render(display); + var references = display.GeometryCacheValues + .Select(geometry => new WeakReference(geometry)) + .ToArray(); + + display.DotCornerRadiusRatio = 0.4; + return references; + } + + private static MatrixDisplay CreateDisplay() + { + var display = new MatrixDisplay + { + Text = "MATRIX 2026", + DotSize = 6, + DotSpacing = 2, + CharacterSpacing = 8, + ActiveBrush = Brushes.Red, + InactiveBrush = Brushes.DarkGray + }; + display.Measure(new Size(800, 120)); + display.Arrange(new Rect(0, 0, 800, 120)); + return display; + } + + private static void Render(MatrixDisplay display) + { + var drawing = new DrawingGroup(); + using var context = drawing.Open(); + display.Render(context); + } + + private static void ForceGarbageCollection() + { + for (var attempt = 0; attempt < 3; attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixGlyphMapTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixGlyphMapTests.cs new file mode 100644 index 0000000..d8061ef --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixGlyphMapTests.cs @@ -0,0 +1,88 @@ +using AtomUI.Labs.Led.Matrix.Character; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Matrix; + +public class MatrixGlyphMapTests +{ + [Fact] + public void SupportedCharacters_ShouldContainExactlyFortyFiveDistinctCharacters() + { + MatrixFiveBySevenGlyphMap.SupportedCharacters.Length.ShouldBe(45); + MatrixFiveBySevenGlyphMap.SupportedCharacters.Distinct().Count().ShouldBe(45); + } + + [Fact] + public void TryGetGlyph_ShouldReturnEverySupportedGlyph() + { + foreach (var character in MatrixFiveBySevenGlyphMap.SupportedCharacters) + { + MatrixFiveBySevenGlyphMap.TryGetGlyph(character, out var glyph).ShouldBeTrue(); + if (character == ' ') + { + glyph.Bits.ShouldBe(0UL); + } + else + { + glyph.Bits.ShouldNotBe(0UL); + } + } + } + + [Fact] + public void SupportedGlyphs_ShouldNotSetBitsOutsideFiveBySevenBounds() + { + foreach (var character in MatrixFiveBySevenGlyphMap.SupportedCharacters) + { + MatrixFiveBySevenGlyphMap.TryGetGlyph(character, out var glyph).ShouldBeTrue(); + (glyph.Bits >> (MatrixGlyph.Width * MatrixGlyph.Height)).ShouldBe(0UL); + } + } + + [Theory] + [InlineData('a')] + [InlineData('中')] + [InlineData('@')] + public void TryGetGlyph_ShouldRejectUnsupportedCharacters(char character) + { + MatrixFiveBySevenGlyphMap.TryGetGlyph(character, out var glyph).ShouldBeFalse(); + glyph.ShouldBe(default); + } + + [Fact] + public void GlyphA_ShouldUseDocumentedRowMajorOrientation() + { + MatrixFiveBySevenGlyphMap.TryGetGlyph('A', out var glyph).ShouldBeTrue(); + var expectedRows = new[] + { + "01110", + "10001", + "10001", + "11111", + "10001", + "10001", + "10001" + }; + + for (var row = 0; row < MatrixGlyph.Height; row++) + { + for (var column = 0; column < MatrixGlyph.Width; column++) + { + glyph.IsActive(row, column).ShouldBe(expectedRows[row][column] == '1'); + } + } + } + + [Theory] + [InlineData(-1, 0)] + [InlineData(7, 0)] + [InlineData(0, -1)] + [InlineData(0, 5)] + public void IsActive_ShouldReturnFalseOutsideGlyphBounds(int row, int column) + { + MatrixFiveBySevenGlyphMap.TryGetGlyph('8', out var glyph).ShouldBeTrue(); + + glyph.IsActive(row, column).ShouldBeFalse(); + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixLayoutEngineTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixLayoutEngineTests.cs new file mode 100644 index 0000000..8b14e1f --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixLayoutEngineTests.cs @@ -0,0 +1,111 @@ +using AtomUI.Labs.Led.Matrix.Layout; +using Avalonia; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Matrix; + +public class MatrixLayoutEngineTests +{ + [Fact] + public void Calculate_ShouldReturnPaddingOnlySizeForEmptyText() + { + var layout = MatrixLayoutEngine.Calculate(null, CreateOptions(padding: new Thickness(1, 2, 3, 4))); + + layout.DesiredSize.ShouldBe(new Size(4, 6)); + layout.GlyphSize.ShouldBe(new Size(38, 54)); + layout.Slots.ShouldBeEmpty(); + } + + [Fact] + public void Calculate_ShouldUseFiveBySevenDotFormula() + { + var layout = MatrixLayoutEngine.Calculate("A", CreateOptions()); + + layout.DesiredSize.ShouldBe(new Size(38, 54)); + layout.GlyphSize.ShouldBe(new Size(38, 54)); + layout.Slots.Count.ShouldBe(1); + layout.Slots[0].Origin.ShouldBe(default); + } + + [Fact] + public void Calculate_ShouldApplyCharacterSpacingAndPadding() + { + var layout = MatrixLayoutEngine.Calculate( + "Ab", + CreateOptions( + characterSpacing: 8, + padding: new Thickness(2, 3, 4, 5))); + + layout.DesiredSize.ShouldBe(new Size(90, 62)); + layout.Slots[0].Origin.ShouldBe(new Point(2, 3)); + layout.Slots[1].Origin.ShouldBe(new Point(48, 3)); + layout.Slots[0].Pattern.Character.ShouldBe('A'); + layout.Slots[1].Pattern.Character.ShouldBe('B'); + } + + [Fact] + public void Calculate_ShouldPreserveFullCellForSpaceAndFallback() + { + var layout = MatrixLayoutEngine.Calculate("A 中", CreateOptions()); + + layout.Slots.Count.ShouldBe(3); + layout.Slots[1].Pattern.Character.ShouldBe(' '); + layout.Slots[2].Pattern.Character.ShouldBe('?'); + layout.Slots[1].Origin.X.ShouldBe(46); + layout.Slots[2].Origin.X.ShouldBe(92); + layout.DesiredSize.Width.ShouldBe(130); + } + + [Fact] + public void Calculate_ShouldCreateOneSlotPerUnicodeScalar() + { + const string text = "A😀Z"; + var layout = MatrixLayoutEngine.Calculate(text, CreateOptions()); + + layout.Slots.Count.ShouldBe(3); + layout.Slots.Select(slot => slot.Pattern.Character).ShouldBe(new[] { 'A', '?', 'Z' }); + layout.DesiredSize.ShouldBe(new Size(130, 54)); + } + + [Fact] + public void Calculate_ShouldCreateOneFallbackSlotForUnpairedSurrogate() + { + var text = new string(new[] { 'A', '\uD800', 'Z' }); + + var layout = MatrixLayoutEngine.Calculate(text, CreateOptions()); + + layout.Slots.Count.ShouldBe(3); + layout.Slots.Select(slot => slot.Pattern.Character).ShouldBe(new[] { 'A', '?', 'Z' }); + layout.DesiredSize.ShouldBe(new Size(130, 54)); + } + + [Fact] + public void Calculate_ShouldCoerceInvalidValues() + { + var layout = MatrixLayoutEngine.Calculate( + "AA", + CreateOptions( + dotSize: double.NaN, + dotSpacing: double.PositiveInfinity, + characterSpacing: -2, + padding: new Thickness(double.NaN, -1, double.PositiveInfinity, 2))); + + layout.DesiredSize.ShouldBe(new Size(10, 9)); + layout.Slots[0].Origin.ShouldBe(default); + layout.Slots[1].Origin.ShouldBe(new Point(5, 0)); + } + + private static MatrixLayoutOptions CreateOptions( + double dotSize = 6, + double dotSpacing = 2, + double characterSpacing = 8, + Thickness? padding = null) + { + return new MatrixLayoutOptions( + dotSize, + dotSpacing, + characterSpacing, + padding ?? default); + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeLifecycleTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeLifecycleTests.cs new file mode 100644 index 0000000..c7d6295 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeLifecycleTests.cs @@ -0,0 +1,124 @@ +using AtomUI.Labs.Led.Matrix; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Threading; +using Shouldly; +using System.Runtime.CompilerServices; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Matrix; + +public class MatrixMarqueeLifecycleTests +{ + static MatrixMarqueeLifecycleTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + [Fact] + public void AttachEnableDisableAndDetach_ShouldOwnAnimationLifetime() + { + var display = CreateDisplay(); + var window = new Window { Width = 320, Height = 100, Content = display }; + + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + display.IsMarqueeAnimationRunning.ShouldBeTrue(); + + display.IsMarqueeEnabled = false; + Dispatcher.UIThread.RunJobs(); + display.IsMarqueeAnimationRunning.ShouldBeFalse(); + display.MarqueeProgress.ShouldBe(0); + + display.IsMarqueeEnabled = true; + Dispatcher.UIThread.RunJobs(); + display.IsMarqueeAnimationRunning.ShouldBeTrue(); + } + finally + { + window.Close(); + } + + display.IsMarqueeAnimationRunning.ShouldBeFalse(); + display.MarqueeProgress.ShouldBe(0); + } + + [Fact] + public void InvalidRuntimeConditions_ShouldNotKeepAnimation() + { + var display = CreateDisplay(); + var window = new Window { Width = 320, Height = 100, Content = display }; + + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + + display.MarqueeSpeed = 0; + display.IsMarqueeAnimationRunning.ShouldBeFalse(); + + display.MarqueeSpeed = 48; + display.Text = string.Empty; + display.IsMarqueeAnimationRunning.ShouldBeFalse(); + + display.Text = "MATRIX"; + display.IsVisible = false; + display.IsMarqueeAnimationRunning.ShouldBeFalse(); + } + finally + { + window.Close(); + } + } + + [Fact] + public void RestartAndDisable_ShouldReleasePreviousController() + { + var display = CreateDisplay(); + var window = new Window { Width = 320, Height = 100, Content = display }; + WeakReference? oldController = null; + + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + oldController = CaptureControllerAndDisable(display); + Dispatcher.UIThread.RunJobs(); + } + finally + { + window.Close(); + } + + ForceFullCollection(); + oldController.IsAlive.ShouldBeFalse(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CaptureControllerAndDisable(MatrixDisplay display) + { + var reference = new WeakReference(display.MarqueeController!); + display.IsMarqueeEnabled = false; + return reference; + } + + private static void ForceFullCollection() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + private static MatrixDisplay CreateDisplay() + { + return new MatrixDisplay + { + Text = "MARQUEE", + IsMarqueeEnabled = true, + Width = 280, + Height = 72 + }; + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeMotionTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeMotionTests.cs new file mode 100644 index 0000000..415adef --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeMotionTests.cs @@ -0,0 +1,45 @@ +using AtomUI.Labs.Led.Marquee; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Matrix; + +public class MatrixMarqueeMotionTests +{ + private readonly LeftThroughMarqueeMotion _motion = new(); + + [Theory] + [InlineData(0, 200)] + [InlineData(0.25, 125)] + [InlineData(0.5, 50)] + [InlineData(0.75, -25)] + [InlineData(1, -100)] + public void LeftThrough_ShouldMoveFromViewportRightToContentLeft( + double progress, + double expectedX) + { + var plan = _motion.Calculate(new MarqueeMotionContext(200, 100, progress)); + + plan.PlacementCount.ShouldBe(1); + plan.GetX(0).ShouldBe(expectedX); + } + + [Theory] + [InlineData(-1, 200)] + [InlineData(double.NaN, 200)] + [InlineData(double.NegativeInfinity, 200)] + [InlineData(2, -100)] + [InlineData(double.PositiveInfinity, -100)] + public void LeftThrough_ShouldClampProgress(double progress, double expectedX) + { + _motion.Calculate(new MarqueeMotionContext(200, 100, progress)).FirstX.ShouldBe(expectedX); + } + + [Fact] + public void RenderPlan_ShouldRejectUnavailablePlacement() + { + var plan = new MarqueeRenderPlan(1, 10); + + Should.Throw(() => plan.GetX(1)); + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeValueSanitizerTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeValueSanitizerTests.cs new file mode 100644 index 0000000..f58d2bd --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeValueSanitizerTests.cs @@ -0,0 +1,29 @@ +using AtomUI.Labs.Led.Marquee; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Matrix; + +public class MatrixMarqueeValueSanitizerTests +{ + [Theory] + [InlineData(double.NaN, 0)] + [InlineData(double.NegativeInfinity, 0)] + [InlineData(-1, 0)] + [InlineData(0, 0)] + [InlineData(48, 48)] + [InlineData(10_001, 10_000)] + [InlineData(double.PositiveInfinity, 10_000)] + public void Speed_ShouldUseSafeEffectiveRange(double value, double expected) + { + LedMarqueeValueSanitizer.CoerceSpeed(value).ShouldBe(expected); + } + + [Fact] + public void RepeatDelay_ShouldUseSafeEffectiveRange() + { + LedMarqueeValueSanitizer.CoerceRepeatDelay(TimeSpan.FromSeconds(-1)).ShouldBe(TimeSpan.Zero); + LedMarqueeValueSanitizer.CoerceRepeatDelay(TimeSpan.FromSeconds(5)).ShouldBe(TimeSpan.FromSeconds(5)); + LedMarqueeValueSanitizer.CoerceRepeatDelay(TimeSpan.FromHours(1)).ShouldBe(TimeSpan.FromMinutes(1)); + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixPanelBorderGeometryFactoryTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixPanelBorderGeometryFactoryTests.cs new file mode 100644 index 0000000..1ac0472 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixPanelBorderGeometryFactoryTests.cs @@ -0,0 +1,54 @@ +using AtomUI.Labs.Led.Matrix; +using AtomUI.Labs.Led.Matrix.Rendering; +using Avalonia; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Matrix; + +public class MatrixPanelBorderGeometryFactoryTests +{ + static MatrixPanelBorderGeometryFactoryTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + [Fact] + public void Create_ShouldKeepGeometryInsideBoundsForAsymmetricBorder() + { + var geometry = MatrixPanelBorderGeometryFactory.Create( + new Size(100, 60), + new Thickness(1, 2, 3, 4), + new CornerRadius(12, 8, 16, 4)); + + geometry.Bounds.Left.ShouldBeGreaterThanOrEqualTo(0); + geometry.Bounds.Top.ShouldBeGreaterThanOrEqualTo(0); + geometry.Bounds.Right.ShouldBeLessThanOrEqualTo(100); + geometry.Bounds.Bottom.ShouldBeLessThanOrEqualTo(60); + } + + [Fact] + public void Create_ShouldFillOuterBoundsWhenBorderConsumesInnerArea() + { + var geometry = MatrixPanelBorderGeometryFactory.Create( + new Size(12, 8), + new Thickness(7, 5, 7, 5), + new CornerRadius(20)); + + geometry.Bounds.ShouldBe(new Rect(0, 0, 12, 8)); + } + + [Fact] + public void Create_ShouldNormalizeOverlappingCornerRadii() + { + var geometry = MatrixPanelBorderGeometryFactory.Create( + new Size(20, 10), + new Thickness(1, 2, 3, 1), + new CornerRadius(1_000_000)); + + MatrixValueSanitizer.IsFinite(geometry.Bounds.X).ShouldBeTrue(); + MatrixValueSanitizer.IsFinite(geometry.Bounds.Y).ShouldBeTrue(); + MatrixValueSanitizer.IsFinite(geometry.Bounds.Width).ShouldBeTrue(); + MatrixValueSanitizer.IsFinite(geometry.Bounds.Height).ShouldBeTrue(); + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixUnicodeStressTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixUnicodeStressTests.cs new file mode 100644 index 0000000..908194c --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixUnicodeStressTests.cs @@ -0,0 +1,72 @@ +using System.Text; +using AtomUI.Labs.Led.Matrix; +using AtomUI.Labs.Led.Matrix.Character; +using AtomUI.Labs.Led.Matrix.Layout; +using Avalonia; +using Avalonia.Media; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Matrix; + +public class MatrixUnicodeStressTests +{ + static MatrixUnicodeStressTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + [Fact] + public void RandomUtf16Input_ShouldKeepMappingAndLayoutConsistent() + { + var random = new Random(0x5A17); + for (var iteration = 0; iteration < 500; iteration++) + { + var text = CreateRandomUtf16(random, random.Next(0, 257)); + var displayText = MatrixCharacterMap.GetDisplayText(text); + var layout = MatrixLayoutEngine.Calculate(text, new MatrixLayoutOptions(6, 2, 8, default)); + + displayText.Length.ShouldBe(MatrixCharacterMap.GetPatternCount(text)); + layout.Slots.Count.ShouldBe(displayText.Length); + layout.Slots.Select(slot => slot.Pattern.Character).ShouldBe(displayText); + MatrixValueSanitizer.IsFinite(layout.DesiredSize.Width).ShouldBeTrue(); + MatrixValueSanitizer.IsFinite(layout.DesiredSize.Height).ShouldBeTrue(); + + if (iteration % 25 == 0) + { + RenderShouldNotThrow(text); + } + } + } + + private static string CreateRandomUtf16(Random random, int length) + { + var characters = new char[length]; + for (var i = 0; i < characters.Length; i++) + { + characters[i] = (char)random.Next(char.MinValue, char.MaxValue + 1); + } + + return new string(characters); + } + + private static void RenderShouldNotThrow(string text) + { + var display = new MatrixDisplay + { + Text = text, + DotSize = 4, + DotSpacing = 1, + CharacterSpacing = 2, + ActiveBrush = Brushes.White, + InactiveBrush = null, + ShowInactiveDots = false + }; + display.Measure(new Size(320, 80)); + display.Arrange(new Rect(0, 0, 320, 80)); + + var drawing = new DrawingGroup(); + using var context = drawing.Open(); + display.Render(context); + } +} From dedd8cda6beaf1a5b86aef9857568a8e601fba06 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 15:22:11 +0800 Subject: [PATCH 12/33] add tests for Glow feature and Character feature --- .../Glow/LedGlowPixelTests.cs | 186 +++++++++++++++++ .../Glow/LedGlowRendererTests.cs | 194 ++++++++++++++++++ .../LedCharacterNormalizerTests.cs | 21 ++ .../LedDisplayLayoutMathTests.cs | 84 ++++++++ 4 files changed, 485 insertions(+) create mode 100644 tests/AtomUI.Labs.Led.Tests/Glow/LedGlowPixelTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Glow/LedGlowRendererTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/LedCharacterNormalizerTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/LedDisplayLayoutMathTests.cs diff --git a/tests/AtomUI.Labs.Led.Tests/Glow/LedGlowPixelTests.cs b/tests/AtomUI.Labs.Led.Tests/Glow/LedGlowPixelTests.cs new file mode 100644 index 0000000..8b56437 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Glow/LedGlowPixelTests.cs @@ -0,0 +1,186 @@ +using System.Runtime.InteropServices; +using AtomUI.Labs.Led.Matrix; +using AtomUI.Labs.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.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.Led.Tests/Glow/LedGlowRendererTests.cs b/tests/AtomUI.Labs.Led.Tests/Glow/LedGlowRendererTests.cs new file mode 100644 index 0000000..99b798c --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Glow/LedGlowRendererTests.cs @@ -0,0 +1,194 @@ +using AtomUI.Labs.Led.Glow; +using AtomUI.Labs.Led.Matrix; +using AtomUI.Labs.Led.Segment; +using Avalonia; +using Avalonia.Media; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.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.Led.Tests/LedCharacterNormalizerTests.cs b/tests/AtomUI.Labs.Led.Tests/LedCharacterNormalizerTests.cs new file mode 100644 index 0000000..0b50151 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/LedCharacterNormalizerTests.cs @@ -0,0 +1,21 @@ +using AtomUI.Labs.Led; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.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.Led.Tests/LedDisplayLayoutMathTests.cs b/tests/AtomUI.Labs.Led.Tests/LedDisplayLayoutMathTests.cs new file mode 100644 index 0000000..8ea2194 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/LedDisplayLayoutMathTests.cs @@ -0,0 +1,84 @@ +using AtomUI.Labs.Led; +using Avalonia; +using Avalonia.Layout; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.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); + } +} From 4a5e0c47feb5fc4b203d308fe188f99bbc1a3ff2 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 15:22:44 +0800 Subject: [PATCH 13/33] add LedSegment showcases --- .../Led/ViewModels/LedSegmentViewModel.cs | 18 +++ .../Led/Views/LedSegmentShowCase.axaml | 141 ++++++++++++++++++ .../Led/Views/LedSegmentShowCase.axaml.cs | 11 ++ 3 files changed, 170 insertions(+) create mode 100644 controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ViewModels/LedSegmentViewModel.cs create mode 100644 controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml create mode 100644 controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml.cs 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/LedSegmentShowCase.axaml b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml new file mode 100644 index 0000000..9edb484 --- /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(); + } +} From 59b036eac1316cbd09bf57686bf9263fd26c0ec4 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 15:22:52 +0800 Subject: [PATCH 14/33] add LedMatrix showcases --- .../ShowCaseControls/LedDynamicDisplays.cs | 105 +++++ .../Led/ShowCaseControls/LedGlowWorkbench.cs | 375 ++++++++++++++++++ .../Led/ViewModels/LedMatrixViewModel.cs | 18 + .../Led/Views/LedMatrixShowCase.axaml | 197 +++++++++ .../Led/Views/LedMatrixShowCase.axaml.cs | 11 + 5 files changed, 706 insertions(+) create mode 100644 controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedDynamicDisplays.cs create mode 100644 controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs create mode 100644 controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ViewModels/LedMatrixViewModel.cs create mode 100644 controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml create mode 100644 controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml.cs 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..da39f5f --- /dev/null +++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedDynamicDisplays.cs @@ -0,0 +1,105 @@ +using AtomUI.Labs.Led.Matrix; +using AtomUI.Labs.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..b51ad27 --- /dev/null +++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs @@ -0,0 +1,375 @@ +using AtomUI.Labs.Led.Matrix; +using AtomUI.Labs.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; + +namespace AtomUILabsGallery.ShowCases.Led; + +public sealed class LedGlowWorkbench : StackPanel, IDisposable +{ + 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 CheckBox _enabled; + private readonly ComboBox _brush; + private readonly ComboBox _mode; + private readonly Slider _opacity; + private readonly Slider _radius; + private readonly TextBlock _opacityValue; + private readonly TextBlock _radiusValue; + private CancellationTokenSource? _animationCancellation; + private bool _disposed; + + public LedGlowWorkbench() + { + Spacing = 12; + _matrix = CreateMatrixPreview(); + _segment = CreateSegmentPreview(); + _enabled = new CheckBox { Content = "Enabled", IsChecked = true }; + _brush = new ComboBox + { + ItemsSource = BrushOptions.Select(option => option.Name).ToArray(), + SelectedIndex = 0, + MinWidth = 130 + }; + _mode = new ComboBox + { + ItemsSource = new[] { "Static", "Breathe", "Pulse" }, + SelectedIndex = 0, + MinWidth = 130 + }; + _opacity = new Slider + { + Minimum = 0, + Maximum = 1, + Value = 0.35, + TickFrequency = 0.05, + Width = 220 + }; + _radius = new Slider + { + Minimum = 0, + Maximum = 24, + Value = 6, + TickFrequency = 1, + Width = 220 + }; + _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(new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 16, + Children = + { + CreateLabeledControl("State", _enabled), + CreateLabeledControl("Brush", _brush), + CreateLabeledControl("Mode", _mode) + } + }); + Children.Add(CreateSliderRow("Opacity", _opacity, _opacityValue)); + Children.Add(CreateSliderRow("Radius", _radius, _radiusValue)); + Children.Add(new Grid + { + ColumnDefinitions = new ColumnDefinitions("*,*"), + ColumnSpacing = 12, + Children = + { + _matrix, + PlaceInSecondColumn(_segment) + } + }); + + _enabled.IsCheckedChanged += HandleConfigurationChanged; + _brush.SelectionChanged += HandleConfigurationChanged; + _mode.SelectionChanged += HandleConfigurationChanged; + _opacity.PropertyChanged += HandleSliderPropertyChanged; + _radius.PropertyChanged += HandleSliderPropertyChanged; + DetachedFromVisualTree += HandleDetachedFromVisualTree; + ApplyConfiguration(); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _enabled.IsCheckedChanged -= HandleConfigurationChanged; + _brush.SelectionChanged -= HandleConfigurationChanged; + _mode.SelectionChanged -= HandleConfigurationChanged; + _opacity.PropertyChanged -= HandleSliderPropertyChanged; + _radius.PropertyChanged -= HandleSliderPropertyChanged; + DetachedFromVisualTree -= HandleDetachedFromVisualTree; + CancelAnimations(); + } + + private void HandleDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + Dispose(); + } + + private void HandleConfigurationChanged(object? sender, EventArgs e) + { + 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 (!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() + { + var index = Math.Clamp(_brush.SelectedIndex, 0, BrushOptions.Length - 1); + return new SolidColorBrush(BrushOptions[index].Color); + } + + private static Control CreateLabeledControl(string label, Control control) + { + return new StackPanel + { + Spacing = 4, + Children = + { + new TextBlock { Text = label, FontWeight = FontWeight.SemiBold }, + control + } + }; + } + + private static Control CreateSliderRow(string label, Slider slider, TextBlock value) + { + return new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 10, + Children = + { + new TextBlock + { + Text = label, + Width = 64, + VerticalAlignment = VerticalAlignment.Center + }, + slider, + value + } + }; + } + + 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/Views/LedMatrixShowCase.axaml b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml new file mode 100644 index 0000000..760fefb --- /dev/null +++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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(); + } +} From 4741eb970d8231839b453b15ce27c0cecee0756a Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 15:24:22 +0800 Subject: [PATCH 15/33] update meta information of Led controls to AtomUI.Labs --- AtomUI.Labs.slnx | 2 ++ Directory.Build.props | 3 +- Directory.Packages.props | 2 ++ .../AtomUILabsGallery.csproj | 1 + .../AtomUILabsGalleryModule.cs | 7 ++-- .../ShowCases/BlankShowCase.axaml | 32 ------------------- .../ShowCases/BlankShowCase.axaml.cs | 27 ---------------- .../ThemeManagerBuilderExtensions.cs | 2 ++ scripts/PackToLocal.ps1 | 21 +++++++++--- 9 files changed, 29 insertions(+), 68 deletions(-) delete mode 100644 controlgallery/AtomUILabsGallery/ShowCases/BlankShowCase.axaml delete mode 100644 controlgallery/AtomUILabsGallery/ShowCases/BlankShowCase.axaml.cs diff --git a/AtomUI.Labs.slnx b/AtomUI.Labs.slnx index 16c21e8..874bd8c 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/controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj b/controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj index 8d5ab37..6ec7118 100644 --- a/controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj +++ b/controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj @@ -16,6 +16,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/ThemeManagerBuilderExtensions.cs b/controlgallery/AtomUILabsGallery/ThemeManagerBuilderExtensions.cs index fba1129..040debe 100644 --- a/controlgallery/AtomUILabsGallery/ThemeManagerBuilderExtensions.cs +++ b/controlgallery/AtomUILabsGallery/ThemeManagerBuilderExtensions.cs @@ -1,4 +1,5 @@ using AtomUI.Theme; +using AtomUI.Labs.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/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 } - From f2fe4b7cdf21e9517ec47f2f6d3c794bb7f30a1a Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 18:00:03 +0800 Subject: [PATCH 16/33] Segment extremely condition tests --- .../Gallery/LedGlowWorkbenchLifecycleTests.cs | 44 ++++++ .../Segment/SegmentAxamlHost.axaml | 12 ++ .../Segment/SegmentAxamlHost.axaml.cs | 15 ++ .../Segment/SegmentDisplayMeasureTests.cs | 15 ++ .../Segment/SegmentDisplayRenderTests.cs | 51 ++++++ .../Segment/SegmentDisplayThemeTests.cs | 146 ++++++++++++++++++ .../Segment/SegmentLayoutEngineTests.cs | 42 +++-- 7 files changed, 309 insertions(+), 16 deletions(-) create mode 100644 tests/AtomUI.Labs.Led.Tests/Gallery/LedGlowWorkbenchLifecycleTests.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Segment/SegmentAxamlHost.axaml create mode 100644 tests/AtomUI.Labs.Led.Tests/Segment/SegmentAxamlHost.axaml.cs create mode 100644 tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayThemeTests.cs diff --git a/tests/AtomUI.Labs.Led.Tests/Gallery/LedGlowWorkbenchLifecycleTests.cs b/tests/AtomUI.Labs.Led.Tests/Gallery/LedGlowWorkbenchLifecycleTests.cs new file mode 100644 index 0000000..7c88343 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Gallery/LedGlowWorkbenchLifecycleTests.cs @@ -0,0 +1,44 @@ +using AtomUILabsGallery.ShowCases.Led; +using Avalonia.Controls; +using Avalonia.Threading; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.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(); + } + + 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(); + } + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentAxamlHost.axaml b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentAxamlHost.axaml new file mode 100644 index 0000000..7c59bb8 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentAxamlHost.axaml @@ -0,0 +1,12 @@ + + + diff --git a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentAxamlHost.axaml.cs b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentAxamlHost.axaml.cs new file mode 100644 index 0000000..eae81e3 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentAxamlHost.axaml.cs @@ -0,0 +1,15 @@ +using AtomUI.Labs.Led.Segment; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace AtomUI.Labs.Led.Tests.Segment; + +internal partial class SegmentAxamlHost : UserControl +{ + public SegmentAxamlHost() + { + AvaloniaXamlLoader.Load(this); + } + + public SegmentDisplay Display => this.FindControl("PART_Segment")!; +} diff --git a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayMeasureTests.cs b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayMeasureTests.cs index c613a90..9bc0d6b 100644 --- a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayMeasureTests.cs +++ b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayMeasureTests.cs @@ -46,6 +46,21 @@ public void Measure_ShouldCoerceInvalidNumericInputs() display.DesiredSize.ShouldBe(new Size(0, 2)); } + [Fact] + public void Measure_ShouldKeepDesiredSizeFiniteForExtremeFiniteInputs() + { + var display = CreateDisplay("8888"); + display.CharacterHeight = double.MaxValue; + display.CharacterAspectRatio = double.MaxValue; + display.CharacterSpacing = double.MaxValue; + display.Padding = new Thickness(double.MaxValue); + + display.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + + double.IsFinite(display.DesiredSize.Width).ShouldBeTrue(); + double.IsFinite(display.DesiredSize.Height).ShouldBeTrue(); + } + [Fact] public void Measure_ShouldNotDependOnSegmentThicknessOrGap() { diff --git a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayRenderTests.cs b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayRenderTests.cs index 6ff44c8..a2cfb38 100644 --- a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayRenderTests.cs +++ b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayRenderTests.cs @@ -304,6 +304,57 @@ public void Render_ShouldKeepIdentityScaleForClipOverflowWhenContentIsConstraine transform.Value.M22.ShouldBe(1); } + [Fact] + public void Render_ShouldPreserveConfiguredHeightForClipOverflow() + { + var display = CreateDisplay("8"); + display.Background = null; + display.CharacterHeight = 72; + display.OverflowMode = SegmentOverflowMode.Clip; + display.Measure(new Size(30, 24)); + display.Arrange(new Rect(0, 0, 30, 24)); + + var drawingGroup = RenderToDrawingGroup(display); + var transform = FindLayoutTransform(drawingGroup); + + transform.ShouldNotBeNull(); + transform.Value.M11.ShouldBe(1); + transform.Value.M22.ShouldBe(1); + display.VisibleActiveGeometry.ShouldNotBeNull(); + display.VisibleActiveGeometry.Bounds.Height.ShouldBeGreaterThan(24); + } + + [Fact] + public void Render_ShouldApplyVerticalAlignmentWhenContentHasExtraHeight() + { + var display = CreateDisplay("8"); + display.Background = null; + display.CharacterHeight = 40; + display.VerticalContentAlignment = VerticalAlignment.Bottom; + display.Measure(new Size(200, 120)); + display.Arrange(new Rect(0, 0, 200, 120)); + + var transform = FindLayoutTransform(RenderToDrawingGroup(display)); + + transform.ShouldNotBeNull(); + transform.Value.M32.ShouldBe(80); + } + + [Theory] + [InlineData(double.NaN, -1, double.PositiveInfinity, 2)] + [InlineData(double.MaxValue, double.MaxValue, double.MaxValue, double.MaxValue)] + public void Render_ShouldCoerceInvalidCornerRadius( + double topLeft, + double topRight, + double bottomRight, + double bottomLeft) + { + var display = CreateDisplay("8"); + display.CornerRadius = new CornerRadius(topLeft, topRight, bottomRight, bottomLeft); + + Should.NotThrow(() => RenderToDrawingGroup(display)); + } + [Fact] public void Render_ShouldNotScaleUpWhenScaleDownContentHasExtraSpace() { diff --git a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayThemeTests.cs b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayThemeTests.cs new file mode 100644 index 0000000..5aa6912 --- /dev/null +++ b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentDisplayThemeTests.cs @@ -0,0 +1,146 @@ +using AtomUI.Theme; +using AtomUI.Theme.Styling; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.Threading; +using Shouldly; +using Xunit; + +namespace AtomUI.Labs.Led.Tests.Segment; + +public class SegmentDisplayThemeTests +{ + static SegmentDisplayThemeTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + [Fact] + public void AxamlHost_ShouldCreateSegmentThroughLabsXmlNamespace() + { + var display = new SegmentAxamlHost().Display; + + display.Text.ShouldBe("12:45"); + display.CharacterHeight.ShouldBe(64); + display.CharacterAspectRatio.ShouldBe(0.6); + display.SegmentThickness.ShouldBe(7); + display.HorizontalContentAlignment.ShouldBe(HorizontalAlignment.Center); + display.ShowInactiveSegments.ShouldBeFalse(); + } + + [Fact] + public void ControlTheme_ShouldResolveSharedTokenDefaults() + { + var host = new SegmentAxamlHost(); + ShowInWindow(host, () => + { + BrushShouldHaveSameColor(host.Display.Background, GetThemeResource(SharedTokenKind.ColorBgContainer)); + BrushShouldHaveSameColor(host.Display.ActiveBrush, GetThemeResource(SharedTokenKind.ColorPrimary)); + BrushShouldHaveSameColor(host.Display.InactiveBrush, GetThemeResource(SharedTokenKind.ColorFillTertiary)); + host.Display.CornerRadius.ShouldBe(GetThemeResource(SharedTokenKind.BorderRadiusLG)); + host.Display.Padding.ShouldBe(GetThemeResource(SharedTokenKind.PaddingLG)); + }); + } + + [Fact] + public void ThemeChanges_ShouldRefreshDefaultsAndPreserveLocalValues() + { + var application = Application.Current; + application.ShouldNotBeNull(); + var previousVariant = application!.RequestedThemeVariant; + var host = new SegmentAxamlHost(); + + try + { + ShowInWindow(host, () => + { + var initialBackground = GetBrushColor(host.Display.Background); + var initialPadding = host.Display.Padding; + host.Display.ActiveBrush = Brushes.Magenta; + + application.RequestedThemeVariant = new ThemeVariant($"{IThemeManager.DEFAULT_THEME_ID}-Dark", null); + Dispatcher.UIThread.RunJobs(); + GetBrushColor(host.Display.Background).ShouldNotBe(initialBackground); + host.Display.ActiveBrush.ShouldBeSameAs(Brushes.Magenta); + + application.RequestedThemeVariant = new ThemeVariant($"{IThemeManager.DEFAULT_THEME_ID}-Compact", null); + Dispatcher.UIThread.RunJobs(); + host.Display.Padding.ShouldBe(GetThemeResource(SharedTokenKind.PaddingLG)); + host.Display.Padding.ShouldNotBe(initialPadding); + host.Display.ActiveBrush.ShouldBeSameAs(Brushes.Magenta); + }); + } + finally + { + application.RequestedThemeVariant = previousVariant; + Dispatcher.UIThread.RunJobs(); + } + } + + [Fact] + public void ExplicitNullBrushes_ShouldOverrideThemeDefaultsAcrossThemeChange() + { + var application = Application.Current; + application.ShouldNotBeNull(); + var previousVariant = application!.RequestedThemeVariant; + var host = new SegmentAxamlHost(); + + try + { + ShowInWindow(host, () => + { + host.Display.ActiveBrush = null; + host.Display.InactiveBrush = null; + + application.RequestedThemeVariant = new ThemeVariant($"{IThemeManager.DEFAULT_THEME_ID}-Dark", null); + Dispatcher.UIThread.RunJobs(); + + host.Display.ActiveBrush.ShouldBeNull(); + host.Display.InactiveBrush.ShouldBeNull(); + }); + } + finally + { + application.RequestedThemeVariant = previousVariant; + Dispatcher.UIThread.RunJobs(); + } + } + + private static T GetThemeResource(object key) + { + var application = Application.Current; + application.ShouldNotBeNull(); + application!.TryGetResource(key, application.ActualThemeVariant, out var value).ShouldBeTrue(); + value.ShouldBeAssignableTo(); + return (T)value!; + } + + private static void BrushShouldHaveSameColor(IBrush? actual, IBrush expected) + { + GetBrushColor(actual).ShouldBe(GetBrushColor(expected)); + } + + private static Color GetBrushColor(IBrush? brush) + { + brush.ShouldBeAssignableTo(); + return ((ISolidColorBrush)brush!).Color; + } + + private static void ShowInWindow(Control content, Action assertion) + { + var window = new Window { Width = 420, Height = 160, Content = content }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + assertion(); + } + finally + { + window.Close(); + } + } +} diff --git a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentLayoutEngineTests.cs b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentLayoutEngineTests.cs index 00c0449..d6148fb 100644 --- a/tests/AtomUI.Labs.Led.Tests/Segment/SegmentLayoutEngineTests.cs +++ b/tests/AtomUI.Labs.Led.Tests/Segment/SegmentLayoutEngineTests.cs @@ -1,3 +1,4 @@ +using AtomUI.Labs.Led.Segment; using AtomUI.Labs.Led.Segment.Character; using AtomUI.Labs.Led.Segment.Layout; using Avalonia; @@ -59,31 +60,18 @@ public void Calculate_ShouldApplyPaddingAndSpacingToDesiredSizeAndSlotPositions( } [Fact] - public void Calculate_ShouldUseFinalHeightWhenAvailable() + public void Calculate_ShouldKeepConfiguredHeightIndependentOfFinalBounds() { var layout = SegmentLayoutEngine.Calculate( "8", CreateOptions( characterHeight: 40, characterAspectRatio: 0.5, - padding: new Thickness(2, 3, 4, 5)), - new Size(200, 128)); - - layout.Slots[0].Bounds.Height.ShouldBe(120); - layout.Slots[0].Bounds.Width.ShouldBe(60); - layout.DesiredSize.ShouldBe(new Size(66, 128)); - } - - [Fact] - public void Calculate_ShouldKeepConfiguredHeightWhenFinalHeightIsInfinity() - { - var layout = SegmentLayoutEngine.Calculate( - "8", - CreateOptions(characterHeight: 40, characterAspectRatio: 0.5), - new Size(200, double.PositiveInfinity)); + padding: new Thickness(2, 3, 4, 5))); layout.Slots[0].Bounds.Height.ShouldBe(40); layout.Slots[0].Bounds.Width.ShouldBe(20); + layout.DesiredSize.ShouldBe(new Size(26, 48)); } [Fact] @@ -114,6 +102,28 @@ public void Calculate_ShouldCoerceNonFiniteLayoutInputs() layout.Slots[1].Bounds.ShouldBe(new Rect(0, 0, 0, 0)); } + [Fact] + public void Calculate_ShouldCapExtremeFiniteLayoutInputs() + { + var layout = SegmentLayoutEngine.Calculate( + "8888", + CreateOptions( + characterHeight: double.MaxValue, + characterAspectRatio: double.MaxValue, + characterSpacing: double.MaxValue, + padding: new Thickness(double.MaxValue))); + + double.IsFinite(layout.DesiredSize.Width).ShouldBeTrue(); + double.IsFinite(layout.DesiredSize.Height).ShouldBeTrue(); + layout.Slots.ShouldAllBe(slot => + double.IsFinite(slot.Bounds.X) + && double.IsFinite(slot.Bounds.Y) + && double.IsFinite(slot.Bounds.Width) + && double.IsFinite(slot.Bounds.Height)); + layout.Slots[0].Bounds.Width.ShouldBe(SegmentValueSanitizer.MaximumLayoutValue); + layout.Slots[0].Bounds.Height.ShouldBe(SegmentValueSanitizer.MaximumLayoutValue); + } + private static SegmentLayoutOptions CreateOptions( double characterHeight = 100, double characterAspectRatio = 0.5, From 094ef589577a3b430fc5b2f7ef55e0c581d44680 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 18:00:39 +0800 Subject: [PATCH 17/33] Matrix extremely condition tests --- .../Matrix/MatrixDisplayRenderTests.cs | 26 +++++++++ .../Matrix/MatrixMarqueeLifecycleTests.cs | 55 +++++++++++++++++++ .../Matrix/MatrixMarqueeMotionTests.cs | 24 ++++++-- 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayRenderTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayRenderTests.cs index bdae1f1..28e06df 100644 --- a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayRenderTests.cs +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixDisplayRenderTests.cs @@ -335,6 +335,32 @@ public void Render_ShouldApplyMarqueePositionWithoutScaleDown(double progress) transform.Value.M31.ShouldBe(600 - (600 + contentWidth) * progress, 0.0001); } + [Theory] + [InlineData(0)] + [InlineData(double.NaN)] + [InlineData(double.NegativeInfinity)] + public void Render_ShouldFallbackToStaticContentWhenMarqueeSpeedIsNotEffective(double speed) + { + var display = CreateDisplay("MATRIX"); + display.IsMarqueeEnabled = true; + display.MarqueeSpeed = speed; + display.HorizontalContentAlignment = HorizontalAlignment.Left; + display.MarqueeProgress = 0; + var staticDisplay = CreateDisplay("MATRIX"); + staticDisplay.HorizontalContentAlignment = HorizontalAlignment.Left; + + var transform = FindLayoutTransform(RenderToDrawingGroup(display)); + var staticTransform = FindLayoutTransform(RenderToDrawingGroup(staticDisplay)); + var dots = RenderToGlyphLayerDrawings(display).ToList(); + var staticDots = RenderToGlyphLayerDrawings(staticDisplay).ToList(); + + transform.ShouldNotBeNull(); + staticTransform.ShouldNotBeNull(); + transform.Value.ShouldBe(staticTransform.Value); + dots.Count.ShouldBe(staticDots.Count); + dots.ShouldNotBeEmpty(); + } + [Fact] public void Render_ShouldKeepLongMarqueeWorkBoundedByViewport() { diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeLifecycleTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeLifecycleTests.cs index c7d6295..a2aa292 100644 --- a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeLifecycleTests.cs +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeLifecycleTests.cs @@ -26,15 +26,18 @@ public void AttachEnableDisableAndDetach_ShouldOwnAnimationLifetime() window.Show(); Dispatcher.UIThread.RunJobs(); display.IsMarqueeAnimationRunning.ShouldBeTrue(); + display.MarqueeVisibilitySubscriptionCount.ShouldBeGreaterThan(0); display.IsMarqueeEnabled = false; Dispatcher.UIThread.RunJobs(); display.IsMarqueeAnimationRunning.ShouldBeFalse(); display.MarqueeProgress.ShouldBe(0); + display.MarqueeVisibilitySubscriptionCount.ShouldBe(0); display.IsMarqueeEnabled = true; Dispatcher.UIThread.RunJobs(); display.IsMarqueeAnimationRunning.ShouldBeTrue(); + display.MarqueeVisibilitySubscriptionCount.ShouldBeGreaterThan(0); } finally { @@ -43,6 +46,28 @@ public void AttachEnableDisableAndDetach_ShouldOwnAnimationLifetime() display.IsMarqueeAnimationRunning.ShouldBeFalse(); display.MarqueeProgress.ShouldBe(0); + display.MarqueeVisibilitySubscriptionCount.ShouldBe(0); + } + + [Fact] + public void AttachedStaticDisplay_ShouldNotSubscribeToAncestorVisibility() + { + var display = CreateDisplay(); + display.IsMarqueeEnabled = false; + var window = new Window { Width = 320, Height = 100, Content = new Border { Child = display } }; + + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + + display.MarqueeVisibilitySubscriptionCount.ShouldBe(0); + display.IsMarqueeAnimationRunning.ShouldBeFalse(); + } + finally + { + window.Close(); + } } [Fact] @@ -73,6 +98,36 @@ public void InvalidRuntimeConditions_ShouldNotKeepAnimation() } } + [Fact] + public void ParentVisibility_ShouldSuspendAndResumeAnimation() + { + var display = CreateDisplay(); + var parent = new Border { Child = display }; + var window = new Window { Width = 320, Height = 100, Content = parent }; + + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + display.IsMarqueeAnimationRunning.ShouldBeTrue(); + + parent.IsVisible = false; + Dispatcher.UIThread.RunJobs(); + display.IsEffectivelyVisible.ShouldBeFalse(); + display.IsMarqueeAnimationRunning.ShouldBeFalse(); + display.MarqueeProgress.ShouldBe(0); + + parent.IsVisible = true; + Dispatcher.UIThread.RunJobs(); + display.IsEffectivelyVisible.ShouldBeTrue(); + display.IsMarqueeAnimationRunning.ShouldBeTrue(); + } + finally + { + window.Close(); + } + } + [Fact] public void RestartAndDisable_ShouldReleasePreviousController() { diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeMotionTests.cs b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeMotionTests.cs index 415adef..3d97f95 100644 --- a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeMotionTests.cs +++ b/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixMarqueeMotionTests.cs @@ -6,8 +6,6 @@ namespace AtomUI.Labs.Led.Tests.Matrix; public class MatrixMarqueeMotionTests { - private readonly LeftThroughMarqueeMotion _motion = new(); - [Theory] [InlineData(0, 200)] [InlineData(0.25, 125)] @@ -18,7 +16,7 @@ public void LeftThrough_ShouldMoveFromViewportRightToContentLeft( double progress, double expectedX) { - var plan = _motion.Calculate(new MarqueeMotionContext(200, 100, progress)); + var plan = LeftThroughMarqueeMotion.Calculate(new MarqueeMotionContext(200, 100, progress)); plan.PlacementCount.ShouldBe(1); plan.GetX(0).ShouldBe(expectedX); @@ -32,7 +30,7 @@ public void LeftThrough_ShouldMoveFromViewportRightToContentLeft( [InlineData(double.PositiveInfinity, -100)] public void LeftThrough_ShouldClampProgress(double progress, double expectedX) { - _motion.Calculate(new MarqueeMotionContext(200, 100, progress)).FirstX.ShouldBe(expectedX); + LeftThroughMarqueeMotion.Calculate(new MarqueeMotionContext(200, 100, progress)).FirstX.ShouldBe(expectedX); } [Fact] @@ -42,4 +40,22 @@ public void RenderPlan_ShouldRejectUnavailablePlacement() Should.Throw(() => plan.GetX(1)); } + + [Theory] + [InlineData(double.NaN, 100, -50)] + [InlineData(double.PositiveInfinity, 100, -50)] + [InlineData(double.NegativeInfinity, 100, -50)] + [InlineData(200, double.NaN, 100)] + [InlineData(200, double.PositiveInfinity, 100)] + [InlineData(200, double.NegativeInfinity, 100)] + public void LeftThrough_ShouldCoerceNonFiniteDimensions( + double viewportWidth, + double contentWidth, + double expectedX) + { + var plan = LeftThroughMarqueeMotion.Calculate(new MarqueeMotionContext(viewportWidth, contentWidth, 0.5)); + + double.IsFinite(plan.FirstX).ShouldBeTrue(); + plan.FirstX.ShouldBe(expectedX); + } } From ed8aa7518a2f482a413c6ba275a77808e61f6ca3 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 18:01:30 +0800 Subject: [PATCH 18/33] update Led.Matrix performance doc --- docs/controls/led/matrix-implementation.md | 38 ++++++++++--------- .../led/matrix-marquee-minimum-contract.md | 17 ++++----- docs/controls/led/matrix-mvp-audit.md | 2 +- .../matrix-performance-allocation-and-soak.md | 2 +- .../led/matrix-performance-baseline.md | 2 +- .../led/matrix-performance-dynamic-load.md | 2 +- .../led/matrix-performance-geometry-batch.md | 2 +- .../led/matrix-static-visual-system.md | 2 +- 8 files changed, 35 insertions(+), 32 deletions(-) diff --git a/docs/controls/led/matrix-implementation.md b/docs/controls/led/matrix-implementation.md index 9a2d421..9fdc1fd 100644 --- a/docs/controls/led/matrix-implementation.md +++ b/docs/controls/led/matrix-implementation.md @@ -1,10 +1,10 @@ # LED Matrix 工业级实现原理 -> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 +> 文档状态:当前实现契约,更新于 2026-07-20。性能数值链接指向带日期的历史证据,不代表当前机器基线。 -本文记录 `AtomUI.Labs.Led.Matrix` 的目标实现设计。Matrix 是 LED 家族中的点阵屏路线,不是字体控件,不是 Segment 的升级版,也不是硬件 LED 控制器。 +本文记录 `AtomUI.Labs.Led.Matrix` 的当前实现设计。Matrix 是 LED 家族中的点阵屏路线,不是字体控件,不是 Segment 的升级版,也不是硬件 LED 控制器。 -第一版公共控件类型固定为 `MatrixDisplay`。第一版只实现单行静态 `5x7` 等宽点阵文本。 +公共控件类型为 `MatrixDisplay`,基础显示为单行 `5x7` 等宽点阵文本,并可选启用 Glow 和单向穿屏 Marquee。 ## 核心链路 @@ -243,10 +243,14 @@ Provider 不属于 Matrix MVP 的内部或公开合同。若新增其它真实 | `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` | 两轮穿屏间隔 | -MVP第一版固定圆点。V2以增量合同加入`MatrixDotShape`和`DotCornerRadiusRatio`;仍不公开Glow、Provider、FontSet、`GlyphWidth`或`GlyphHeight`。 - -后续Glow增量已经落地,新增`GlowBrush`、`GlowOpacity`和`GlowRadius`,默认`GlowBrush=null`,因此不改变基础显示。正式算法、范围和测试结论以[LED Glow技术路线选型](glow-technical-options.md)及[LED Glow原型评估](glow-prototype-evaluation.md)为准;本段中“仍不公开Glow”只描述MVP历史边界,不再代表当前API。 +当前仍不公开 Provider、FontSet、`GlyphWidth`、`GlyphHeight` 或运动策略注入。Glow 技术范围见[LED Glow技术路线选型](glow-technical-options.md),Marquee 生命周期与运动语义见[LED Matrix Marquee最小契约](matrix-marquee-minimum-contract.md)。 `MatrixOverflowMode` 只包含: @@ -547,7 +551,7 @@ Labs 程序集通过 `https://atomui.net/labs` XML 命名空间公开 `MatrixDis 字模、布局和自动化路径都使用编译期已知类型。Matrix MVP 不引入需要独立释放的 subscription、binding、timer、动态视觉或非 Visual 资源宿主。 -真实发布验收使用 Labs Sample 的 Release NativeAOT 配置和专用 `LabsPublishAot` 开关,避免把全局 `PublishAot` 属性传播到 `AtomUI.Generator` Analyzer 项目。`win-x64` NativeAOT 已完成真实 publish;当前剩余 warning 来自 `AtomUI.Core/AppBuilderExtensions.cs` 的既有 Win32 反射配置路径,不来自 Matrix 或 Labs。 +真实发布验收使用 `controlgallery/AtomUILabsGallery.Desktop` 的 Release NativeAOT 配置和专用 `GalleryPublishAot` 开关,避免把全局 `PublishAot` 属性传播到 Analyzer 项目。当前 LED 源码必须保持无 AOT/trim 分析警告;依赖程序集警告应在每次发布结果中单独归因。 ## 性能基线 @@ -574,30 +578,30 @@ tools/performances/AtomUI.Labs.Led.Performance Labs 包必须使用独立标题、描述、标签和 README,明确不保证 Ant Design 视觉一致性,也不要求安装 `AtomUI.Desktop.Controls`。net8/net10 包依赖只允许包含 `AtomUI.Core` 与 Avalonia,不得出现 AtomUI 成型控件包。 -## 第一版边界 +## 当前能力边界 -以下列表记录已冻结的MVP第一版。V2只增量加入静态DotShape系统,完整合同见[matrix-static-visual-system.md](matrix-static-visual-system.md)。 +以下列表描述当前实现。静态 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 主题默认值和 Labs sample。 -- 可选的单向穿屏Marquee,最小契约见[LED Matrix Marquee最小契约](matrix-marquee-minimum-contract.md)。 +- Shared Token 主题默认值和 Gallery。 +- 共享 scoped `BlurEffect` Glow。 +- 可选的单向穿屏 Marquee;有效速度为 0 时回退为普通静态显示。 -第一版不做: +当前不支持: - Provider 接口或自定义字模来源。 - 多套字模规格或任意分辨率配置。 - 中文、CJK、复杂脚本和独立小写字形。 -- 方点、圆角方点和点形状切换 API。 -- Glow、扫描线、材质和复杂视觉效果。 +- Glow 材质、扫描线、偏移、质量等级或自定义渲染后端。 - 除已冻结单向穿屏Marquee之外的滚动模式、闪烁、多行和自动换行。 - 图片点阵化或硬件 LED 控制。 - 依赖 AtomUI 成型控件包。 @@ -636,7 +640,7 @@ Labs 包必须使用独立标题、描述、标签和 README,明确不保证 A - 非均匀圆角边框在100%、125%、150%、200% RenderScaling下保持单一连通区域,并按四边有效厚度落点。 - 像素层面的 Clip、ScaleDown、内容对齐和暗点开关。 -Labs sample 至少展示: +Gallery 至少展示: - 大写字母和小写输入。 - 数字。 diff --git a/docs/controls/led/matrix-marquee-minimum-contract.md b/docs/controls/led/matrix-marquee-minimum-contract.md index 9760076..2a4bd73 100644 --- a/docs/controls/led/matrix-marquee-minimum-contract.md +++ b/docs/controls/led/matrix-marquee-minimum-contract.md @@ -1,6 +1,6 @@ # LED Matrix Marquee 最小契约 -> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 +> 文档状态:当前实现契约,更新于 2026-07-20。 ## 领域边界 @@ -40,21 +40,21 @@ Matrix静态显示在Marquee关闭时必须独立、完整可用。关闭路径 4. 保持离开位置等待`MarqueeRepeatDelay`; 5. 从右侧外部开始下一轮。 -第一轮立即开始,不应用前置延迟。Marquee运行时忽略`HorizontalContentAlignment`,保留`VerticalContentAlignment`;内部按`Clip`语义绘制且不回写`OverflowMode`。空文本、无效视口或有效速度为0时不启动动画。 +第一轮立即开始,不应用前置延迟。Marquee运行时忽略`HorizontalContentAlignment`,保留`VerticalContentAlignment`;内部按`Clip`语义绘制且不回写`OverflowMode`。空文本或无效视口不启动动画;有效速度为0时不启动动画,并按普通静态 `Clip/ScaleDown`、对齐语义渲染。 ## 内部扩展结构 ```text MatrixDisplay -> LedMarqueeController:Avalonia Animation生命周期和进度 - -> IMarqueeMotion:无Avalonia绘制依赖的纯运动数学 + -> LeftThroughMarqueeMotion:无Avalonia绘制依赖的静态纯运动数学 -> MarqueeRenderPlan:一帧中一个或多个内容放置位置 -> Matrix可见字符剔除、Geometry和Render ``` -首版只有`LeftThroughMarqueeMotion`。内部帧计划从第一版起允许多个放置位置,使后续官方连续首尾模式无需重写Matrix渲染组合流程;首版不向开发者开放策略注入,不使用反射、动态发现、DI或插件注册。 +当前只有静态 `LeftThroughMarqueeMotion.Calculate`。在出现第二个真实运动实现前不引入接口;帧计划仍可表达一个或多个放置位置,但不向开发者开放策略注入,也不使用反射、动态发现、DI或插件注册。 -动画使用Avalonia Animation驱动内部归一化进度,不使用`DispatcherTimer`,也不按帧累加固定像素。Text、点尺寸、间距、Padding、边框、Bounds、速度或重复间隔变化时取消旧周期并从右侧重新开始。Detach、隐藏、禁用和空文本立即释放动画;重新进入可运行状态后从头开始。 +动画使用Avalonia Animation驱动内部归一化进度,不使用`DispatcherTimer`,也不按帧累加固定像素。Text、点尺寸、间距、Padding、边框、Bounds、速度或重复间隔变化时取消旧周期并从右侧重新开始。Detach、控件自身或任一视觉祖先隐藏、禁用和空文本都会立即释放动画;重新进入可运行状态后从头开始。祖先可见性订阅只在控件已挂载且 `IsMarqueeEnabled=true` 期间存在;关闭 Marquee 或 Detach 时完整解除。 ## 渲染与性能契约 @@ -73,13 +73,12 @@ MatrixDisplay - Attach、Detach、隐藏、启停、Text/Bounds/参数变化和WeakReference回收。 - Clip、垂直对齐、Border、Inactive、Glow和不同DPI下的像素边界。 - 1000与10000字符窄视口保持相同数量级的可见绘制命令。 -- Labs全量测试、Sample Release、真实Windows窗口长稳和win-x64 NativeAOT。 +- Labs全量测试、Gallery Release、真实Windows窗口长稳和win-x64 NativeAOT。 ## 首轮实现验证结果 -- Labs全量测试`405/405`通过,包含公共合同、纯运动数学、非法输入、AXAML、渲染位置、长文本视口剔除、600帧缓存稳定和Controller WeakReference释放。 +- 当前测试覆盖公共合同、纯运动数学、非法输入、AXAML、渲染位置、长文本视口剔除、600帧缓存稳定、父级有效可见性和Controller WeakReference释放;精确数量以本仓库最新 `dotnet test` 结果为准。 - 100与10000字符在相同窄视口和中段进度下提交相同数量级的可见字模命令,不随完整文本长度线性增长。 - `net8.0`与`net10.0` Release双目标构建通过,0 warning、0 error。 -- Sample Release构建通过;普通Win32产物和NativeAOT产物分别持续运行15秒,均未提前退出,关闭路径无异常。 -- win-x64 NativeAOT发布成功。警告仍来自`AtomUI.Core/AppBuilderExtensions.cs`既有Win32反射配置,不来自Labs或Marquee。 +- 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 index 5cb0915..304838d 100644 --- a/docs/controls/led/matrix-mvp-audit.md +++ b/docs/controls/led/matrix-mvp-audit.md @@ -1,6 +1,6 @@ # Matrix MVP 收口审计 -> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 +> 文档状态:历史 MVP 收口审计(审计日期 2026-07-10)。本文保留当时边界和修复证据,不描述后续 Glow、Marquee、Gallery 或当前测试状态。 - 审计日期:2026-07-10 - 审计对象:`AtomUI.Labs.Led.Matrix.MatrixDisplay` diff --git a/docs/controls/led/matrix-performance-allocation-and-soak.md b/docs/controls/led/matrix-performance-allocation-and-soak.md index 03cd344..f1fe2a8 100644 --- a/docs/controls/led/matrix-performance-allocation-and-soak.md +++ b/docs/controls/led/matrix-performance-allocation-and-soak.md @@ -1,6 +1,6 @@ # Matrix Interaction Performance Baseline -> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 +> 文档状态:历史性能快照。下列数值只适用于文内日期、配置和机器,不代表当前性能;回归时必须重新运行本仓库 runner。 - Date: 2026-07-10 19:08:33 +08:00 - Configuration: Release, .NET 10 diff --git a/docs/controls/led/matrix-performance-baseline.md b/docs/controls/led/matrix-performance-baseline.md index d09a55c..28d4a59 100644 --- a/docs/controls/led/matrix-performance-baseline.md +++ b/docs/controls/led/matrix-performance-baseline.md @@ -1,6 +1,6 @@ # Matrix Interaction Performance Baseline -> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 +> 文档状态:历史性能快照。下列数值只适用于文内日期、配置和机器,不代表当前性能;回归时必须重新运行本仓库 runner。 - Date: 2026-07-10 17:22:19 +08:00 - Configuration: Release, .NET 10 diff --git a/docs/controls/led/matrix-performance-dynamic-load.md b/docs/controls/led/matrix-performance-dynamic-load.md index 427d46a..ea3a3ad 100644 --- a/docs/controls/led/matrix-performance-dynamic-load.md +++ b/docs/controls/led/matrix-performance-dynamic-load.md @@ -1,6 +1,6 @@ # Matrix Interaction Performance Baseline -> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 +> 文档状态:历史性能快照。下列数值只适用于文内日期、配置和机器,不代表当前性能;回归时必须重新运行本仓库 runner。 - Date: 2026-07-10 18:53:37 +08:00 - Configuration: Release, .NET 10 diff --git a/docs/controls/led/matrix-performance-geometry-batch.md b/docs/controls/led/matrix-performance-geometry-batch.md index 56ec38a..0d27109 100644 --- a/docs/controls/led/matrix-performance-geometry-batch.md +++ b/docs/controls/led/matrix-performance-geometry-batch.md @@ -1,6 +1,6 @@ # Matrix Interaction Performance Baseline -> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 +> 文档状态:历史性能快照。下列数值只适用于文内日期、配置和机器,不代表当前性能;回归时必须重新运行本仓库 runner。 - Date: 2026-07-10 18:21:22 +08:00 - Configuration: Release, .NET 10 diff --git a/docs/controls/led/matrix-static-visual-system.md b/docs/controls/led/matrix-static-visual-system.md index 882b740..89820ba 100644 --- a/docs/controls/led/matrix-static-visual-system.md +++ b/docs/controls/led/matrix-static-visual-system.md @@ -1,6 +1,6 @@ # Matrix V2 静态视觉系统 -> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 +> 文档状态:历史 V2 设计与验收记录。点轮廓和面板边框仍已实现,但测试数量、宿主及构建结果停留在当时;当前公共契约以 [Matrix 实现原理](matrix-implementation.md) 为准。 本文定义`MatrixDisplay`第二阶段的静态点轮廓系统。它只改变每个点的外轮廓,不改变字模、点位坐标、测量尺寸、颜色合同、溢出策略或动态文本行为。 From 30fb51706e8c0aee01d0363d12645d265f684ae8 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 18:02:01 +0800 Subject: [PATCH 19/33] update Led.Segment performance doc --- docs/controls/led/segment-implementation.md | 86 +++++++++++-------- .../led/segment-performance-regression.md | 4 +- 2 files changed, 52 insertions(+), 38 deletions(-) diff --git a/docs/controls/led/segment-implementation.md b/docs/controls/led/segment-implementation.md index 57d49ed..324c920 100644 --- a/docs/controls/led/segment-implementation.md +++ b/docs/controls/led/segment-implementation.md @@ -1,6 +1,6 @@ # LED Segment 工业级实现原理 -> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 +> 文档状态:当前实现契约,更新于 2026-07-20。本文描述现有 `AtomUI.Labs.Led.Segment` 源码;历史性能数据另见性能回归文档。 > 当前实现已将早期同形叠色Glow升级为共享Scoped Blur Glow,并补充`GlowRadius`。正式Glow契约与性能结论见[LED Glow技术路线选型](glow-technical-options.md)和[LED Glow原型评估](glow-prototype-evaluation.md)。 @@ -202,6 +202,35 @@ internal readonly record struct SegmentCharacterPattern( 不支持字符第一版建议按空格处理,不抛异常。显示控件不应该因为输入中出现一个不可显示字符导致 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 和属性优先级。 + ## 布局测量 映射决定“哪些段亮”,布局测量决定“每个字符放哪里、多大”。 @@ -209,13 +238,13 @@ internal readonly record struct SegmentCharacterPattern( 布局输入: - 规范化后的字符 pattern 列表。 -- 字符期望高度或最终可用尺寸。 +- 字符期望高度。 - 字符宽高比。 - 字符间距。 - 符号宽度规则。 - Padding。 -`CharacterHeight` 表示期望字符高度,用于 `MeasureOverride` 计算理想尺寸。实际 `Render` 阶段会根据最终 `Bounds.Height` 重新计算 layout;如果父容器给了更高或更低的最终高度,最终绘制高度以 arranged bounds 为准。因此 `CharacterHeight` 不是强制绘制高度,而是参与测量的期望值。 +`CharacterHeight` 表示理想字符高度,同时决定未缩放字符格的绘制高度。最终 `Bounds` 不会重写 layout:空间更大时只产生对齐偏移;空间更小时由 `Clip` 裁剪,只有显式 `ScaleDown` 才按比例缩小。 段厚度和段间隙不参与理想尺寸计算。它们只影响字符格内部的几何形状,所以只应触发重绘,不应触发布局测量。 @@ -270,7 +299,7 @@ Text 改变 ```text Render - -> 根据 Bounds.Size 计算实际 layout + -> 读取与 MeasureOverride 相同的理想 layout -> 根据 OverflowMode 计算绘制缩放 -> 根据 HorizontalContentAlignment / VerticalContentAlignment 计算偏移 -> PushClip 到 Bounds @@ -279,24 +308,7 @@ Render -> 根据 pattern 绘制暗段和亮段 ``` -`ArrangeOverride` 不是主要几何生成入口。Segment 是自绘控件,通常没有子控件需要 arrange。`ArrangeOverride` 最多用于记录最终尺寸或标记缓存失效: - -最终空间中的ScaleDown比例和内容对齐偏移由LED家族根目录的`LedDisplayLayoutMath`计算。Segment仍自行决定何时启用ScaleDown,并保留最终Bounds参与字符高度布局的路线专属语义。 - -```csharp -protected override Size ArrangeOverride(Size finalSize) -{ - if (_lastArrangeSize != finalSize) - { - _lastArrangeSize = finalSize; - InvalidateGeometryCache(); - } - - return finalSize; -} -``` - -不要把几何生成主要塞进 `ArrangeOverride`。几何不仅依赖最终尺寸,也依赖段厚度、间隙、几何风格和字符 slot。Avalonia 可能因为视觉失效重新 `Render`,但不一定重新 `Arrange`。 +Segment 不重写 `ArrangeOverride`。最终空间中的 ScaleDown 比例和内容对齐偏移由家族根目录的 `LedDisplayLayoutMath` 计算;Arrange 尺寸不进入 layout 或 geometry 缓存键。Avalonia 可能因为视觉失效重新 `Render` 而不重新 `Arrange`,因此几何生成只由当前理想 layout 和几何属性驱动。 ## 几何生成 @@ -429,14 +441,13 @@ foreach (var slot in layout.Slots) 暗段用于表达未点亮但仍可见的 LED 轮廓。没有暗段时,控件更像普通矢量图形,不像真实设备面板。 -第一版不做真实 blur 发光。当前 Glow 是一个可选半透明预绘制层: +当前 Glow 是可选的 scoped blur 层: - `GlowBrush = null` 时完全关闭,这是默认状态。 - `GlowOpacity` 默认 `0.35`,但只有 `GlowBrush` 存在时才生效。 -- Glow 使用同一份 cached `Geometry`,不会因为开启 Glow 生成另一套几何。 -- `GlowBrush` 和 `GlowOpacity` 只影响绘制,不进入 geometry cache key。 - -这不是最终真实 LED 光晕模型,只是最低风险的视觉层次能力。后续如果做 blur、外扩光晕或材质效果,必须重新审查性能、缓存和边界测试。 +- Glow 使用同一份可见 Active 聚合 `Geometry`,通过单个 `BlurEffect` 作用域绘制,不生成另一套段几何。 +- `GlowBrush`、`GlowOpacity` 和 `GlowRadius` 只影响绘制,不进入 geometry cache key。 +- `GlowBrush = null`、有效透明度为 0 或有效半径为 0 时不创建 Effect。 当前第一版已经落地的绘制语义: @@ -460,6 +471,8 @@ Segment 第一版采用“显示控件不因非法输入崩溃”的策略。所 - `Infinity` 视为最小值或被 clamp 到范围内。 - 负数按 0 处理,带最小值的属性按最小值处理。 - `Padding` 的四个方向分别规整为非负有限数。 +- 布局数值上限为 `1_000_000`;极端有限输入也不能产生无穷 DesiredSize、slot 或几何坐标。 +- `CornerRadius` 四角分别规整为非负有限数并受相同上限约束。 - `CharacterAspectRatio` 最小值为 `0.1`。 - 渲染阶段 `SegmentThickness` 最小值为 `1`,随后在 `SegmentGeometryFactory` 中按字符格尺寸继续 clamp。 - `SegmentGap` 最小值为 `0`,随后在 `SegmentGeometryFactory` 中按字符格尺寸继续 clamp。 @@ -498,7 +511,7 @@ Segment 第一版采用“显示控件不因非法输入崩溃”的策略。所 当前控件缓存分两层: -- layout 缓存:`Text`、`CharacterHeight`、`CharacterAspectRatio`、`CharacterSpacing`、`Padding`、最终 `Bounds.Size`。 +- 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 捆绑保存。 @@ -552,9 +565,9 @@ Shared Token -> Render 读取最终属性值 ``` -## 第一版边界 +## 当前能力边界 -第一版应该做: +当前已经实现: - 静态十四段字符显示。 - 数字、`A-Z`、冒号、小数点、负号、空格。 @@ -567,15 +580,15 @@ Shared Token - 可主题化。 - 基础段形态配置。 - 冒号和小数点统一几何缓存。 -- 最小 Glow 绘制层。 +- 共享 `LedGlowRenderer` 的 scoped `BlurEffect` Glow。 -第一版不做: +当前不支持: - 普通字体模拟 LED。 - 点阵显示。 - 滚动字幕。 - 内置闪烁和复杂动画。 -- 真实 blur 光晕和复杂材质。 +- Glow 材质、偏移、质量等级或自定义后端。 - 多行文本。 - 中文和复杂脚本。 - 富文本。 @@ -600,15 +613,16 @@ Segment 不能只靠手动看 sample。 - `LedCharacterNormalizerTests`:ASCII 小写转大写。 - `SegmentCharacterMapTests`:数字、`A-Z`、符号、冒号、小数点、未知字符 fallback。 -- `SegmentLayoutEngineTests`:slot 数量、窄符号宽度、padding、spacing、最终高度、非法数值规整。 +- `SegmentLayoutEngineTests`:slot 数量、窄符号宽度、padding、spacing、理想尺寸不受最终空间改写、非法数值规整。 - `SegmentGeometryFactoryTests`:14 段完整性、bounds 内几何、极端 thickness/gap、非正 bounds、非有限选项。 - `SegmentDisplayContractTests`:StyledProperty 名称、默认值、CLR wrapper、内容对齐和溢出策略。 - `SegmentDisplayMeasureTests`:真实控件测量、padding、非法数值、厚度和间隙不影响 DesiredSize。 -- `SegmentDisplayRenderTests`:基础 render 不抛异常、暗段/亮段绘制数量、冒号/小数点绘制语义、`ActiveBrush = null` 语义、Glow 绘制语义、同 topology 文本更新复用几何、topology 或几何参数变化刷新几何、Glow 参数变化不刷新几何、内容对齐或溢出策略变化不刷新几何、`ScaleDown` 产生绘制变换。 +- `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)。Segment当前同形叠色Glow属于历史现状,不代表选型已经完成。 +当前 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 index ac33899..aef9af1 100644 --- a/docs/controls/led/segment-performance-regression.md +++ b/docs/controls/led/segment-performance-regression.md @@ -1,12 +1,12 @@ # LED Segment 性能回归矩阵 -> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 +> 文档状态:当前回归契约,更新于 2026-07-20。机器相关性能数值仅作历史参考,确定性的缓存和重建次数才是自动化门禁。 本文定义 `SegmentDisplay` 高频更新和缓存行为的长期回归边界。指标使用确定性的 layout/geometry 重建次数,不使用容易受机器负载影响的单元测试耗时阈值。 ## 场景资格 -- Labs sample 同时展示超过 5 个 `SegmentDisplay` 实例。 +- Gallery 同时展示超过 5 个 `SegmentDisplay` 实例。 - 动态时钟和计数器会在一次运行期间持续更新 `Text`。 - 高频成本位于控件自己的字符映射、layout 和 geometry 生成路径,不依赖对 Avalonia 内部成本的推测。 From 57d183395d758a1c7de734704ae198a1d41a605a Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 18:02:33 +0800 Subject: [PATCH 20/33] update Glow feature doc --- .../led/family-common-boundary-audit.md | 6 +- .../controls/led/glow-prototype-evaluation.md | 2 +- docs/controls/led/glow-technical-options.md | 411 +++--------------- 3 files changed, 71 insertions(+), 348 deletions(-) diff --git a/docs/controls/led/family-common-boundary-audit.md b/docs/controls/led/family-common-boundary-audit.md index 89e135f..12fa4c4 100644 --- a/docs/controls/led/family-common-boundary-audit.md +++ b/docs/controls/led/family-common-boundary-audit.md @@ -1,6 +1,6 @@ # LED 家族公共边界审计 -> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 +> 文档状态:历史审计记录(审计日期 2026-07-10)。结论用于解释共享边界的形成过程;其中宿主名称、构建结果和测试数量不是当前验证状态,当前契约以 [overview](overview.md) 为准。 - 审计日期:2026-07-10 - 审计对象:`Led.Segment` 与 `Led.Matrix` @@ -39,7 +39,7 @@ Segment和Matrix仍各自判断自己的公开`OverflowMode`是否为`ScaleDown` | `SegmentOverflowMode` / `MatrixOverflowMode` | 不共享 | 二者是已经冻结的独立公开合同,合并会造成API变更和路线耦合 | | StyledProperty与控件基类 | 不共享 | 公共基类会扩大公开API,并把两条显示路线强制绑定到同一继承合同 | | ValueSanitizer | 不共享 | Matrix对布局参数设置`1,000,000`上限;Segment没有该上限且额外支持范围规整 | -| LayoutEngine、Layout、Slot | 不共享 | Segment支持窄符号和最终高度约束;Matrix使用固定5x7等宽Rune布局 | +| LayoutEngine、Layout、Slot | 不共享 | Segment使用十四段字符格并支持窄符号;Matrix使用固定5x7等宽Rune布局 | | CharacterMap与fallback | 不共享 | Segment未知字符为空格,Matrix未知Unicode标量为问号,语义不同 | | Geometry与缓存 | 不共享 | Segment缓存字符槽拓扑和十四段骨架;Matrix按字模bit缓存亮暗点Geometry | | AutomationPeer | 不共享 | 外壳相似,但提取需要基类、接口或委托,两个消费者不足以抵消复杂度 | @@ -50,7 +50,7 @@ Segment和Matrix仍各自判断自己的公开`OverflowMode`是否为`ScaleDown` Segment继续拥有: -- 最终Bounds参与字符高度布局。 +- 基于 `CharacterHeight` 生成理想布局,并在绘制阶段处理最终 Bounds、对齐和溢出。 - Segment专属几何缓存与Glow绘制。 - 将`SegmentOverflowMode`映射为是否调用共享ScaleDown计算。 diff --git a/docs/controls/led/glow-prototype-evaluation.md b/docs/controls/led/glow-prototype-evaluation.md index 10b2a9c..1c25955 100644 --- a/docs/controls/led/glow-prototype-evaluation.md +++ b/docs/controls/led/glow-prototype-evaluation.md @@ -1,6 +1,6 @@ # LED Glow 首轮原型评估 -> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 +> 文档状态:历史原型与评估记录(首轮日期 2026-07-11)。测试数量、Labs Sample、警告和性能数值仅表示当时环境;当前 Glow 契约以 [技术路线选型](glow-technical-options.md)和当前实现文档为准。 ## 评估状态 diff --git a/docs/controls/led/glow-technical-options.md b/docs/controls/led/glow-technical-options.md index 317576c..1421767 100644 --- a/docs/controls/led/glow-technical-options.md +++ b/docs/controls/led/glow-technical-options.md @@ -1,374 +1,97 @@ -# LED Glow 技术路线选型 +# LED Glow 当前技术合同 -> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 +> 文档状态:当前实现与发布契约,更新于 2026-07-20。候选路线、原型过程和旧性能数据见 [LED Glow 原型评估](glow-prototype-evaluation.md),不作为当前运行时事实。 -本文记录 `AtomUI.Labs.Led` 家族真实静态 Glow 的技术路线。候选路线、评估过程和迁移前参考实现的最终决议均保留在本文中;对应运行时代码现已迁入本仓库。 +## 当前结论 -Glow是Matrix与Segment之上的可选视觉增强层。两个基础控件在没有Glow时必须保持完整、可生产使用。Glow只接收已经生成的Active Geometry,不读取字符、字模、点阵行列或SegmentParts。 +Segment 与 Matrix 使用同一个内部 `Glow/LedGlowRenderer.cs`。正式路线是 Avalonia 公共 API `DrawingContext.PushEffect(BlurEffect, bounds)`:控件先生成并剔除可见 Active Geometry,在一个局部 Effect 作用域内用 `GlowBrush` 绘制一次,再退出作用域绘制清晰 Active 本体。 -```text -Matrix Active Geometry ─┐ - ├─> LED Glow增强层 -> Active本体绘制 -Segment Active Geometry ─┘ -``` - -Segment当前的`GlowBrush`和`GlowOpacity`只是使用相同Geometry做一次透明叠色,没有向Geometry外部扩散。该实现是历史现状,不视为本文定义的真实空间Glow。 - -## 第一版契约约束 - -候选公共属性严格限制为: - -| 属性 | 类型 | 默认值 | 语义 | -|---|---|---:|---| -| `GlowBrush` | `IBrush?` | `null` | 光晕画刷;`null`关闭Glow | -| `GlowOpacity` | `double` | `0.35` | 光晕强度,有效范围`0..1` | -| `GlowRadius` | `double` | `6` DIP | 光晕在局部绘制坐标中的扩散范围;有效范围`0..24` DIP | - -不增加`GlowEnabled`、`GlowColor`、`GlowIntensity`、`GlowBlurRadius`、`GlowSpread`、`GlowOffsetX/Y`、`GlowLayerCount`、`GlowQuality`或专用动画属性。动画由Avalonia Animation驱动上述静态StyledProperty。 - -`GlowRadius`第一版使用固定安全范围: - -```text -负数、NaN、正负Infinity -> 0 -0..24 -> 保持原值 -大于24 -> 内部按24处理 -``` - -规整只作用于内部有效值,不回写开发者设置的StyledProperty,避免破坏Binding和属性优先级。Matrix与Segment必须使用完全相同的范围和规则。视觉语义建议为:`0`关闭、`2`轻微柔光、`6`默认、`12`明显、`24`第一版强Glow上限。 - -Radius上限只限制Geometry外扩,不能限制超大字符本体产生的Mask。因此正式实现仍必须同时定义单个离屏缓冲区物理像素上限和单控件Glow缓存总字节预算。 - -路线B单个离屏Glow Mask使用以下内部安全上限: - -```text -单边最大值:1024物理像素 -总面积上限:262144物理像素(等价于512 x 512) -``` - -请求尺寸超限时只降低Glow Mask分辨率,Active Geometry本体仍按正常RenderScaling清晰绘制。降采样比例为: - -```text -scaleByWidth = 1024 / requestedWidth -scaleByHeight = 1024 / requestedHeight -scaleByArea = sqrt(262144 / requestedArea) -maskScale = min(1, scaleByWidth, scaleByHeight, scaleByArea) -``` - -降采样后必须保持逻辑Glow Bounds和Radius语义不变。若极端输入或后端错误导致仍无法安全创建资源,只跳过该Glow绘制,基础Active Geometry必须继续正常显示;第一版不回退到路线A,避免同一属性在不同尺寸下静默切换视觉算法。 - -降采样或跳过只能按控件和同类配置记录一次诊断,禁止每帧刷日志。诊断至少包含请求/实际物理尺寸、RenderScaling、Content Scale、GlowRadius和降级结果。不公开`GlowQuality`,分辨率控制属于内部资源安全策略。 - -路线B单个控件的Glow派生资源缓存初始总预算固定为`16 MiB`。预算统计所有长期保留的Alpha Mask、Blurred Mask、后端纹理/Image和缓存项专属辅助缓冲,不得只统计托管对象或缓存项数量。该数值是内部安全预算,不公开为开发者属性;原型数据证明不合理时必须保留原始数据并形成书面调整决议。 - -缓存淘汰使用LRU,但当前帧可见的不同字符级Glow Source组成帧内Pin工作集,绘制当前帧时不得被淘汰。每帧先去重并估算工作集字节;工作集超过16 MiB时,对该帧全部Glow Mask统一降低分辨率,禁止同一画面混用无依据的高低质量。达到内部最低Mask比例后仍超限时,跳过无法安全容纳的Glow并继续绘制Active本体,禁止进入每帧反复构建和淘汰的缓存抖动路径。 - -`GlowBrush=null`表示明确关闭,必须立即释放全部Glow缓存并令CachedBytes和EntryCount归零。`GlowOpacity=0`只跳过合成并保留缓存,保证呼吸动画经过零点时不触发重建。 - -Glow不参与Measure。开发者通过Padding预留完整显示空间;光晕允许进入Padding,但Matrix裁剪在Border内缘,Segment裁剪在控件Bounds。绘制层次固定为: - -```text -Background - -> 全部Inactive Geometry - -> 全部Active Geometry的Glow - -> 全部Active Geometry本体 - -> Matrix Border -``` - -## Matrix与Segment的共享边界 - -Matrix与Segment不仅要保持Glow语义一致,还必须共享同一套Glow核心算法。不得在两个控件中分别复制Mask、Blur、着色、缓存和资源释放实现。 - -```text -MatrixDisplay - -> Matrix字符级Active Geometry适配 ─┐ - ├─> LedGlowRenderer / LedGlowCache -SegmentDisplay │ - -> Segment活跃段Geometry集合适配 ───┘ -``` - -分层职责固定为: - -| 层 | Matrix | Segment | 是否共享 | -|---|---|---|---| -| 公共StyledProperty | 分别注册`GlowBrush/GlowOpacity/GlowRadius` | 分别注册同名、同类型、同默认值属性 | 共享契约,不共享属性所有者 | -| 基础显示 | 字模映射、点阵布局、点Geometry | 段位映射、Segment布局、段Geometry | 不共享 | -| 输入适配 | 提供聚合后的字符Active Geometry | 提供一个字符的活跃段Geometry集合 | 各自实现薄适配 | -| Glow核心 | Mask、Blur、Brush着色、Opacity合成、Radius和DPI处理 | 使用完全相同实现 | 真实共享 | -| 派生资源 | 字符级Glow缓存 | 字符级Glow缓存 | 共享缓存实现,实例分别拥有 | - -不建立 `LedGlowControl` 公共控件基类,不让 Matrix 通过 `SegmentDisplay.GlowBrushProperty.AddOwner` 依赖 Segment,也不为了 Glow 合并字符映射、布局、Overflow 或基础 Geometry 缓存。分别注册 StyledProperty 是为了保持控件所有权边界,不代表允许复制 Glow 算法。 - -最终内部结构允许类似: - -```text -src/AtomUI.Labs.Led/ - Glow/ - LedGlowRenderOptions - LedGlowRenderer - LedGlowCache - 选定路线的Mask/Blur实现 - Matrix/ - Matrix自己的基础显示和Glow输入适配 - Segment/ - Segment自己的基础显示和Glow输入适配 -``` - -Segment现有同形叠色路径升级为真实空间Glow后必须删除,不保留`LegacyGlowMode`、`UseOldGlow`或两套并行算法。默认`GlowBrush=null`保证未启用Glow的基础视觉不变;已显式启用Glow的Segment获得与Matrix相同的真实外围光晕语义。 - -## 路线A:多层矢量扩张 - -围绕Active Geometry绘制多层不同宽度和透明度的描边,使用离本体越远越透明的层模拟光晕。 +当前实现没有应用层 Alpha Mask、离屏位图、LRU、字符级 Glow 缓存、`MaskBuildCount` 或 `BlurBuildCount`。这些概念只属于被否决或未采用的历史候选路线。 ```text -Active Geometry - -> 宽描边、低透明度 - -> 中描边、中低透明度 - -> 窄描边、较高透明度 - -> Active本体 +Background / Inactive + -> 已剔除的可见 Active Geometry + -> PushOpacity(GlowOpacity) + -> PushEffect(共享 BlurEffect, Active Bounds) + -> GlowBrush 绘制 Active Geometry + -> 退出 Effect + -> ActiveBrush 绘制清晰本体 + -> Border(Matrix) ``` -### 优势 +Glow 是可选视觉增强,不参与字符映射、字模、SegmentParts、Measure 或 DesiredSize。关闭 Glow 后,两个基础控件必须保持完整可用。 -- 直接消费Avalonia Geometry,不需要栅格化或离屏位图。 -- 可仅使用Avalonia公共矢量绘制API,跨渲染后端和NativeAOT风险较低。 -- 裁剪、Transform和Brush语义与现有Matrix/Segment绘制路径一致。 -- Geometry和Pen可按Radius档位缓存,生命周期容易限定在控件实例或Glow渲染器实例。 -- Headless测试可以检查绘制命令、像素外扩范围和缓存上界。 +## 公共属性 -### 劣势 +Segment 与 Matrix 分别注册同名、同类型、同默认值的 StyledProperty: -- 多层描边是离散近似,不是真正连续高斯模糊;层数不足时可能出现色带。 -- 增加层数会线性增加绘制命令和合成成本。 -- 大Radius或高Opacity下容易呈现粗轮廓,而不是柔和空气光。 -- 描边扩张可能填平Segment尖角,并在Matrix相邻灯珠之间过早连成一片。 -- Geometry为填充区域而非单一路径时,需要确认描边对内孔、组合Geometry和自交路径的行为。 - -### 定位 - -路线A是低风险保底方案,适合较小Radius、克制的工业视觉和无法安全使用离屏模糊的后端。它不能在没有视觉证据时被描述为与真实高斯Glow等价。 - -## 路线B:Alpha Mask加模糊 - -路线B先把Active Geometry栅格化为只记录透明度的遮罩,再模糊遮罩、使用GlowBrush着色并合成到主DrawingContext。 - -```text -Active Geometry - -> 栅格化到透明离屏缓冲区 - -> Alpha Mask - -> Blur(GlowRadius) - -> 乘以GlowBrush和GlowOpacity - -> 合成模糊光晕 - -> 绘制清晰Active本体 -``` - -Alpha Mask只表达发光源覆盖率,不提前写入Glow颜色。因此同一份轮廓可以使用不同GlowBrush着色,Brush和Geometry职责保持分离。 - -```text -原始Mask 模糊后的Mask - - █████ ······· - █████████ ··░░░░░░░░░·· - █████████ ·░░▒▒█████▒▒░░· - █████ ······· -``` - -### 优势 - -- 透明度连续衰减,最接近网页Neon、真实灯珠和柔和空气光。 -- 算法只依赖Alpha轮廓,不关心输入是Circle、Square、RoundedSquare还是十四段Geometry。 -- GlowBrush、GlowOpacity和GlowRadius三项契约都能获得直接、可解释的视觉含义。 -- 合理实现后可由渲染后端加速模糊与合成。 -- 不需要通过公开LayerCount或Quality暴露算法内部细节。 - -### 劣势 - -- 必须管理离屏像素缓冲区。缓冲区大致为`Geometry.Bounds + 四周GlowRadius`,物理像素还要乘以RenderScaling。 -- 内存、栅格化和模糊成本同时受可见面积、DPI和Radius影响;Radius动画可能导致缓冲尺寸或模糊核持续变化。 -- 不能把超长Matrix文本整行渲染到一张巨大位图。必须按可见字模或有限批次处理,并给Matrix字符剔除范围增加Radius外扩。 -- 缓存键至少涉及Geometry身份或版本、Radius、RenderScaling和可能影响Mask的Transform;失效和释放比路线A复杂。 -- Brush变化原则上应复用Alpha Mask,但若底层API把着色与模糊绑定在一起,可能无法做到。 -- Headless、不同平台后端和NativeAOT发布都需要真实验证,不能只依靠桌面Skia肉眼效果。 - -### 必须证明的工程条件 - -- 使用公开且稳定的Avalonia API完成局部Alpha Mask和Blur,或者明确记录所需后端边界。 -- Glow关闭后不创建离屏缓冲、不增加绘制命令或持续分配。 -- 静态Radius稳态帧不反复创建大型位图;缓存有固定上界且旧资源可释放。 -- Matrix按可见字模或有限批次处理,Segment按可见字符/Geometry集合处理。 -- Glow只作用于Active层,Background、Inactive和Border不得进入Mask。 - -### 定位 - -路线B是当前视觉质量首选。只有在公开API、局部缓冲、稳态分配和跨平台验证全部通过后才能成为正式方案;不能只因为效果最好而忽略资源成本。 - -## 路线C:Avalonia Effect或Skia自定义效果 - -路线C优先评估Avalonia现有`BlurEffect`、`DropShadowEffect`等后端效果。Avalonia 12公开`DrawingContext.PushEffect(IEffect, Rect)`,可以把Effect限制在一组Active Geometry绘制命令内,并按Effect输出Padding扩张给定的预膨胀Bounds;该能力必须通过原型证明实际像素隔离。公开Effect仍无法满足时,再评估`ICustomDrawOperation`或Skia自定义绘制。 - -### 优势 - -- 可能直接使用Avalonia渲染后端或GPU完成模糊与合成。 -- 模糊质量和大Radius性能可能优于应用层多次矢量绘制。 -- 若公开Effect能够作用于独立Active视觉层,业务代码可以较少。 - -### 劣势 - -- 直接设置`Visual.Effect`仍会把MatrixDisplay或SegmentDisplay的Background、Inactive、Active和Border一起处理,不符合Glow契约;必须使用绘制作用域隔离Active层。 -- 为隔离Active层而新增子Visual、离屏Visual或模板层,会改变当前自绘控件结构、生命周期和命令组织。 -- Skia自定义路径绑定具体渲染后端,削弱Avalonia跨平台后端边界。 -- 自定义绘制需要处理渲染线程资源、相等性、失效、设备上下文变化和释放,测试与维护成本最高。 -- Headless实现与真实Skia/GPU表现可能不同;NativeAOT和平台发布风险也更高。 - -### 定位 - -Avalonia公开`PushEffect`原型已经证明可在不新增子Visual的情况下产生Geometry外像素;是否成为正式方案仍需通过图层隔离、Brush、裁剪、真实窗口、缓存和完整性能门禁。Skia自定义实现是最后备选,不作为第一版优先路线。 - -## 统一评估矩阵 - -三个原型必须使用相同输入和指标: - -| 维度 | 验收要求 | -|---|---| -| 视觉真实性 | 光晕必须扩散到Geometry外,不能只是同形叠色 | -| 输入覆盖 | Matrix三种点形、Segment典型横段/竖段/斜段/符号 | -| 图层隔离 | Background、Inactive、Border不参与Glow | -| 裁剪 | Glow进入Padding但不越过外壳边界 | -| 布局 | GlowRadius不影响Measure和DesiredSize | -| DPI | 100%、125%、150%、200%下无明显断层或异常裁剪 | -| 动态 | GlowOpacity和GlowRadius变化不破坏Geometry基础缓存 | -| 性能 | 记录首次构建、稳态帧分配、命令数、缓冲区尺寸和Radius动画成本 | -| 缓存 | 历史文本、Radius和DPI变化不导致无界增长,旧资源可回收/释放 | -| 长文本 | Matrix不能创建整行无上限离屏缓冲;可见性剔除包含GlowRadius | -| 发布 | Labs测试、Sample、性能工具和win-x64 NativeAOT通过 | - -## 性能契约与测试强度 - -Glow选型不得凭单次肉眼观察或单次Benchmark结果通过。验证分为PR门禁、专项性能审计和发布前长稳三层;每个Case必须对应明确风险,禁止用重复但无判定价值的测试数量制造虚假覆盖。 - -### 硬性性能契约 - -Glow关闭时: - -- `GlowBrush=null`不得创建Glow Renderer后端资源、Mask、Blur结果或Glow缓存。 -- 不增加Glow绘制命令,不查询Glow缓存,不启动计时器或订阅事件。 -- 稳态托管分配必须与当前无Glow基线相同;相同场景中位耗时不得超过基线`1.05x`。 - -静态Glow预热后: - -- 重复Render的Mask和Blur新增数必须为0。 -- Brush或Opacity变化不得重建Geometry、Mask或Blur结果。 -- 缓存数量不得超过当前实例出现过的不同字符级Active Geometry数量。 -- 重复字符只允许复用同一份字符级Glow资源。 -- 单控件长期保留资源不得超过16 MiB;缓存必须同时报告EntryCount和CachedBytes。 - -动画时: - -- Opacity动画不得重建Geometry、Mask或Blur,缓存数量全程不变。 -- Radius动画允许重建Blur派生资源,但只允许保留当前配置的一代缓存;历史Radius不得累计。 -- 动画停止并强制完整回收后,存活托管内存和后端资源数量不得呈持续增长趋势。 - -缓冲区与长文本: - -- 单个离屏缓冲区只能覆盖单字符或明确有上界的有限批次,物理尺寸按`(CharacterBounds + 2 * GlowRadius) * RenderScaling * ContentScale`核算。 -- 单边不得超过1024物理像素,单Mask不得超过262144物理像素;超限时按统一公式降采样Glow Mask。 -- 禁止按完整长文本宽度创建无上限离屏缓冲。 -- Matrix必须先执行包含GlowRadius外扩的可见字符剔除,再进入Mask、Blur和合成。 - -### 第一层:PR确定性门禁 - -该层进入常规Labs测试,要求快速、可重复,不使用墙钟耗时作为断言。 - -数值边界Case: - -- `GlowOpacity`覆盖`NaN`、正负Infinity、负数、0、接近0、0.35、接近1、1和大于1,至少10组。 -- `GlowRadius`覆盖`NaN`、正负Infinity、负数、0、亚像素值、2、6、12、24、刚超过24和极大有限值,至少12组;验证大于24时内部有效值固定为24。 -- 原始StyledProperty值不得因内部规整被回写。 - -Brush Case: - -- `null`、不透明SolidColorBrush、带Alpha的SolidColorBrush、LinearGradientBrush、RadialGradientBrush、Brush实例替换和DynamicResource更新,至少7组。 -- Brush与Opacity变化必须通过计数器证明Mask/Blur未重建。 - -Matrix像素Case: - -- 3种DotShape × 4种RenderScaling × 4种Radius × 亮点稀疏/密集两类代表字模,共至少96组。 -- 另测无Border、有Border、Padding不足、Padding充足、Clip、ScaleDown、左中右及上中下边界组合。 -- Glow像素必须出现在Active Geometry外部,Background、Inactive和Border像素不得被模糊。 - -Segment像素Case: - -- 横段、竖段、斜段、交汇段、冒号、小数点至少6类 × 4种RenderScaling × 4种Radius,共至少96组。 -- 验证字符级Glow一次处理全部活跃段,不能退化为逐段Blur。 - -缓存与生命周期Case: - -- 重复字模、全支持字模、历史文本、Geometry参数抖动、Radius抖动、RenderScaling切换和Content Scale切换。 -- 每项参数抖动至少1000次;每轮后断言缓存上界和当前代资源数量。 -- LRU验收覆盖命中、淘汰顺序、帧内Pin、工作集统一降采样、最低比例超限和Brush关闭清空。 -- Geometry失效、`GlowBrush=null`、Detach和控件失去引用分别执行WeakReference/显式资源释放验收。 +| 属性 | 类型 | 默认值 | 内部有效值 | +|---|---|---:|---| +| `GlowBrush` | `IBrush?` | `null` | `null` 表示硬关闭 | +| `GlowOpacity` | `double` | `0.35` | 非有限值回退默认值,之后限制到 `0..1` | +| `GlowRadius` | `double` | `6` | 非有限值回退默认值,之后限制到 `0..24` DIP | -### 第二层:专项性能审计 +规整只影响内部有效值,不回写 StyledProperty。当前不公开 `GlowEnabled`、Offset、Spread、Quality、RenderMode、LayerCount 或专用动画属性。 -使用Labs性能工具独立运行,不放入普通PR单元测试时长预算。三条技术路线必须使用相同输入、相同进程配置和相同预热策略。 +## 绘制与资源语义 -场景矩阵: +- `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 为完整光晕预留空间。 -- 字符数量:6、16、64、256。 -- RenderScaling:100%、125%、150%、200%。 -- GlowRadius:2、6、12、24。 -- Matrix:Circle、Square、RoundedSquare,分别测试仅亮点和亮暗双层。 -- Segment:数字、字母、符号混合,覆盖低活跃段和高活跃段字符。 -- 状态:Glow关闭、静态Glow、Opacity动画、Radius动画、动态文本。 +## 失效与生命周期 -每个静态场景至少预热600帧,再测量6000帧。每组Benchmark至少使用5个独立进程;报告Median、P95、Allocated Bytes、Gen0/1/2、MaskBuildCount、BlurBuildCount、绘制命令数、离屏物理像素总量和最终缓存数,不得只报告平均值。 +Glow 属性只触发重绘,不触发 Measure。`GlowBrush` 变为 `null` 时立即丢弃 Renderer;控件不可达后 Renderer 和 Effect 随实例回收。Glow 不创建计时器、不订阅事件,也不拥有动画生命周期。动画由 Avalonia Animation 修改现有 StyledProperty。 -初始时间目标: +## 自动化验证 -- 16字符、200% RenderScaling、Radius=6的静态Glow,真实窗口CPU侧Glow处理P95目标不超过4ms。 -- 64字符、200% RenderScaling、Radius=6的压力场景,整帧P95目标不超过16.67ms。 -- Glow关闭路径相对无Glow基线中位耗时不得超过`1.05x`,分配必须相同。 +必须覆盖: -这些是原型选型门槛。若测试环境证明指标不可比或目标不合理,必须保留原始数据、解释测量偏差并形成新的书面决议;不得静默放宽。 +- 三项公共属性的名称、类型、默认值、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 和一次清晰本体绘制。 -### 第三层:发布前长稳 +## 性能门禁 -- 静态Glow:固定6、16、64字符分别运行100000帧,预热后Mask/Blur新增数必须为0。 -- Opacity呼吸:至少36000帧,Mask/Blur新增数必须为0,缓存数量恒定。 -- Radius往返动画:至少36000帧,缓存始终只有当前配置代,旧后端资源及时释放。 -- 动态文本:至少100000次固定长度更新和10000次长度/拓扑变化,缓存不得按历史槽位增长。 -- DPI/Scale切换:100%、125%、150%、200%往返至少1000轮,旧配置资源不得存活累积。 -- 多实例压力:1、10、50个Glow控件分别覆盖关闭、重复字模和不同字模;关闭Glow的50个控件必须保持零Glow缓存,移除控件后实例资源必须释放。 -- 真实窗口连续运行至少30分钟,采集进程工作集、托管堆、Gen2次数、帧时间P95/P99和后端资源计数;内存曲线不得持续单调增长。 -- 长稳结束后停止动画、Detach控件、释放窗口并强制完整回收;控件、Glow缓存和可释放后端资源必须通过生命周期验收。 -- 离屏安全测试覆盖刚低于、等于、刚超过面积上限,单边超限、双边与面积同时超限、极端CharacterHeight、100%/200% DPI以及ScaleDown重新进入安全范围。 -- 连续1000次跨越降采样阈值时,逻辑Glow Bounds保持一致、Active像素不受影响且旧Mask不累积。 +`tools/performances/AtomUI.Labs.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 低于基线不视为回归。 -- Glow关闭路径产生额外Mask、Blur、命令、订阅或持续分配。 -- Opacity动画触发Mask或Blur重建。 -- 缓存随历史文本、Radius、DPI或动画帧数无界增长。 -- Background、Inactive或Border被错误纳入Glow。 -- 依赖AtomUI成型控件包、非公开Avalonia API或未被明确批准的Skia后端耦合。 -- Headless通过但真实窗口出现裁剪、DPI断层、资源泄漏或无法满足性能门槛。 -- NativeAOT新增未解释的动态代码、反射或裁剪告警。 +发布级门禁: -## 最终选型决议 +- 使用 Release 构建,至少运行 5 个独立进程。 +- 报告耗时倍率和分配差异的 Median,并保留每个进程的原始结果。 +- Median 必须满足上述耗时和分配阈值;单个进程的偶发超限要记录,但不替代 Median 判定。 +- 额外执行 100000 帧静态/动态长稳、固定长度文本更新、拓扑变化和完整回收检查;缓存、Effect 数和存活内存不得随历史帧数或文本无界增长。 -```text -正式路线:路线C,Avalonia公开Scoped BlurEffect -Effect粒度:每个控件一次 -路线A:仅保留实验基线,不进入正式运行时 -路线B:停止,不进入正式运行时 -Skia自定义:未启动,不进入正式运行时 -``` +## 发布失败条件 -最终实现使用`DrawingContext.PushEffect(BlurEffect, bounds)`隔离全部可见Active Geometry。Matrix与Segment分别完成基础Geometry和可见性剔除,共享同一个内部`LedGlowRenderer`,每个控件每帧最多建立一个Glow Effect作用域。相邻Active Geometry的Glow允许自然融合,清晰Active本体在Effect作用域退出后重新绘制。 +以下任一情况阻止发布: -路线B章节中的Alpha Mask、LRU、16 MiB派生缓存、单Mask尺寸和降采样公式只记录被评估路线的工程要求,不再是正式路线C的实现契约。正式路线不得为了机械满足路线B要求而创建应用层Mask或Glow缓存。路线C仍必须执行以下资源安全约束:Effect Bounds只来自已剔除的可见Active Geometry并受控件内容视口限制;异常或空Bounds跳过Glow但保留Active本体;Glow关闭不提交Effect命令、不创建后端资源。 +- Glow 关闭仍提交 Effect、创建后端 Effect 或增加 Glow 绘制命令。 +- 静态 Glow 在稳态帧重复创建 Effect。 +- 一个控件一帧建立多个逐点、逐段或逐字模 Effect scope。 +- Background、Inactive 或 Border 被纳入 Glow。 +- Glow 属性变化破坏基础 Geometry 缓存或影响 DesiredSize。 +- Release 五进程 Median 超过正式耗时或分配阈值。 -不实现`PerGeometry`、Batch8或Batch16运行时分支,不增加`GlowQuality`、`GlowRenderMode`或粒度配置。不得根据字符数量、帧率或全局负载自动切换算法或关闭Glow。 +## 历史路线边界 -首轮原型事实和Smoke数据见 [glow-prototype-evaluation.md](glow-prototype-evaluation.md)。 +多层矢量扩张、应用层 Alpha Mask、离屏 Blur、LRU 和 Skia 自定义绘制曾作为候选方案评估。它们的工程约束和原型证据保留在 [LED Glow 原型评估](glow-prototype-evaluation.md) 及带日期的性能文档中。除非形成新的书面技术决议,否则不得把这些历史设计描述为当前实现,也不得为满足旧路线指标而在运行时引入 Mask 或缓存系统。 From de980cdfa5019f748d2f0700efb6c51bc6c7ca86 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 18:02:52 +0800 Subject: [PATCH 21/33] update Led overview doc --- docs/controls/led/overview.md | 33 ++++++++++++++++++----------- src/AtomUI.Labs.Led/README.nuget.md | 2 +- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/docs/controls/led/overview.md b/docs/controls/led/overview.md index 6209493..121c544 100644 --- a/docs/controls/led/overview.md +++ b/docs/controls/led/overview.md @@ -1,15 +1,15 @@ # LED 控件家族设计 -> 文档状态:已实现。本文于 2026-07-20 随 LED 控件从 AtomUI 迁入 AtomUI.Labs,并已按本仓库的包名、目录和验证入口完成适配。历史性能数值仍表示迁移时的基线,后续变更应在本仓库重新验证。 +> 文档状态:当前架构与公共契约,更新于 2026-07-20。历史审计、原型和性能数据在导航中单独标识,不作为当前实现事实。 本文记录 `AtomUI.Labs.Led` 的组件域设计。LED 是 Labs 下的实验控件家族名,不是单一控件名。 ## 文档导航 - Segment:[实现原理](segment-implementation.md)、[性能回归矩阵](segment-performance-regression.md)。 -- Matrix:[实现原理](matrix-implementation.md)、[静态视觉系统](matrix-static-visual-system.md)、[Marquee 最小契约](matrix-marquee-minimum-contract.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)。 -- 家族增强与边界:[公共边界审计](family-common-boundary-audit.md)、[Glow 技术选型](glow-technical-options.md)、[Glow 原型评估](glow-prototype-evaluation.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)是历史证据。 ## 定位 @@ -99,9 +99,8 @@ Text Matrix 需要额外考虑: -- 第一版固定 `5x7` 字模规格,不开放任意行列配置。 -- 第一版固定圆点,不开放点形状切换。 -- 第二阶段在相同点位外接框内增加Circle、Square和RoundedSquare静态轮廓,不改变字模和布局。 +- 当前固定 `5x7` 字模规格,不开放任意行列配置。 +- 当前在相同点位外接框内支持 Circle、Square 和 RoundedSquare 静态轮廓,不改变字模和布局。 - 点尺寸、点间距、字符间距、Padding 和内容对齐。 - 小空间下的裁剪和显式等比缩小。 @@ -194,11 +193,11 @@ LED 家族不得使用 AtomUI 已经成型的控件包: ## 非目标 -第一阶段不处理: +当前不处理: - 真实硬件 LED 控制。 - 中文、复杂脚本或富文本排版。 -- 动态滚动、闪烁、故障动画等效果。 +- Segment 内建动画,以及 Matrix 单向穿屏之外的滚动、闪烁或故障动画。 - 把 `Segment` 和 `Matrix` 合并为一个万能控件。 - 提前固定 LED 家族公共代码目录名。 @@ -207,14 +206,24 @@ LED 家族不得使用 AtomUI 已经成型的控件包: 实现阶段至少需要验证: - Labs 项目构建通过。 -- Labs Sample 构建通过。 +- `controlgallery/AtomUILabsGallery.Desktop` 构建和 `win-x64` NativeAOT 发布通过。 - 搜索确认 LED 家族没有引用 AtomUI 成型控件包。 -- Sample 能展示 Segment 和 Matrix 的基础视觉。 +- 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):记录多层矢量扩张、Alpha Mask模糊和Avalonia/Skia Effect三条候选路线。 +- [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/src/AtomUI.Labs.Led/README.nuget.md b/src/AtomUI.Labs.Led/README.nuget.md index 763318a..bb25e78 100644 --- a/src/AtomUI.Labs.Led/README.nuget.md +++ b/src/AtomUI.Labs.Led/README.nuget.md @@ -1,4 +1,4 @@ -## AtomUI Labs Led +## AtomUI Labs LED `AtomUI.Labs.Led` provides experimental LED-style display controls for AtomUI applications: From 48451d33fb8baa86737ee59ca2ffa4d79871f77d Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 18:05:05 +0800 Subject: [PATCH 22/33] optimize Led.Segment --- .../Segment/Layout/SegmentLayoutEngine.cs | 15 ++---- src/AtomUI.Labs.Led/Segment/SegmentDisplay.cs | 52 ++++++++++--------- .../Segment/SegmentValueSanitizer.cs | 15 +++++- 3 files changed, 44 insertions(+), 38 deletions(-) diff --git a/src/AtomUI.Labs.Led/Segment/Layout/SegmentLayoutEngine.cs b/src/AtomUI.Labs.Led/Segment/Layout/SegmentLayoutEngine.cs index 3f62f82..327c2d9 100644 --- a/src/AtomUI.Labs.Led/Segment/Layout/SegmentLayoutEngine.cs +++ b/src/AtomUI.Labs.Led/Segment/Layout/SegmentLayoutEngine.cs @@ -10,8 +10,7 @@ internal static class SegmentLayoutEngine public static SegmentDisplayLayout Calculate( string? text, - SegmentLayoutOptions options, - Size? finalSize = null) + SegmentLayoutOptions options) { var patterns = BuildPatterns(text); if (patterns.Count == 0) @@ -24,16 +23,8 @@ public static SegmentDisplayLayout Calculate( var padding = SegmentValueSanitizer.CoerceThickness(options.Padding); var characterHeight = SegmentValueSanitizer.CoerceNonNegative(options.CharacterHeight); - if (finalSize.HasValue && SegmentValueSanitizer.IsFinite(finalSize.Value.Height)) - { - var constrainedHeight = SegmentValueSanitizer.CoerceNonNegative(finalSize.Value.Height - padding.Top - padding.Bottom); - if (constrainedHeight > 0) - { - characterHeight = constrainedHeight; - } - } - - var characterWidth = characterHeight * SegmentValueSanitizer.CoerceAtLeast(options.CharacterAspectRatio, 0.1); + 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); diff --git a/src/AtomUI.Labs.Led/Segment/SegmentDisplay.cs b/src/AtomUI.Labs.Led/Segment/SegmentDisplay.cs index a14552a..4749894 100644 --- a/src/AtomUI.Labs.Led/Segment/SegmentDisplay.cs +++ b/src/AtomUI.Labs.Led/Segment/SegmentDisplay.cs @@ -260,7 +260,7 @@ static SegmentDisplay() protected override Size MeasureOverride(Size availableSize) { - var layout = GetLayout(null); + var layout = GetLayout(); return layout.DesiredSize; } @@ -301,10 +301,13 @@ public override void Render(DrawingContext context) -offset.Y / scale, Bounds.Width / scale, Bounds.Height / scale); - var effectiveGlowRadius = GetEffectiveGlowRadius(); - if (effectiveGlowRadius > 0) + 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(effectiveGlowRadius); + visibleBounds = visibleBounds.Inflate(glowRadius); } var firstVisibleIndex = FindFirstVisibleSlot(layout, visibleBounds.Left); @@ -328,11 +331,16 @@ public override void Render(DrawingContext context) if (visibleGeometry.ActiveGeometry is { } activeGeometry) { - using (var glowScope = PushGlow(context, activeGeometry.Bounds)) + using (var glowScope = PushGlow( + context, + activeGeometry.Bounds, + glowBrush, + glowOpacity, + glowRadius)) { if (glowScope.IsActive) { - context.DrawGeometry(GlowBrush, null, activeGeometry); + context.DrawGeometry(glowBrush, null, activeGeometry); } } @@ -375,15 +383,15 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang } } - private SegmentDisplayLayout GetLayout(Size? finalSize) + private SegmentDisplayLayout GetLayout() { - var key = new SegmentLayoutCacheKey(Text, GetLayoutOptions(), finalSize); + var key = new SegmentLayoutCacheKey(Text, GetLayoutOptions()); if (_hasLayoutCache && _layoutCacheKey == key && _layoutCache is not null) { return _layoutCache; } - var layout = SegmentLayoutEngine.Calculate(key.Text, key.Options, key.FinalSize); + var layout = SegmentLayoutEngine.Calculate(key.Text, key.Options); _layoutCacheKey = key; _layoutCache = layout; _hasLayoutCache = true; @@ -393,7 +401,7 @@ private SegmentDisplayLayout GetLayout(Size? finalSize) private SegmentDisplayLayout GetPreparedLayout() { - return GetLayout(Bounds.Size); + return GetLayout(); } private IReadOnlyList GetPreparedSlots(SegmentDisplayLayout layout) @@ -441,7 +449,9 @@ private void RenderBackground(DrawingContext context) context.DrawRectangle( background, null, - new RoundedRect(new Rect(0, 0, Bounds.Width, Bounds.Height), CornerRadius)); + new RoundedRect( + new Rect(0, 0, Bounds.Width, Bounds.Height), + SegmentValueSanitizer.CoerceCornerRadius(CornerRadius))); } private SegmentLayoutOptions GetLayoutOptions() @@ -593,18 +603,13 @@ private void ClearVisibleGeometryCache() _visibleInactiveGeometryCache = null; } - private double GetEffectiveGlowRadius() + private LedGlowRenderScope PushGlow( + DrawingContext context, + Rect activeBounds, + IBrush? glowBrush, + double opacity, + double radius) { - return GlowBrush is not null && LedGlowValueSanitizer.CoerceOpacity(GlowOpacity) > 0 - ? LedGlowValueSanitizer.CoerceRadius(GlowRadius) - : 0; - } - - private LedGlowRenderScope PushGlow(DrawingContext context, Rect activeBounds) - { - var glowBrush = GlowBrush; - var opacity = LedGlowValueSanitizer.CoerceOpacity(GlowOpacity); - var radius = LedGlowValueSanitizer.CoerceRadius(GlowRadius); if (glowBrush is null || opacity <= 0 || radius <= 0) { return default; @@ -650,8 +655,7 @@ private static int FindLastVisibleSlot( private readonly record struct SegmentLayoutCacheKey( string? Text, - SegmentLayoutOptions Options, - Size? FinalSize); + SegmentLayoutOptions Options); private readonly record struct SegmentPreparedSlot( SegmentCharacterKind Kind, diff --git a/src/AtomUI.Labs.Led/Segment/SegmentValueSanitizer.cs b/src/AtomUI.Labs.Led/Segment/SegmentValueSanitizer.cs index 0bbafd7..9317ca8 100644 --- a/src/AtomUI.Labs.Led/Segment/SegmentValueSanitizer.cs +++ b/src/AtomUI.Labs.Led/Segment/SegmentValueSanitizer.cs @@ -4,14 +4,16 @@ namespace AtomUI.Labs.Led.Segment; internal static class SegmentValueSanitizer { + public const double MaximumLayoutValue = 1_000_000; + public static double CoerceNonNegative(double value) { - return IsFinite(value) ? Math.Max(0, value) : 0; + return IsFinite(value) ? Math.Clamp(value, 0, MaximumLayoutValue) : 0; } public static double CoerceAtLeast(double value, double minimum) { - return IsFinite(value) ? Math.Max(minimum, value) : minimum; + return IsFinite(value) ? Math.Clamp(value, minimum, MaximumLayoutValue) : minimum; } public static double CoerceRange(double value, double minimum, double maximum) @@ -33,6 +35,15 @@ public static Thickness CoerceThickness(Thickness thickness) 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); From 8b8878e9880c649878efa03537275a5d02ea7e65 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 18:05:31 +0800 Subject: [PATCH 23/33] update Led.Tests project --- tests/AtomUI.Labs.Led.Tests/AtomUI.Labs.Led.Tests.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/AtomUI.Labs.Led.Tests/AtomUI.Labs.Led.Tests.csproj b/tests/AtomUI.Labs.Led.Tests/AtomUI.Labs.Led.Tests.csproj index cdf7733..b81a282 100644 --- a/tests/AtomUI.Labs.Led.Tests/AtomUI.Labs.Led.Tests.csproj +++ b/tests/AtomUI.Labs.Led.Tests/AtomUI.Labs.Led.Tests.csproj @@ -15,5 +15,6 @@ + From 3f50cb5fecc0307aa91f4eb8fdfeae718a553b44 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 18:05:51 +0800 Subject: [PATCH 24/33] update main project --- controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj | 4 ++++ src/AtomUI.Labs.Led/AtomUI.Labs.Led.csproj | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj b/controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj index 6ec7118..e5df4e3 100644 --- a/controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj +++ b/controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj @@ -6,6 +6,10 @@ AtomUILabsGallery + + + + diff --git a/src/AtomUI.Labs.Led/AtomUI.Labs.Led.csproj b/src/AtomUI.Labs.Led/AtomUI.Labs.Led.csproj index e05e5be..b84d12d 100644 --- a/src/AtomUI.Labs.Led/AtomUI.Labs.Led.csproj +++ b/src/AtomUI.Labs.Led/AtomUI.Labs.Led.csproj @@ -2,7 +2,7 @@ $(AtomUITargetFrameworks) AtomUI.Labs.Led - AtomUI Labs Led Controls + 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 From 26c25e6e312c4fd15917f3cdfabc6d3eb145fd45 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 18:06:21 +0800 Subject: [PATCH 25/33] optimize Led performance tool --- .../Glow/FormalGlowPerformanceRunner.cs | 116 ++++++++++++++++-- 1 file changed, 103 insertions(+), 13 deletions(-) diff --git a/tools/performances/AtomUI.Labs.Led.Performance/Glow/FormalGlowPerformanceRunner.cs b/tools/performances/AtomUI.Labs.Led.Performance/Glow/FormalGlowPerformanceRunner.cs index c25015c..bbf4e51 100644 --- a/tools/performances/AtomUI.Labs.Led.Performance/Glow/FormalGlowPerformanceRunner.cs +++ b/tools/performances/AtomUI.Labs.Led.Performance/Glow/FormalGlowPerformanceRunner.cs @@ -12,6 +12,11 @@ namespace AtomUI.Labs.Led.Performance; internal static class FormalGlowPerformanceRunner { private const int WarmupFrames = 100; + private const int DisabledTimingTrialCount = 5; + private const double DisabledMaximumTimingRatio = 1.05; + private const double DisabledMaximumTimingDeltaMicroseconds = 0.25; + private const double DisabledMaximumAllocationRatio = 1.025; + private const double DisabledMaximumAllocationDeltaPerFrame = 4_096; private static DrawingGroup? _drawingSink; public static int Run(int frameCount, string? markdownOutputPath) @@ -29,18 +34,24 @@ public static int Run(int frameCount, string? markdownOutputPath) var disabledPairs = Enum.GetValues() .Select(kind => MeasureDisabledPair(kind, frameCount)) .ToArray(); + var disabledAllocations = Enum.GetValues() + .Select(kind => CreateDisabledAllocationResult(kind, results)) + .ToArray(); - var report = RenderReport(results, disabledPairs); + var report = RenderReport(results, disabledPairs, disabledAllocations); Console.WriteLine(report); if (!string.IsNullOrWhiteSpace(markdownOutputPath)) { var fullPath = Path.GetFullPath(markdownOutputPath); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); - File.WriteAllText(fullPath, RenderMarkdown(results, disabledPairs), new UTF8Encoding(false)); + File.WriteAllText( + fullPath, + RenderMarkdown(results, disabledPairs, disabledAllocations), + new UTF8Encoding(false)); Console.WriteLine($"Wrote formal Glow result: {fullPath}"); } - return Validate(results, disabledPairs) ? 0 : 1; + return Validate(results, disabledPairs, disabledAllocations) ? 0 : 1; } private static DisabledPairResult MeasureDisabledPair(FormalGlowControlKind kind, int frameCount) @@ -60,6 +71,21 @@ private static DisabledPairResult MeasureDisabledPair(FormalGlowControlKind kind _drawingSink = Render(i % 2 == 0 ? zeroOpacity : nullBrush); } + var trials = new DisabledPairResult[DisabledTimingTrialCount]; + for (var trial = 0; trial < trials.Length; trial++) + { + trials[trial] = MeasureDisabledPairTrial(kind, nullBrush, zeroOpacity, frameCount); + } + + return trials.OrderBy(result => result.ZeroToNullRatio).ElementAt(trials.Length / 2); + } + + private static DisabledPairResult MeasureDisabledPairTrial( + FormalGlowControlKind kind, + Control nullBrush, + Control zeroOpacity, + int frameCount) + { long nullTicks = 0; long zeroTicks = 0; const int batchSize = 16; @@ -280,18 +306,35 @@ private static void ForceFullCollection() private static bool Validate( IReadOnlyList results, - IReadOnlyList disabledPairs) + IReadOnlyList disabledPairs, + IReadOnlyList disabledAllocations) { return results.Where(result => result.Mode is FormalGlowMode.DisabledNullBrush or FormalGlowMode.DisabledZeroOpacity) .All(result => result.EffectBuilds == 0 && result.EffectScopes == 0) && results.Where(result => result.Mode == FormalGlowMode.Static) .All(result => result.EffectBuilds == 0 && result.EffectScopes == result.FrameCount) - && disabledPairs.All(result => result.SlowerToFasterRatio <= 1.05); + && disabledPairs.All(result => result.IsWithinBudget) + && disabledAllocations.All(result => result.IsWithinBudget); + } + + private static DisabledAllocationResult CreateDisabledAllocationResult( + FormalGlowControlKind controlKind, + IReadOnlyList results) + { + var nullBrush = results.Single(result => + result.Control == controlKind && result.Mode == FormalGlowMode.DisabledNullBrush); + var zeroOpacity = results.Single(result => + result.Control == controlKind && result.Mode == FormalGlowMode.DisabledZeroOpacity); + return new DisabledAllocationResult( + controlKind, + nullBrush.BytesPerFrame, + zeroOpacity.BytesPerFrame); } private static string RenderReport( IReadOnlyList results, - IReadOnlyList disabledPairs) + IReadOnlyList disabledPairs, + IReadOnlyList disabledAllocations) { var builder = new StringBuilder(); builder.AppendLine("Formal LED Glow performance (DrawingGroup command submission)"); @@ -303,11 +346,19 @@ private static string RenderReport( } builder.AppendLine("Disabled paired timing"); - builder.AppendLine("Control NullBrush us/frame ZeroOpacity us/frame slower/faster Gate"); + builder.AppendLine("Control NullBrush us/frame ZeroOpacity us/frame added us zero/null Gate"); foreach (var pair in disabledPairs) { builder.AppendLine(CultureInfo.InvariantCulture, - $"{pair.Control,-9}{pair.NullMicrosecondsPerFrame,19:0.00}{pair.ZeroMicrosecondsPerFrame,22:0.00}{pair.SlowerToFasterRatio,15:0.000} {(pair.SlowerToFasterRatio <= 1.05 ? "PASS" : "FAIL")}"); + $"{pair.Control,-9}{pair.NullMicrosecondsPerFrame,19:0.00}{pair.ZeroMicrosecondsPerFrame,22:0.00}{pair.AdditionalMicrosecondsPerFrame,10:0.00}{pair.ZeroToNullRatio,11:0.000} {(pair.IsWithinBudget ? "PASS" : "FAIL")}"); + } + + builder.AppendLine("Disabled allocation"); + builder.AppendLine("Control NullBrush bytes/frame ZeroOpacity bytes/frame added zero/null Gate"); + foreach (var allocation in disabledAllocations) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"{allocation.Control,-9}{allocation.NullBytesPerFrame,22:0.0}{allocation.ZeroBytesPerFrame,25:0.0}{allocation.AdditionalBytesPerFrame,8:0.0}{allocation.ZeroToNullRatio,11:0.000} {(allocation.IsWithinBudget ? "PASS" : "FAIL")}"); } return builder.ToString(); @@ -315,7 +366,8 @@ private static string RenderReport( private static string RenderMarkdown( IReadOnlyList results, - IReadOnlyList disabledPairs) + IReadOnlyList disabledPairs, + IReadOnlyList disabledAllocations) { var builder = new StringBuilder(); builder.AppendLine("# Formal LED Glow Performance"); @@ -331,12 +383,21 @@ private static string RenderMarkdown( } builder.AppendLine(); - builder.AppendLine("| Control | NullBrush us/frame | ZeroOpacity us/frame | slower/faster | Gate |"); - builder.AppendLine("|---|---:|---:|---:|---|"); + builder.AppendLine("| Control | NullBrush us/frame | ZeroOpacity us/frame | added us | zero/null | Gate |"); + builder.AppendLine("|---|---:|---:|---:|---:|---|"); foreach (var pair in disabledPairs) { builder.AppendLine(CultureInfo.InvariantCulture, - $"| {pair.Control} | {pair.NullMicrosecondsPerFrame:0.00} | {pair.ZeroMicrosecondsPerFrame:0.00} | {pair.SlowerToFasterRatio:0.000} | {(pair.SlowerToFasterRatio <= 1.05 ? "PASS" : "FAIL")} |"); + $"| {pair.Control} | {pair.NullMicrosecondsPerFrame:0.00} | {pair.ZeroMicrosecondsPerFrame:0.00} | {pair.AdditionalMicrosecondsPerFrame:0.00} | {pair.ZeroToNullRatio:0.000} | {(pair.IsWithinBudget ? "PASS" : "FAIL")} |"); + } + + builder.AppendLine(); + builder.AppendLine("| Control | NullBrush bytes/frame | ZeroOpacity bytes/frame | added | zero/null | Gate |"); + builder.AppendLine("|---|---:|---:|---:|---:|---|"); + foreach (var allocation in disabledAllocations) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"| {allocation.Control} | {allocation.NullBytesPerFrame:0.0} | {allocation.ZeroBytesPerFrame:0.0} | {allocation.AdditionalBytesPerFrame:0.0} | {allocation.ZeroToNullRatio:0.000} | {(allocation.IsWithinBudget ? "PASS" : "FAIL")} |"); } return builder.ToString(); @@ -382,6 +443,35 @@ private sealed record DisabledPairResult( public double ZeroMicrosecondsPerFrame => ZeroTicks * 1_000_000d / Stopwatch.Frequency / FrameCount; - public double SlowerToFasterRatio => Math.Max(NullTicks, ZeroTicks) / (double)Math.Min(NullTicks, ZeroTicks); + public double ZeroToNullRatio => ZeroTicks / (double)NullTicks; + + public double AdditionalMicrosecondsPerFrame => + Math.Max(0, ZeroMicrosecondsPerFrame - NullMicrosecondsPerFrame); + + public bool IsWithinBudget => + ZeroToNullRatio <= DisabledMaximumTimingRatio + || AdditionalMicrosecondsPerFrame <= DisabledMaximumTimingDeltaMicroseconds; + } + + private sealed record DisabledAllocationResult( + FormalGlowControlKind Control, + double NullBytesPerFrame, + double ZeroBytesPerFrame) + { + public double AdditionalBytesPerFrame => Math.Max(0, ZeroBytesPerFrame - NullBytesPerFrame); + + public double ZeroToNullRatio + { + get + { + return NullBytesPerFrame <= 0 + ? (AdditionalBytesPerFrame <= 0 ? 1 : double.PositiveInfinity) + : ZeroBytesPerFrame / NullBytesPerFrame; + } + } + + public bool IsWithinBudget => + AdditionalBytesPerFrame <= DisabledMaximumAllocationDeltaPerFrame + && ZeroToNullRatio <= DisabledMaximumAllocationRatio; } } From 125606953c8c830bf001fc88eeba523729a1caad Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 18:07:22 +0800 Subject: [PATCH 26/33] add finitely diagnose --- .../Marquee/LeftThroughMarqueeMotion.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/AtomUI.Labs.Led/Marquee/LeftThroughMarqueeMotion.cs b/src/AtomUI.Labs.Led/Marquee/LeftThroughMarqueeMotion.cs index 6c9479c..1eb42db 100644 --- a/src/AtomUI.Labs.Led/Marquee/LeftThroughMarqueeMotion.cs +++ b/src/AtomUI.Labs.Led/Marquee/LeftThroughMarqueeMotion.cs @@ -1,11 +1,18 @@ namespace AtomUI.Labs.Led.Marquee; -internal sealed class LeftThroughMarqueeMotion : IMarqueeMotion +internal static class LeftThroughMarqueeMotion { - public MarqueeRenderPlan Calculate(in MarqueeMotionContext context) + public static MarqueeRenderPlan Calculate(in MarqueeMotionContext context) { var progress = double.IsNaN(context.Progress) ? 0 : Math.Clamp(context.Progress, 0, 1); - var distance = Math.Max(0, context.ViewportWidth) + Math.Max(0, context.ContentWidth); - return new MarqueeRenderPlan(1, Math.Max(0, context.ViewportWidth) - distance * progress); + 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; } } From e93b178ae9fceeeb615c9e0ed35c27945593eb27 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 18:07:59 +0800 Subject: [PATCH 27/33] add IMarqueeMotion interface --- src/AtomUI.Labs.Led/Marquee/IMarqueeMotion.cs | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 src/AtomUI.Labs.Led/Marquee/IMarqueeMotion.cs diff --git a/src/AtomUI.Labs.Led/Marquee/IMarqueeMotion.cs b/src/AtomUI.Labs.Led/Marquee/IMarqueeMotion.cs deleted file mode 100644 index 838d87f..0000000 --- a/src/AtomUI.Labs.Led/Marquee/IMarqueeMotion.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace AtomUI.Labs.Led.Marquee; - -internal interface IMarqueeMotion -{ - MarqueeRenderPlan Calculate(in MarqueeMotionContext context); -} From 28e64b6f638c21fa96bd199c9bba4ecfc82207e2 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 18:08:48 +0800 Subject: [PATCH 28/33] Optimize Led.Matrix display --- src/AtomUI.Labs.Led/Matrix/MatrixDisplay.cs | 114 ++++++++++++++++---- 1 file changed, 91 insertions(+), 23 deletions(-) diff --git a/src/AtomUI.Labs.Led/Matrix/MatrixDisplay.cs b/src/AtomUI.Labs.Led/Matrix/MatrixDisplay.cs index aea455c..dc93909 100644 --- a/src/AtomUI.Labs.Led/Matrix/MatrixDisplay.cs +++ b/src/AtomUI.Labs.Led/Matrix/MatrixDisplay.cs @@ -262,14 +262,16 @@ internal double MarqueeProgress internal object? MarqueeController => _marqueeController; + internal int MarqueeVisibilitySubscriptionCount => _visibilityAncestors.Count; + #endregion private bool _hasLayoutCache; - private static readonly IMarqueeMotion MarqueeMotion = new LeftThroughMarqueeMotion(); 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(); @@ -337,12 +339,14 @@ 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); } @@ -407,6 +411,11 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang _glowRenderer = null; } + if (change.Property == IsMarqueeEnabledProperty) + { + UpdateAncestorVisibilitySubscriptions(); + } + if (change.Property == TextProperty || change.Property == DotSizeProperty || change.Property == DotSpacingProperty @@ -433,9 +442,10 @@ private void RenderContent(DrawingContext context) var layout = GetLayout(); var options = GetLayoutOptions(); var contentViewport = GetContentViewport(); - if (IsMarqueeEnabled && layout.Slots.Count > 0) + var glow = GetGlowRenderOptions(); + if (IsEffectiveMarqueeEnabled() && layout.Slots.Count > 0) { - RenderMarqueeContent(context, layout, options, contentViewport, activeBrush); + RenderMarqueeContent(context, layout, options, contentViewport, activeBrush, glow); return; } @@ -457,7 +467,7 @@ private void RenderContent(DrawingContext context) contentViewport.X + alignmentOffset.X, contentViewport.Y + alignmentOffset.Y); var visibleBounds = CalculateVisibleBounds(contentViewport, scale, offset); - var effectiveGlowRadius = GetEffectiveGlowRadius(); + var effectiveGlowRadius = glow.EffectiveRadius; if (effectiveGlowRadius > 0) { visibleBounds = visibleBounds.Inflate(effectiveGlowRadius); @@ -465,7 +475,7 @@ private void RenderContent(DrawingContext context) using (context.PushClip(contentViewport)) using (PushLayoutTransform(context, scale, offset)) { - RenderVisibleGlyphs(context, layout, visibleBounds, options, activeBrush); + RenderVisibleGlyphs(context, layout, visibleBounds, options, activeBrush, glow); } } @@ -474,7 +484,8 @@ private void RenderMarqueeContent( MatrixDisplayLayout layout, MatrixLayoutOptions options, Rect contentViewport, - IBrush activeBrush) + IBrush activeBrush, + MatrixGlowRenderOptions glow) { if (contentViewport.Width <= 0 || contentViewport.Height <= 0) { @@ -487,7 +498,7 @@ private void RenderMarqueeContent( 1, HorizontalAlignment.Left, VerticalContentAlignment).Y; - var plan = MarqueeMotion.Calculate(new MarqueeMotionContext( + var plan = LeftThroughMarqueeMotion.Calculate(new MarqueeMotionContext( contentViewport.Width, layout.DesiredSize.Width, MarqueeProgress)); @@ -500,7 +511,7 @@ private void RenderMarqueeContent( contentViewport.X + plan.GetX(i), contentViewport.Y + verticalOffset); var visibleBounds = CalculateVisibleBounds(contentViewport, 1, offset); - var effectiveGlowRadius = GetEffectiveGlowRadius(); + var effectiveGlowRadius = glow.EffectiveRadius; if (effectiveGlowRadius > 0) { visibleBounds = visibleBounds.Inflate(effectiveGlowRadius); @@ -508,7 +519,7 @@ private void RenderMarqueeContent( using (PushLayoutTransform(context, 1, offset)) { - RenderVisibleGlyphs(context, layout, visibleBounds, options, activeBrush); + RenderVisibleGlyphs(context, layout, visibleBounds, options, activeBrush, glow); } } } @@ -516,7 +527,10 @@ private void RenderMarqueeContent( private void UpdateMarqueeAnimation() { - if (!_isAttachedToVisualTree || !IsVisible || !IsMarqueeEnabled || string.IsNullOrEmpty(Text)) + if (!_isAttachedToVisualTree + || !IsEffectivelyVisible + || !IsEffectiveMarqueeEnabled() + || string.IsNullOrEmpty(Text)) { ReleaseMarqueeController(); return; @@ -545,6 +559,51 @@ private void ReleaseMarqueeController() _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()); @@ -668,25 +727,26 @@ private static Rect CalculateVisibleBounds(Rect viewport, double scale, Vector o viewport.Height / scale); } - private double GetEffectiveGlowRadius() + private MatrixGlowRenderOptions GetGlowRenderOptions() { - return GlowBrush is not null && LedGlowValueSanitizer.CoerceOpacity(GlowOpacity) > 0 - ? LedGlowValueSanitizer.CoerceRadius(GlowRadius) - : 0; + return new MatrixGlowRenderOptions( + GlowBrush, + LedGlowValueSanitizer.CoerceOpacity(GlowOpacity), + LedGlowValueSanitizer.CoerceRadius(GlowRadius)); } - private LedGlowRenderScope PushGlow(DrawingContext context, Rect activeBounds) + private LedGlowRenderScope PushGlow( + DrawingContext context, + Rect activeBounds, + in MatrixGlowRenderOptions glow) { - var glowBrush = GlowBrush; - var opacity = LedGlowValueSanitizer.CoerceOpacity(GlowOpacity); - var radius = LedGlowValueSanitizer.CoerceRadius(GlowRadius); - if (glowBrush is null || opacity <= 0 || radius <= 0) + if (!glow.IsActive) { return default; } _glowRenderer ??= new LedGlowRenderer(); - return _glowRenderer.Push(context, glowBrush, opacity, radius, activeBounds); + return _glowRenderer.Push(context, glow.Brush, glow.Opacity, glow.Radius, activeBounds); } private void RenderVisibleGlyphs( @@ -694,7 +754,8 @@ private void RenderVisibleGlyphs( MatrixDisplayLayout layout, Rect visibleBounds, MatrixLayoutOptions options, - IBrush activeBrush) + IBrush activeBrush, + in MatrixGlowRenderOptions glow) { if (layout.Slots.Count == 0) { @@ -714,11 +775,11 @@ private void RenderVisibleGlyphs( if (TryCalculateActiveBounds(layout, options, firstVisibleIndex, lastVisibleIndex, out var activeBounds)) { - using (var glowScope = PushGlow(context, activeBounds)) + using (var glowScope = PushGlow(context, activeBounds, glow)) { if (glowScope.IsActive) { - RenderActiveGlyphs(context, layout, options, firstVisibleIndex, lastVisibleIndex, GlowBrush!); + RenderActiveGlyphs(context, layout, options, firstVisibleIndex, lastVisibleIndex, glow.Brush!); } } } @@ -726,6 +787,13 @@ private void RenderVisibleGlyphs( 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; From 0d8f28a9fb21e6e382cf4fc556d403d05d732992 Mon Sep 17 00:00:00 2001 From: youname Date: Mon, 20 Jul 2026 18:09:31 +0800 Subject: [PATCH 29/33] Optimize Led glow dispose process --- .../Led/ShowCaseControls/LedGlowWorkbench.cs | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs index b51ad27..501548f 100644 --- a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs +++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs @@ -12,7 +12,7 @@ namespace AtomUILabsGallery.ShowCases.Led; -public sealed class LedGlowWorkbench : StackPanel, IDisposable +public sealed class LedGlowWorkbench : StackPanel { private static readonly GlowBrushOption[] BrushOptions = [ @@ -33,7 +33,15 @@ public sealed class LedGlowWorkbench : StackPanel, IDisposable private readonly TextBlock _opacityValue; private readonly TextBlock _radiusValue; private CancellationTokenSource? _animationCancellation; - private bool _disposed; + private bool _isAttachedToVisualTree; + + internal bool HasActiveAnimation => _animationCancellation is { IsCancellationRequested: false }; + + internal int AnimationModeIndex + { + get => _mode.SelectedIndex; + set => _mode.SelectedIndex = value; + } public LedGlowWorkbench() { @@ -107,30 +115,21 @@ public LedGlowWorkbench() _mode.SelectionChanged += HandleConfigurationChanged; _opacity.PropertyChanged += HandleSliderPropertyChanged; _radius.PropertyChanged += HandleSliderPropertyChanged; + AttachedToVisualTree += HandleAttachedToVisualTree; DetachedFromVisualTree += HandleDetachedFromVisualTree; ApplyConfiguration(); } - public void Dispose() + private void HandleAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { - if (_disposed) - { - return; - } - - _disposed = true; - _enabled.IsCheckedChanged -= HandleConfigurationChanged; - _brush.SelectionChanged -= HandleConfigurationChanged; - _mode.SelectionChanged -= HandleConfigurationChanged; - _opacity.PropertyChanged -= HandleSliderPropertyChanged; - _radius.PropertyChanged -= HandleSliderPropertyChanged; - DetachedFromVisualTree -= HandleDetachedFromVisualTree; - CancelAnimations(); + _isAttachedToVisualTree = true; + ApplyConfiguration(); } private void HandleDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { - Dispose(); + _isAttachedToVisualTree = false; + CancelAnimations(); } private void HandleConfigurationChanged(object? sender, EventArgs e) @@ -155,7 +154,7 @@ private void ApplyConfiguration() var brush = enabled ? CreateSelectedBrush() : null; ApplyStaticValues(_matrix, brush); ApplyStaticValues(_segment, brush); - if (!enabled || _mode.SelectedIndex == 0) + if (!_isAttachedToVisualTree || !enabled || _mode.SelectedIndex == 0) { return; } From 795148001bf843191d08839a44df6dfecd605ffd Mon Sep 17 00:00:00 2001 From: youname Date: Fri, 24 Jul 2026 12:08:33 +0800 Subject: [PATCH 30/33] =?UTF-8?q?=E5=BD=BB=E5=BA=95=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=8C=85=E5=90=8D=EF=BC=8C=E9=81=B5=E5=AE=88AtomUI.Labs?= =?UTF-8?q?=E6=A0=87=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AtomUI.Labs.slnx | 4 ++-- README.md | 4 ++-- README.zh-CN.md | 4 ++-- .../AtomUILabsGallery/AtomUILabsGallery.csproj | 4 ++-- .../Led/ShowCaseControls/LedDynamicDisplays.cs | 4 ++-- .../Led/ShowCaseControls/LedGlowWorkbench.cs | 4 ++-- .../DataDisplay/Led/Views/LedMatrixShowCase.axaml | 4 ++-- .../DataDisplay/Led/Views/LedSegmentShowCase.axaml | 4 ++-- .../ThemeManagerBuilderExtensions.cs | 2 +- docs/controls/led/family-common-boundary-audit.md | 2 +- docs/controls/led/glow-prototype-evaluation.md | 14 +++++++------- docs/controls/led/glow-technical-options.md | 2 +- docs/controls/led/matrix-implementation.md | 6 +++--- .../led/matrix-marquee-minimum-contract.md | 2 +- docs/controls/led/matrix-mvp-audit.md | 4 ++-- .../led/matrix-performance-allocation-and-soak.md | 2 +- docs/controls/led/matrix-performance-baseline.md | 2 +- .../led/matrix-performance-dynamic-load.md | 2 +- .../led/matrix-performance-geometry-batch.md | 2 +- docs/controls/led/overview.md | 8 ++++---- docs/controls/led/segment-implementation.md | 6 +++--- docs/controls/overview.md | 2 +- docs/engineering/overview.md | 2 +- .../AtomUI.Labs.Controls.Led.csproj} | 6 +++--- .../Glow/LedGlowRenderer.cs | 2 +- .../Glow/LedGlowValueSanitizer.cs | 2 +- .../LedCharacterNormalizer.cs | 2 +- .../LedDisplayLayoutMath.cs | 2 +- .../LedThemesProvider.axaml | 4 ++-- .../LedThemesProvider.cs | 2 +- .../Marquee/LedMarqueeController.cs | 2 +- .../Marquee/LedMarqueeValueSanitizer.cs | 2 +- .../Marquee/LeftThroughMarqueeMotion.cs | 2 +- .../Marquee/MarqueeMotionContext.cs | 2 +- .../Marquee/MarqueeRenderPlan.cs | 2 +- .../Matrix/Character/MatrixCharacterMap.cs | 4 ++-- .../Matrix/Character/MatrixCharacterPattern.cs | 2 +- .../Matrix/Character/MatrixFiveBySevenGlyphMap.cs | 2 +- .../Matrix/Character/MatrixGlyph.cs | 2 +- .../Matrix/Layout/MatrixDisplayLayout.cs | 2 +- .../Matrix/Layout/MatrixGlyphSlot.cs | 4 ++-- .../Matrix/Layout/MatrixLayoutEngine.cs | 4 ++-- .../Matrix/Layout/MatrixLayoutOptions.cs | 2 +- .../Matrix/MatrixDisplay.cs | 14 +++++++------- .../Matrix/MatrixDisplayAutomationPeer.cs | 4 ++-- .../Matrix/MatrixDotShape.cs | 2 +- .../Matrix/MatrixOverflowMode.cs | 2 +- .../Matrix/MatrixValueSanitizer.cs | 2 +- .../Matrix/Rendering/MatrixDotShapeResolver.cs | 2 +- .../Matrix/Rendering/MatrixGlyphGeometry.cs | 2 +- .../Rendering/MatrixGlyphGeometryCacheKey.cs | 2 +- .../Matrix/Rendering/MatrixGlyphGeometryFactory.cs | 4 ++-- .../Rendering/MatrixPanelBorderGeometryFactory.cs | 2 +- .../Matrix/Themes/MatrixDisplayTheme.axaml | 2 +- .../Matrix/Themes/MatrixThemes.axaml | 0 .../Properties/AssemblyInfo.cs | 6 +++--- .../README.nuget.md | 4 ++-- .../Segment/Character/SegmentCharacterKind.cs | 2 +- .../Segment/Character/SegmentCharacterMap.cs | 4 ++-- .../Segment/Character/SegmentCharacterPattern.cs | 2 +- .../Segment/Character/SegmentParts.cs | 2 +- .../Segment/Layout/SegmentCharacterSlot.cs | 4 ++-- .../Segment/Layout/SegmentDisplayLayout.cs | 2 +- .../Segment/Layout/SegmentLayoutEngine.cs | 6 +++--- .../Segment/Layout/SegmentLayoutOptions.cs | 2 +- .../Segment/Rendering/SegmentGeometryFactory.cs | 6 +++--- .../Segment/Rendering/SegmentGeometryItem.cs | 4 ++-- .../Segment/Rendering/SegmentGeometryOptions.cs | 2 +- .../Segment/Rendering/SegmentGeometrySet.cs | 2 +- .../Segment/Rendering/SegmentVisibleGeometry.cs | 2 +- .../Segment/SegmentDisplay.cs | 12 ++++++------ .../Segment/SegmentDisplayAutomationPeer.cs | 4 ++-- .../Segment/SegmentOverflowMode.cs | 2 +- .../Segment/SegmentValueSanitizer.cs | 2 +- .../Segment/Themes/SegmentDisplayTheme.axaml | 2 +- .../Segment/Themes/SegmentThemes.axaml | 0 .../ThemeManagerBuilderExtensions.cs | 2 +- .../Themes/LedThemes.axaml | 0 .../AtomUI.Labs.Controls.Led.Tests.csproj} | 4 ++-- .../AvaloniaTestApp.cs | 6 +++--- .../Gallery/LedGlowWorkbenchLifecycleTests.cs | 2 +- .../Glow/LedGlowPixelTests.cs | 6 +++--- .../Glow/LedGlowRendererTests.cs | 8 ++++---- .../LedCharacterNormalizerTests.cs | 4 ++-- .../LedDisplayLayoutMathTests.cs | 4 ++-- .../Matrix/MatrixAxamlHost.axaml | 2 +- .../Matrix/MatrixAxamlHost.axaml.cs | 4 ++-- .../Matrix/MatrixCharacterMapTests.cs | 4 ++-- .../Matrix/MatrixDisplayAutomationTests.cs | 6 +++--- .../Matrix/MatrixDisplayContractTests.cs | 4 ++-- .../Matrix/MatrixDisplayInvalidationTests.cs | 4 ++-- .../Matrix/MatrixDisplayMeasureTests.cs | 4 ++-- .../Matrix/MatrixDisplayPixelTests.cs | 4 ++-- .../Matrix/MatrixDisplayRenderTests.cs | 4 ++-- .../Matrix/MatrixDisplayThemeTests.cs | 4 ++-- .../Matrix/MatrixDynamicLoadTests.cs | 4 ++-- .../Matrix/MatrixGlyphGeometryCacheTests.cs | 6 +++--- .../Matrix/MatrixGlyphGeometryFactoryTests.cs | 8 ++++---- .../Matrix/MatrixGlyphGeometryLifecycleTests.cs | 4 ++-- .../Matrix/MatrixGlyphMapTests.cs | 4 ++-- .../Matrix/MatrixLayoutEngineTests.cs | 4 ++-- .../Matrix/MatrixMarqueeLifecycleTests.cs | 4 ++-- .../Matrix/MatrixMarqueeMotionTests.cs | 4 ++-- .../Matrix/MatrixMarqueeValueSanitizerTests.cs | 4 ++-- .../MatrixPanelBorderGeometryFactoryTests.cs | 6 +++--- .../Matrix/MatrixUnicodeStressTests.cs | 8 ++++---- .../Segment/SegmentAxamlHost.axaml | 2 +- .../Segment/SegmentAxamlHost.axaml.cs | 4 ++-- .../Segment/SegmentCharacterMapTests.cs | 4 ++-- .../Segment/SegmentDisplayAutomationTests.cs | 6 +++--- .../Segment/SegmentDisplayContractTests.cs | 4 ++-- .../SegmentDisplayGeometryLifecycleTests.cs | 4 ++-- .../Segment/SegmentDisplayMeasureTests.cs | 4 ++-- .../Segment/SegmentDisplayRenderTests.cs | 4 ++-- .../Segment/SegmentDisplayThemeTests.cs | 2 +- .../Segment/SegmentGeometryFactoryTests.cs | 6 +++--- .../Segment/SegmentLayoutEngineTests.cs | 8 ++++---- ...Labs.Controls.Led.GlowPrototype.Desktop.csproj} | 4 ++-- .../FormalGlowDesktop.cs | 6 +++--- .../GlowDesktopBenchmark.cs | 4 ++-- .../GlowLifecycle.cs | 2 +- .../GlowPrototypeApplication.cs | 2 +- .../GlowPrototypeWindow.cs | 4 ++-- .../Program.cs | 2 +- .../AtomUI.Labs.Controls.Led.Performance.csproj} | 6 +++--- .../Glow/FormalGlowPerformanceRunner.cs | 6 +++--- .../Glow/GlowPrototypeRunner.cs | 2 +- .../Program.cs | 6 +++--- 128 files changed, 238 insertions(+), 238 deletions(-) rename src/{AtomUI.Labs.Led/AtomUI.Labs.Led.csproj => AtomUI.Labs.Controls.Led/AtomUI.Labs.Controls.Led.csproj} (78%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Glow/LedGlowRenderer.cs (97%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Glow/LedGlowValueSanitizer.cs (91%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/LedCharacterNormalizer.cs (86%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/LedDisplayLayoutMath.cs (97%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/LedThemesProvider.axaml (76%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/LedThemesProvider.cs (84%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Marquee/LedMarqueeController.cs (98%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Marquee/LedMarqueeValueSanitizer.cs (94%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Marquee/LeftThroughMarqueeMotion.cs (93%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Marquee/MarqueeMotionContext.cs (74%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Marquee/MarqueeRenderPlan.cs (89%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/Character/MatrixCharacterMap.cs (95%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/Character/MatrixCharacterPattern.cs (65%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/Character/MatrixFiveBySevenGlyphMap.cs (99%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/Character/MatrixGlyph.cs (87%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/Layout/MatrixDisplayLayout.cs (89%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/Layout/MatrixGlyphSlot.cs (55%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/Layout/MatrixLayoutEngine.cs (94%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/Layout/MatrixLayoutOptions.cs (76%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/MatrixDisplay.cs (99%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/MatrixDisplayAutomationPeer.cs (94%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/MatrixDotShape.cs (63%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/MatrixOverflowMode.cs (58%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/MatrixValueSanitizer.cs (96%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/Rendering/MatrixDotShapeResolver.cs (92%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/Rendering/MatrixGlyphGeometry.cs (91%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/Rendering/MatrixGlyphGeometryCacheKey.cs (77%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/Rendering/MatrixGlyphGeometryFactory.cs (98%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/Rendering/MatrixPanelBorderGeometryFactory.cs (99%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/Themes/MatrixDisplayTheme.axaml (91%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Matrix/Themes/MatrixThemes.axaml (100%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Properties/AssemblyInfo.cs (79%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/README.nuget.md (85%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/Character/SegmentCharacterKind.cs (60%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/Character/SegmentCharacterMap.cs (98%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/Character/SegmentCharacterPattern.cs (71%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/Character/SegmentParts.cs (90%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/Layout/SegmentCharacterSlot.cs (55%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/Layout/SegmentDisplayLayout.cs (86%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/Layout/SegmentLayoutEngine.cs (94%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/Layout/SegmentLayoutOptions.cs (78%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/Rendering/SegmentGeometryFactory.cs (98%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/Rendering/SegmentGeometryItem.cs (54%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/Rendering/SegmentGeometryOptions.cs (73%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/Rendering/SegmentGeometrySet.cs (79%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/Rendering/SegmentVisibleGeometry.cs (71%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/SegmentDisplay.cs (98%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/SegmentDisplayAutomationPeer.cs (93%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/SegmentOverflowMode.cs (58%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/SegmentValueSanitizer.cs (97%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/Themes/SegmentDisplayTheme.axaml (91%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Segment/Themes/SegmentThemes.axaml (100%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/ThemeManagerBuilderExtensions.cs (89%) rename src/{AtomUI.Labs.Led => AtomUI.Labs.Controls.Led}/Themes/LedThemes.axaml (100%) rename tests/{AtomUI.Labs.Led.Tests/AtomUI.Labs.Led.Tests.csproj => AtomUI.Labs.Controls.Led.Tests/AtomUI.Labs.Controls.Led.Tests.csproj} (79%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/AvaloniaTestApp.cs (88%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Gallery/LedGlowWorkbenchLifecycleTests.cs (95%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Glow/LedGlowPixelTests.cs (97%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Glow/LedGlowRendererTests.cs (96%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/LedCharacterNormalizerTests.cs (86%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/LedDisplayLayoutMathTests.cs (97%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixAxamlHost.axaml (90%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixAxamlHost.axaml.cs (75%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixCharacterMapTests.cs (95%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixDisplayAutomationTests.cs (94%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixDisplayContractTests.cs (98%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixDisplayInvalidationTests.cs (97%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixDisplayMeasureTests.cs (97%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixDisplayPixelTests.cs (99%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixDisplayRenderTests.cs (99%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixDisplayThemeTests.cs (98%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixDynamicLoadTests.cs (97%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixGlyphGeometryCacheTests.cs (97%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixGlyphGeometryFactoryTests.cs (94%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixGlyphGeometryLifecycleTests.cs (98%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixGlyphMapTests.cs (96%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixLayoutEngineTests.cs (97%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixMarqueeLifecycleTests.cs (98%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixMarqueeMotionTests.cs (95%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixMarqueeValueSanitizerTests.cs (90%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixPanelBorderGeometryFactoryTests.cs (92%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Matrix/MatrixUnicodeStressTests.cs (92%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Segment/SegmentAxamlHost.axaml (86%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Segment/SegmentAxamlHost.axaml.cs (75%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Segment/SegmentCharacterMapTests.cs (97%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Segment/SegmentDisplayAutomationTests.cs (95%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Segment/SegmentDisplayContractTests.cs (98%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Segment/SegmentDisplayGeometryLifecycleTests.cs (96%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Segment/SegmentDisplayMeasureTests.cs (96%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Segment/SegmentDisplayRenderTests.cs (99%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Segment/SegmentDisplayThemeTests.cs (99%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Segment/SegmentGeometryFactoryTests.cs (97%) rename tests/{AtomUI.Labs.Led.Tests => AtomUI.Labs.Controls.Led.Tests}/Segment/SegmentLayoutEngineTests.cs (96%) rename tools/performances/{AtomUI.Labs.Led.GlowPrototype.Desktop/AtomUI.Labs.Led.GlowPrototype.Desktop.csproj => AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop.csproj} (66%) rename tools/performances/{AtomUI.Labs.Led.GlowPrototype.Desktop => AtomUI.Labs.Controls.Led.GlowPrototype.Desktop}/FormalGlowDesktop.cs (98%) rename tools/performances/{AtomUI.Labs.Led.GlowPrototype.Desktop => AtomUI.Labs.Controls.Led.GlowPrototype.Desktop}/GlowDesktopBenchmark.cs (99%) rename tools/performances/{AtomUI.Labs.Led.GlowPrototype.Desktop => AtomUI.Labs.Controls.Led.GlowPrototype.Desktop}/GlowLifecycle.cs (99%) rename tools/performances/{AtomUI.Labs.Led.GlowPrototype.Desktop => AtomUI.Labs.Controls.Led.GlowPrototype.Desktop}/GlowPrototypeApplication.cs (93%) rename tools/performances/{AtomUI.Labs.Led.GlowPrototype.Desktop => AtomUI.Labs.Controls.Led.GlowPrototype.Desktop}/GlowPrototypeWindow.cs (98%) rename tools/performances/{AtomUI.Labs.Led.GlowPrototype.Desktop => AtomUI.Labs.Controls.Led.GlowPrototype.Desktop}/Program.cs (87%) rename tools/performances/{AtomUI.Labs.Led.Performance/AtomUI.Labs.Led.Performance.csproj => AtomUI.Labs.Controls.Led.Performance/AtomUI.Labs.Controls.Led.Performance.csproj} (65%) rename tools/performances/{AtomUI.Labs.Led.Performance => AtomUI.Labs.Controls.Led.Performance}/Glow/FormalGlowPerformanceRunner.cs (99%) rename tools/performances/{AtomUI.Labs.Led.Performance => AtomUI.Labs.Controls.Led.Performance}/Glow/GlowPrototypeRunner.cs (99%) rename tools/performances/{AtomUI.Labs.Led.Performance => AtomUI.Labs.Controls.Led.Performance}/Program.cs (99%) diff --git a/AtomUI.Labs.slnx b/AtomUI.Labs.slnx index 874bd8c..47a7b45 100644 --- a/AtomUI.Labs.slnx +++ b/AtomUI.Labs.slnx @@ -1,6 +1,6 @@ - - + + diff --git a/README.md b/README.md index b6a3b68..1ef5257 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,10 @@ Labs package names follow this pattern: dotnet add package AtomUI.Labs.Controls. ``` -The currently implemented LED family is published as an explicit naming exception: +For example, install the currently implemented LED family with: ```bash -dotnet add package AtomUI.Labs.Led +dotnet add package AtomUI.Labs.Controls.Led ``` Use a Labs package version that matches your AtomUI package version. diff --git a/README.zh-CN.md b/README.zh-CN.md index 55ba502..197b57a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -27,10 +27,10 @@ Labs 包名遵循以下格式: dotnet add package AtomUI.Labs.Controls. ``` -当前已实现的 LED 控件家族采用明确的命名例外: +例如,安装当前已经实现的 LED 控件家族: ```bash -dotnet add package AtomUI.Labs.Led +dotnet add package AtomUI.Labs.Controls.Led ``` Labs 包版本应与应用使用的 AtomUI 主包版本保持一致。 diff --git a/controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj b/controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj index e5df4e3..067ca2b 100644 --- a/controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj +++ b/controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj @@ -7,7 +7,7 @@ - + @@ -20,7 +20,7 @@ - + diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedDynamicDisplays.cs b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedDynamicDisplays.cs index da39f5f..d3898ee 100644 --- a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedDynamicDisplays.cs +++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedDynamicDisplays.cs @@ -1,5 +1,5 @@ -using AtomUI.Labs.Led.Matrix; -using AtomUI.Labs.Led.Segment; +using AtomUI.Labs.Controls.Led.Matrix; +using AtomUI.Labs.Controls.Led.Segment; using Avalonia; using Avalonia.Controls; using Avalonia.Layout; diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs index 501548f..9941ecb 100644 --- a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs +++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs @@ -1,5 +1,5 @@ -using AtomUI.Labs.Led.Matrix; -using AtomUI.Labs.Led.Segment; +using AtomUI.Labs.Controls.Led.Matrix; +using AtomUI.Labs.Controls.Led.Segment; using Avalonia; using Avalonia.Animation; using Avalonia.Animation.Easings; diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml index 760fefb..b20d1ef 100644 --- a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml +++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml @@ -14,8 +14,8 @@ Status="Experimental" Subtitle="Fixed 5x7 dot-matrix LED-style text display." Description="Displays Latin text and symbols with configurable dot shape, panel border, glow, overflow and through-screen marquee." - Namespace="AtomUI.Labs.Led.Matrix" - Package="AtomUI.Labs.Led" + Namespace="AtomUI.Labs.Controls.Led.Matrix" + Package="AtomUI.Labs.Controls.Led" BaseClass="Control" /> diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml index 9edb484..df2ee54 100644 --- a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml +++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml @@ -14,8 +14,8 @@ Status="Experimental" Subtitle="Fourteen-segment LED-style text display." Description="Displays numbers, Latin letters and common symbols with configurable segment geometry, layout, glow and inactive segments." - Namespace="AtomUI.Labs.Led.Segment" - Package="AtomUI.Labs.Led" + Namespace="AtomUI.Labs.Controls.Led.Segment" + Package="AtomUI.Labs.Controls.Led" BaseClass="Control" /> diff --git a/controlgallery/AtomUILabsGallery/ThemeManagerBuilderExtensions.cs b/controlgallery/AtomUILabsGallery/ThemeManagerBuilderExtensions.cs index 040debe..a5d442c 100644 --- a/controlgallery/AtomUILabsGallery/ThemeManagerBuilderExtensions.cs +++ b/controlgallery/AtomUILabsGallery/ThemeManagerBuilderExtensions.cs @@ -1,5 +1,5 @@ using AtomUI.Theme; -using AtomUI.Labs.Led; +using AtomUI.Labs.Controls.Led; using AtomUI.Toolkits.GalleryBase; namespace AtomUILabsGallery; diff --git a/docs/controls/led/family-common-boundary-audit.md b/docs/controls/led/family-common-boundary-audit.md index 12fa4c4..a93bc18 100644 --- a/docs/controls/led/family-common-boundary-audit.md +++ b/docs/controls/led/family-common-boundary-audit.md @@ -23,7 +23,7 @@ LedDisplayLayoutMath ## 新增共享边界 -`LedDisplayLayoutMath` 直接位于 `src/AtomUI.Labs.Led/` 根目录,不新增 `Primitives`、`Shared` 或 `Internal` 目录。 +`LedDisplayLayoutMath` 直接位于 `src/AtomUI.Labs.Controls.Led/` 根目录,不新增 `Primitives`、`Shared` 或 `Internal` 目录。 它只包含: diff --git a/docs/controls/led/glow-prototype-evaluation.md b/docs/controls/led/glow-prototype-evaluation.md index 1c25955..c9b6270 100644 --- a/docs/controls/led/glow-prototype-evaluation.md +++ b/docs/controls/led/glow-prototype-evaluation.md @@ -4,7 +4,7 @@ ## 评估状态 -本报告记录2026-07-11的首轮可行性Smoke,不是最终选型或正式性能结论。原型只存在于`AtomUI.Labs.Led.Performance`工具,不进入Labs运行时程序集。 +本报告记录2026-07-11的首轮可行性Smoke,不是最终选型或正式性能结论。原型只存在于`AtomUI.Labs.Controls.Led.Performance`工具,不进入Labs运行时程序集。 ## Avalonia公开API审计 @@ -136,7 +136,7 @@ Scoped Blur行为Gate: ```text tools/performances/ - AtomUI.Labs.Led.GlowPrototype.Desktop/ + AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/ ``` 该项目引用Performance试验程序集并通过friend assembly复用同一份路线A与Scoped Blur Renderer,不复制算法,也不引用或修改Labs运行时Glow实现。它并排展示: @@ -150,7 +150,7 @@ tools/performances/ 运行: ```powershell -dotnet run --project tools\performances\AtomUI.Labs.Led.GlowPrototype.Desktop\AtomUI.Labs.Led.GlowPrototype.Desktop.csproj -c Release +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性能已经通过。 @@ -166,7 +166,7 @@ Release构建为0警告、0错误。短时真实Win32进程Smoke保持运行5秒 运行示例: ```powershell -dotnet run --project tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/AtomUI.Labs.Led.GlowPrototype.Desktop.csproj -c Release --no-build -- --benchmark --route scoped --instances 64 --warmup 30 --ticks 120 +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 ``` ### 五个独立进程 @@ -358,7 +358,7 @@ PerControl的P95比最佳Batch16高约1.4%,没有达到预先约定的15%改 ## 第七轮:正式控件接入 -Scoped Blur 与 PerControl 粒度已进入 `AtomUI.Labs.Led` 正式运行时。共享实现位于 `Glow/`,Matrix 和 Segment 不复制 Effect 创建、数值规整或作用域释放逻辑。 +Scoped Blur 与 PerControl 粒度已进入 `AtomUI.Labs.Controls.Led` 正式运行时。共享实现位于 `Glow/`,Matrix 和 Segment 不复制 Effect 创建、数值规整或作用域释放逻辑。 正式公共契约: @@ -431,7 +431,7 @@ Labs Sample增加Matrix默认关闭、Radius 6/12/24和多色Glow案例,以及 运行方式: ```powershell -dotnet run --project tools/performances/AtomUI.Labs.Led.Performance/AtomUI.Labs.Led.Performance.csproj -c Release --no-build -- --formal-glow --frames 6000 --markdown output/formal-glow.md +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窗口门禁 @@ -480,7 +480,7 @@ Segment在10实例60Hz时,NoGlow、Static和DynamicText均处于约62%同一 运行示例: ```powershell -dotnet run --project tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/AtomUI.Labs.Led.GlowPrototype.Desktop.csproj -c Release --no-build -- --formal-controls --control matrix --mode opacity --instances 10 --hz 60 --warmup 30 --ticks 120 +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命令聚合 diff --git a/docs/controls/led/glow-technical-options.md b/docs/controls/led/glow-technical-options.md index 1421767..c7622f7 100644 --- a/docs/controls/led/glow-technical-options.md +++ b/docs/controls/led/glow-technical-options.md @@ -64,7 +64,7 @@ Glow 属性只触发重绘,不触发 Measure。`GlowBrush` 变为 `null` 时 ## 性能门禁 -`tools/performances/AtomUI.Labs.Led.Performance --formal-glow` 是当前正式命令提交基准。它测量 DrawingGroup 构建和命令提交,不宣称代表 GPU 呈现时间。 +`tools/performances/AtomUI.Labs.Controls.Led.Performance --formal-glow` 是当前正式命令提交基准。它测量 DrawingGroup 构建和命令提交,不宣称代表 GPU 呈现时间。 PR 级单进程门禁: diff --git a/docs/controls/led/matrix-implementation.md b/docs/controls/led/matrix-implementation.md index 9fdc1fd..c1e3ca9 100644 --- a/docs/controls/led/matrix-implementation.md +++ b/docs/controls/led/matrix-implementation.md @@ -2,7 +2,7 @@ > 文档状态:当前实现契约,更新于 2026-07-20。性能数值链接指向带日期的历史证据,不代表当前机器基线。 -本文记录 `AtomUI.Labs.Led.Matrix` 的当前实现设计。Matrix 是 LED 家族中的点阵屏路线,不是字体控件,不是 Segment 的升级版,也不是硬件 LED 控制器。 +本文记录 `AtomUI.Labs.Controls.Led.Matrix` 的当前实现设计。Matrix 是 LED 家族中的点阵屏路线,不是字体控件,不是 Segment 的升级版,也不是硬件 LED 控制器。 公共控件类型为 `MatrixDisplay`,基础显示为单行 `5x7` 等宽点阵文本,并可选启用 Glow 和单向穿屏 Marquee。 @@ -35,7 +35,7 @@ Matrix 按当前真实职责组织: ```text -src/AtomUI.Labs.Led/Matrix/ +src/AtomUI.Labs.Controls.Led/Matrix/ MatrixDisplay.cs MatrixDisplayAutomationPeer.cs MatrixDotShape.cs @@ -558,7 +558,7 @@ Labs 程序集通过 `https://atomui.net/labs` XML 命名空间公开 `MatrixDis Matrix 使用独立测量程序: ```text -tools/performances/AtomUI.Labs.Led.Performance +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 或平台呈现成本,也不把机器相关毫秒数作为单元测试阈值。 diff --git a/docs/controls/led/matrix-marquee-minimum-contract.md b/docs/controls/led/matrix-marquee-minimum-contract.md index 2a4bd73..0945b11 100644 --- a/docs/controls/led/matrix-marquee-minimum-contract.md +++ b/docs/controls/led/matrix-marquee-minimum-contract.md @@ -7,7 +7,7 @@ Marquee与Glow同属LED基础显示之上的可选动态增强领域,但二者是平行模块: ```text -src/AtomUI.Labs.Led/ +src/AtomUI.Labs.Controls.Led/ ├── Matrix/ MatrixDisplay、字符、布局和Geometry ├── Segment/ SegmentDisplay及十四段基础实现 ├── Glow/ 可见Active Geometry的光效增强 diff --git a/docs/controls/led/matrix-mvp-audit.md b/docs/controls/led/matrix-mvp-audit.md index 304838d..7cf1738 100644 --- a/docs/controls/led/matrix-mvp-audit.md +++ b/docs/controls/led/matrix-mvp-audit.md @@ -3,7 +3,7 @@ > 文档状态:历史 MVP 收口审计(审计日期 2026-07-10)。本文保留当时边界和修复证据,不描述后续 Glow、Marquee、Gallery 或当前测试状态。 - 审计日期:2026-07-10 -- 审计对象:`AtomUI.Labs.Led.Matrix.MatrixDisplay` +- 审计对象:当时命名为 `AtomUI.Labs.Led.Matrix.MatrixDisplay` 的 `MatrixDisplay`;当前命名见实现文档。 - 审计类型:MVP 交付收口,不进行行为修改或性能优化 - 结论:未发现高严重度运行缺陷;审计发现已于同日修复并进入回归验证 @@ -90,7 +90,7 @@ Matrix 对 `DotSize`、间距和 Padding 有明确数值规整,但 `CornerRadi - 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` 资产。 +- 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。 diff --git a/docs/controls/led/matrix-performance-allocation-and-soak.md b/docs/controls/led/matrix-performance-allocation-and-soak.md index f1fe2a8..a6499d0 100644 --- a/docs/controls/led/matrix-performance-allocation-and-soak.md +++ b/docs/controls/led/matrix-performance-allocation-and-soak.md @@ -4,7 +4,7 @@ - Date: 2026-07-10 19:08:33 +08:00 - Configuration: Release, .NET 10 -- Runner: `tools/performances/AtomUI.Labs.Led.Performance --count --frames --soak-frames ` +- 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 | diff --git a/docs/controls/led/matrix-performance-baseline.md b/docs/controls/led/matrix-performance-baseline.md index 28d4a59..f96b508 100644 --- a/docs/controls/led/matrix-performance-baseline.md +++ b/docs/controls/led/matrix-performance-baseline.md @@ -4,7 +4,7 @@ - Date: 2026-07-10 17:22:19 +08:00 - Configuration: Release, .NET 10 -- Runner: `tools/performances/AtomUI.Labs.Led.Performance --count ` +- 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 | diff --git a/docs/controls/led/matrix-performance-dynamic-load.md b/docs/controls/led/matrix-performance-dynamic-load.md index ea3a3ad..ae06066 100644 --- a/docs/controls/led/matrix-performance-dynamic-load.md +++ b/docs/controls/led/matrix-performance-dynamic-load.md @@ -4,7 +4,7 @@ - Date: 2026-07-10 18:53:37 +08:00 - Configuration: Release, .NET 10 -- Runner: `tools/performances/AtomUI.Labs.Led.Performance --count --frames ` +- 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 | diff --git a/docs/controls/led/matrix-performance-geometry-batch.md b/docs/controls/led/matrix-performance-geometry-batch.md index 0d27109..480bebc 100644 --- a/docs/controls/led/matrix-performance-geometry-batch.md +++ b/docs/controls/led/matrix-performance-geometry-batch.md @@ -4,7 +4,7 @@ - Date: 2026-07-10 18:21:22 +08:00 - Configuration: Release, .NET 10 -- Runner: `tools/performances/AtomUI.Labs.Led.Performance --count ` +- 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 | diff --git a/docs/controls/led/overview.md b/docs/controls/led/overview.md index 121c544..def818a 100644 --- a/docs/controls/led/overview.md +++ b/docs/controls/led/overview.md @@ -2,7 +2,7 @@ > 文档状态:当前架构与公共契约,更新于 2026-07-20。历史审计、原型和性能数据在导航中单独标识,不作为当前实现事实。 -本文记录 `AtomUI.Labs.Led` 的组件域设计。LED 是 Labs 下的实验控件家族名,不是单一控件名。 +本文记录 `AtomUI.Labs.Controls.Led` 的组件域设计。LED 是 Labs 下的实验控件家族名,不是单一控件名。 ## 文档导航 @@ -13,12 +13,12 @@ ## 定位 -`AtomUI.Labs.Led` 用于承载 LED 风格显示控件。它不表示硬件 LED 控制器,也不表示普通文本控件。 +`AtomUI.Labs.Controls.Led` 用于承载 LED 风格显示控件。它不表示硬件 LED 控制器,也不表示普通文本控件。 LED 家族目标包含两条并列路线: ```text -AtomUI.Labs.Led +AtomUI.Labs.Controls.Led Segment 十四段数码管路线 Matrix 点阵屏路线 ``` @@ -28,7 +28,7 @@ AtomUI.Labs.Led - `Segment` 以“段”为最小视觉单元,适合数字、英文字母、仪表读数和电子设备面板风格。 - `Matrix` 以“点阵像素”为最小视觉单元,适合字符屏、公告屏、滚动文字和更自由的符号表达。 -共享基础代码直接放在 `src/AtomUI.Labs.Led/` 根目录下,当前包括 `LedCharacterNormalizer` 和 `LedDisplayLayoutMath`,不创建 `Primitives`、`Shared` 或 `Internal` 等独立目录。 +共享基础代码直接放在 `src/AtomUI.Labs.Controls.Led/` 根目录下,当前包括 `LedCharacterNormalizer` 和 `LedDisplayLayoutMath`,不创建 `Primitives`、`Shared` 或 `Internal` 等独立目录。 LED 家族主题必须采用聚合入口: diff --git a/docs/controls/led/segment-implementation.md b/docs/controls/led/segment-implementation.md index 324c920..9d9392f 100644 --- a/docs/controls/led/segment-implementation.md +++ b/docs/controls/led/segment-implementation.md @@ -1,12 +1,12 @@ # LED Segment 工业级实现原理 -> 文档状态:当前实现契约,更新于 2026-07-20。本文描述现有 `AtomUI.Labs.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.Led.Segment` 的目标实现原理。目标读者可以是第一次接触 LED 控件的新手,但实现标准必须按工业级自绘控件来约束。 +本文记录 `AtomUI.Labs.Controls.Led.Segment` 的目标实现原理。目标读者可以是第一次接触 LED 控件的新手,但实现标准必须按工业级自绘控件来约束。 `Segment` 是十四段数码管路线。它不是字体控件,不是点阵控件,也不是硬件 LED 控制器。 @@ -55,7 +55,7 @@ Segment 的本质是基于字符映射表和参数化几何生成器的 Avalonia `Segment` 必须保持和正式控件库一致的工程入口习惯:公共控件类型留在控件根目录,内部实现按稳定职责进入子目录,主题通过 `*Themes.axaml` 聚合。 ```text -src/AtomUI.Labs.Led/Segment/ +src/AtomUI.Labs.Controls.Led/Segment/ SegmentDisplay.cs SegmentOverflowMode.cs SegmentValueSanitizer.cs diff --git a/docs/controls/overview.md b/docs/controls/overview.md index 3165e2a..39ac238 100644 --- a/docs/controls/overview.md +++ b/docs/controls/overview.md @@ -6,4 +6,4 @@ ## 控件目录 -- [LED 控件家族](led/overview.md):已实现的 `AtomUI.Labs.Led` 包,包含十四段 Segment、5x7 Matrix、Glow 和 Marquee。 +- [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 52e9c7e..ce40358 100644 --- a/docs/engineering/overview.md +++ b/docs/engineering/overview.md @@ -29,7 +29,7 @@ docs/engineering/ AtomUI.Labs.Controls. ``` -LED 控件家族沿用迁移时确定的独立包名 `AtomUI.Labs.Led`;其测试项目为 `AtomUI.Labs.Led.Tests`。这是显式命名例外,不改变后续控件的默认规则。 +示例:LED 控件家族遵循该规则,项目名和包名为 `AtomUI.Labs.Controls.Led`,测试项目为 `AtomUI.Labs.Controls.Led.Tests`。 测试项目使用: diff --git a/src/AtomUI.Labs.Led/AtomUI.Labs.Led.csproj b/src/AtomUI.Labs.Controls.Led/AtomUI.Labs.Controls.Led.csproj similarity index 78% rename from src/AtomUI.Labs.Led/AtomUI.Labs.Led.csproj rename to src/AtomUI.Labs.Controls.Led/AtomUI.Labs.Controls.Led.csproj index b84d12d..bf6a54c 100644 --- a/src/AtomUI.Labs.Led/AtomUI.Labs.Led.csproj +++ b/src/AtomUI.Labs.Controls.Led/AtomUI.Labs.Controls.Led.csproj @@ -1,7 +1,7 @@ $(AtomUITargetFrameworks) - AtomUI.Labs.Led + 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 @@ -13,8 +13,8 @@ - - + + diff --git a/src/AtomUI.Labs.Led/Glow/LedGlowRenderer.cs b/src/AtomUI.Labs.Controls.Led/Glow/LedGlowRenderer.cs similarity index 97% rename from src/AtomUI.Labs.Led/Glow/LedGlowRenderer.cs rename to src/AtomUI.Labs.Controls.Led/Glow/LedGlowRenderer.cs index 181ab51..3b29509 100644 --- a/src/AtomUI.Labs.Led/Glow/LedGlowRenderer.cs +++ b/src/AtomUI.Labs.Controls.Led/Glow/LedGlowRenderer.cs @@ -1,7 +1,7 @@ using Avalonia; using Avalonia.Media; -namespace AtomUI.Labs.Led.Glow; +namespace AtomUI.Labs.Controls.Led.Glow; internal sealed class LedGlowRenderer { diff --git a/src/AtomUI.Labs.Led/Glow/LedGlowValueSanitizer.cs b/src/AtomUI.Labs.Controls.Led/Glow/LedGlowValueSanitizer.cs similarity index 91% rename from src/AtomUI.Labs.Led/Glow/LedGlowValueSanitizer.cs rename to src/AtomUI.Labs.Controls.Led/Glow/LedGlowValueSanitizer.cs index 1d59394..53d94ad 100644 --- a/src/AtomUI.Labs.Led/Glow/LedGlowValueSanitizer.cs +++ b/src/AtomUI.Labs.Controls.Led/Glow/LedGlowValueSanitizer.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Glow; +namespace AtomUI.Labs.Controls.Led.Glow; internal static class LedGlowValueSanitizer { diff --git a/src/AtomUI.Labs.Led/LedCharacterNormalizer.cs b/src/AtomUI.Labs.Controls.Led/LedCharacterNormalizer.cs similarity index 86% rename from src/AtomUI.Labs.Led/LedCharacterNormalizer.cs rename to src/AtomUI.Labs.Controls.Led/LedCharacterNormalizer.cs index adeea13..1ece0b2 100644 --- a/src/AtomUI.Labs.Led/LedCharacterNormalizer.cs +++ b/src/AtomUI.Labs.Controls.Led/LedCharacterNormalizer.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led; +namespace AtomUI.Labs.Controls.Led; internal static class LedCharacterNormalizer { diff --git a/src/AtomUI.Labs.Led/LedDisplayLayoutMath.cs b/src/AtomUI.Labs.Controls.Led/LedDisplayLayoutMath.cs similarity index 97% rename from src/AtomUI.Labs.Led/LedDisplayLayoutMath.cs rename to src/AtomUI.Labs.Controls.Led/LedDisplayLayoutMath.cs index ed17ede..4ca83a3 100644 --- a/src/AtomUI.Labs.Led/LedDisplayLayoutMath.cs +++ b/src/AtomUI.Labs.Controls.Led/LedDisplayLayoutMath.cs @@ -1,7 +1,7 @@ using Avalonia; using Avalonia.Layout; -namespace AtomUI.Labs.Led; +namespace AtomUI.Labs.Controls.Led; internal static class LedDisplayLayoutMath { diff --git a/src/AtomUI.Labs.Led/LedThemesProvider.axaml b/src/AtomUI.Labs.Controls.Led/LedThemesProvider.axaml similarity index 76% rename from src/AtomUI.Labs.Led/LedThemesProvider.axaml rename to src/AtomUI.Labs.Controls.Led/LedThemesProvider.axaml index d5e29d1..cbef7e3 100644 --- a/src/AtomUI.Labs.Led/LedThemesProvider.axaml +++ b/src/AtomUI.Labs.Controls.Led/LedThemesProvider.axaml @@ -1,8 +1,8 @@ diff --git a/src/AtomUI.Labs.Led/LedThemesProvider.cs b/src/AtomUI.Labs.Controls.Led/LedThemesProvider.cs similarity index 84% rename from src/AtomUI.Labs.Led/LedThemesProvider.cs rename to src/AtomUI.Labs.Controls.Led/LedThemesProvider.cs index edb0741..cb07586 100644 --- a/src/AtomUI.Labs.Led/LedThemesProvider.cs +++ b/src/AtomUI.Labs.Controls.Led/LedThemesProvider.cs @@ -1,7 +1,7 @@ using AtomUI.Theme; using Avalonia.Markup.Xaml; -namespace AtomUI.Labs.Led; +namespace AtomUI.Labs.Controls.Led; internal class LedThemesProvider : ControlThemesProvider { diff --git a/src/AtomUI.Labs.Led/Marquee/LedMarqueeController.cs b/src/AtomUI.Labs.Controls.Led/Marquee/LedMarqueeController.cs similarity index 98% rename from src/AtomUI.Labs.Led/Marquee/LedMarqueeController.cs rename to src/AtomUI.Labs.Controls.Led/Marquee/LedMarqueeController.cs index 8921d0c..a019f1d 100644 --- a/src/AtomUI.Labs.Led/Marquee/LedMarqueeController.cs +++ b/src/AtomUI.Labs.Controls.Led/Marquee/LedMarqueeController.cs @@ -3,7 +3,7 @@ using Avalonia.Controls; using Avalonia.Styling; -namespace AtomUI.Labs.Led.Marquee; +namespace AtomUI.Labs.Controls.Led.Marquee; internal sealed class LedMarqueeController : IDisposable { diff --git a/src/AtomUI.Labs.Led/Marquee/LedMarqueeValueSanitizer.cs b/src/AtomUI.Labs.Controls.Led/Marquee/LedMarqueeValueSanitizer.cs similarity index 94% rename from src/AtomUI.Labs.Led/Marquee/LedMarqueeValueSanitizer.cs rename to src/AtomUI.Labs.Controls.Led/Marquee/LedMarqueeValueSanitizer.cs index ddede10..8c43c86 100644 --- a/src/AtomUI.Labs.Led/Marquee/LedMarqueeValueSanitizer.cs +++ b/src/AtomUI.Labs.Controls.Led/Marquee/LedMarqueeValueSanitizer.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Marquee; +namespace AtomUI.Labs.Controls.Led.Marquee; internal static class LedMarqueeValueSanitizer { diff --git a/src/AtomUI.Labs.Led/Marquee/LeftThroughMarqueeMotion.cs b/src/AtomUI.Labs.Controls.Led/Marquee/LeftThroughMarqueeMotion.cs similarity index 93% rename from src/AtomUI.Labs.Led/Marquee/LeftThroughMarqueeMotion.cs rename to src/AtomUI.Labs.Controls.Led/Marquee/LeftThroughMarqueeMotion.cs index 1eb42db..cabf258 100644 --- a/src/AtomUI.Labs.Led/Marquee/LeftThroughMarqueeMotion.cs +++ b/src/AtomUI.Labs.Controls.Led/Marquee/LeftThroughMarqueeMotion.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Marquee; +namespace AtomUI.Labs.Controls.Led.Marquee; internal static class LeftThroughMarqueeMotion { diff --git a/src/AtomUI.Labs.Led/Marquee/MarqueeMotionContext.cs b/src/AtomUI.Labs.Controls.Led/Marquee/MarqueeMotionContext.cs similarity index 74% rename from src/AtomUI.Labs.Led/Marquee/MarqueeMotionContext.cs rename to src/AtomUI.Labs.Controls.Led/Marquee/MarqueeMotionContext.cs index 4f3a571..f185385 100644 --- a/src/AtomUI.Labs.Led/Marquee/MarqueeMotionContext.cs +++ b/src/AtomUI.Labs.Controls.Led/Marquee/MarqueeMotionContext.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Marquee; +namespace AtomUI.Labs.Controls.Led.Marquee; internal readonly record struct MarqueeMotionContext( double ViewportWidth, diff --git a/src/AtomUI.Labs.Led/Marquee/MarqueeRenderPlan.cs b/src/AtomUI.Labs.Controls.Led/Marquee/MarqueeRenderPlan.cs similarity index 89% rename from src/AtomUI.Labs.Led/Marquee/MarqueeRenderPlan.cs rename to src/AtomUI.Labs.Controls.Led/Marquee/MarqueeRenderPlan.cs index f169551..27d31a5 100644 --- a/src/AtomUI.Labs.Led/Marquee/MarqueeRenderPlan.cs +++ b/src/AtomUI.Labs.Controls.Led/Marquee/MarqueeRenderPlan.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Marquee; +namespace AtomUI.Labs.Controls.Led.Marquee; internal readonly record struct MarqueeRenderPlan( int PlacementCount, diff --git a/src/AtomUI.Labs.Led/Matrix/Character/MatrixCharacterMap.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixCharacterMap.cs similarity index 95% rename from src/AtomUI.Labs.Led/Matrix/Character/MatrixCharacterMap.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixCharacterMap.cs index e2b512c..9071b20 100644 --- a/src/AtomUI.Labs.Led/Matrix/Character/MatrixCharacterMap.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixCharacterMap.cs @@ -1,7 +1,7 @@ -using AtomUI.Labs.Led; +using AtomUI.Labs.Controls.Led; using System.Text; -namespace AtomUI.Labs.Led.Matrix.Character; +namespace AtomUI.Labs.Controls.Led.Matrix.Character; internal static class MatrixCharacterMap { diff --git a/src/AtomUI.Labs.Led/Matrix/Character/MatrixCharacterPattern.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixCharacterPattern.cs similarity index 65% rename from src/AtomUI.Labs.Led/Matrix/Character/MatrixCharacterPattern.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixCharacterPattern.cs index 03e8284..6f642a8 100644 --- a/src/AtomUI.Labs.Led/Matrix/Character/MatrixCharacterPattern.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixCharacterPattern.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Matrix.Character; +namespace AtomUI.Labs.Controls.Led.Matrix.Character; internal readonly record struct MatrixCharacterPattern( char Character, diff --git a/src/AtomUI.Labs.Led/Matrix/Character/MatrixFiveBySevenGlyphMap.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixFiveBySevenGlyphMap.cs similarity index 99% rename from src/AtomUI.Labs.Led/Matrix/Character/MatrixFiveBySevenGlyphMap.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixFiveBySevenGlyphMap.cs index 5eb8d0c..e885734 100644 --- a/src/AtomUI.Labs.Led/Matrix/Character/MatrixFiveBySevenGlyphMap.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixFiveBySevenGlyphMap.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Matrix.Character; +namespace AtomUI.Labs.Controls.Led.Matrix.Character; internal static class MatrixFiveBySevenGlyphMap { diff --git a/src/AtomUI.Labs.Led/Matrix/Character/MatrixGlyph.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixGlyph.cs similarity index 87% rename from src/AtomUI.Labs.Led/Matrix/Character/MatrixGlyph.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixGlyph.cs index c2849e1..07d5ac3 100644 --- a/src/AtomUI.Labs.Led/Matrix/Character/MatrixGlyph.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/Character/MatrixGlyph.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Matrix.Character; +namespace AtomUI.Labs.Controls.Led.Matrix.Character; internal readonly record struct MatrixGlyph(ulong Bits) { diff --git a/src/AtomUI.Labs.Led/Matrix/Layout/MatrixDisplayLayout.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixDisplayLayout.cs similarity index 89% rename from src/AtomUI.Labs.Led/Matrix/Layout/MatrixDisplayLayout.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixDisplayLayout.cs index 38516ea..a1633d4 100644 --- a/src/AtomUI.Labs.Led/Matrix/Layout/MatrixDisplayLayout.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixDisplayLayout.cs @@ -1,6 +1,6 @@ using Avalonia; -namespace AtomUI.Labs.Led.Matrix.Layout; +namespace AtomUI.Labs.Controls.Led.Matrix.Layout; internal sealed class MatrixDisplayLayout { diff --git a/src/AtomUI.Labs.Led/Matrix/Layout/MatrixGlyphSlot.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixGlyphSlot.cs similarity index 55% rename from src/AtomUI.Labs.Led/Matrix/Layout/MatrixGlyphSlot.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixGlyphSlot.cs index fcd4868..3e54b4d 100644 --- a/src/AtomUI.Labs.Led/Matrix/Layout/MatrixGlyphSlot.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixGlyphSlot.cs @@ -1,7 +1,7 @@ -using AtomUI.Labs.Led.Matrix.Character; +using AtomUI.Labs.Controls.Led.Matrix.Character; using Avalonia; -namespace AtomUI.Labs.Led.Matrix.Layout; +namespace AtomUI.Labs.Controls.Led.Matrix.Layout; internal readonly record struct MatrixGlyphSlot( MatrixCharacterPattern Pattern, diff --git a/src/AtomUI.Labs.Led/Matrix/Layout/MatrixLayoutEngine.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixLayoutEngine.cs similarity index 94% rename from src/AtomUI.Labs.Led/Matrix/Layout/MatrixLayoutEngine.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixLayoutEngine.cs index 56f798b..0cdc426 100644 --- a/src/AtomUI.Labs.Led/Matrix/Layout/MatrixLayoutEngine.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixLayoutEngine.cs @@ -1,8 +1,8 @@ -using AtomUI.Labs.Led.Matrix.Character; +using AtomUI.Labs.Controls.Led.Matrix.Character; using Avalonia; using System.Text; -namespace AtomUI.Labs.Led.Matrix.Layout; +namespace AtomUI.Labs.Controls.Led.Matrix.Layout; internal static class MatrixLayoutEngine { diff --git a/src/AtomUI.Labs.Led/Matrix/Layout/MatrixLayoutOptions.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixLayoutOptions.cs similarity index 76% rename from src/AtomUI.Labs.Led/Matrix/Layout/MatrixLayoutOptions.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixLayoutOptions.cs index 0e66b9b..fcfc202 100644 --- a/src/AtomUI.Labs.Led/Matrix/Layout/MatrixLayoutOptions.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/Layout/MatrixLayoutOptions.cs @@ -1,6 +1,6 @@ using Avalonia; -namespace AtomUI.Labs.Led.Matrix.Layout; +namespace AtomUI.Labs.Controls.Led.Matrix.Layout; internal readonly record struct MatrixLayoutOptions( double DotSize, diff --git a/src/AtomUI.Labs.Led/Matrix/MatrixDisplay.cs b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixDisplay.cs similarity index 99% rename from src/AtomUI.Labs.Led/Matrix/MatrixDisplay.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/MatrixDisplay.cs index dc93909..07d03b0 100644 --- a/src/AtomUI.Labs.Led/Matrix/MatrixDisplay.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixDisplay.cs @@ -1,9 +1,9 @@ -using AtomUI.Labs.Led; -using AtomUI.Labs.Led.Glow; -using AtomUI.Labs.Led.Marquee; -using AtomUI.Labs.Led.Matrix.Character; -using AtomUI.Labs.Led.Matrix.Layout; -using AtomUI.Labs.Led.Matrix.Rendering; +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; @@ -12,7 +12,7 @@ using Avalonia.VisualTree; using AvaloniaMatrix = Avalonia.Matrix; -namespace AtomUI.Labs.Led.Matrix; +namespace AtomUI.Labs.Controls.Led.Matrix; public class MatrixDisplay : Control { diff --git a/src/AtomUI.Labs.Led/Matrix/MatrixDisplayAutomationPeer.cs b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixDisplayAutomationPeer.cs similarity index 94% rename from src/AtomUI.Labs.Led/Matrix/MatrixDisplayAutomationPeer.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/MatrixDisplayAutomationPeer.cs index 69c7bcd..c674087 100644 --- a/src/AtomUI.Labs.Led/Matrix/MatrixDisplayAutomationPeer.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixDisplayAutomationPeer.cs @@ -1,8 +1,8 @@ -using AtomUI.Labs.Led.Matrix.Character; +using AtomUI.Labs.Controls.Led.Matrix.Character; using Avalonia.Automation; using Avalonia.Automation.Peers; -namespace AtomUI.Labs.Led.Matrix; +namespace AtomUI.Labs.Controls.Led.Matrix; internal sealed class MatrixDisplayAutomationPeer : ControlAutomationPeer { diff --git a/src/AtomUI.Labs.Led/Matrix/MatrixDotShape.cs b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixDotShape.cs similarity index 63% rename from src/AtomUI.Labs.Led/Matrix/MatrixDotShape.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/MatrixDotShape.cs index dcb8d7a..16f3543 100644 --- a/src/AtomUI.Labs.Led/Matrix/MatrixDotShape.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixDotShape.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Matrix; +namespace AtomUI.Labs.Controls.Led.Matrix; public enum MatrixDotShape { diff --git a/src/AtomUI.Labs.Led/Matrix/MatrixOverflowMode.cs b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixOverflowMode.cs similarity index 58% rename from src/AtomUI.Labs.Led/Matrix/MatrixOverflowMode.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/MatrixOverflowMode.cs index 91d0929..01b6763 100644 --- a/src/AtomUI.Labs.Led/Matrix/MatrixOverflowMode.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixOverflowMode.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Matrix; +namespace AtomUI.Labs.Controls.Led.Matrix; public enum MatrixOverflowMode { diff --git a/src/AtomUI.Labs.Led/Matrix/MatrixValueSanitizer.cs b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixValueSanitizer.cs similarity index 96% rename from src/AtomUI.Labs.Led/Matrix/MatrixValueSanitizer.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/MatrixValueSanitizer.cs index fbde499..e196293 100644 --- a/src/AtomUI.Labs.Led/Matrix/MatrixValueSanitizer.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/MatrixValueSanitizer.cs @@ -1,6 +1,6 @@ using Avalonia; -namespace AtomUI.Labs.Led.Matrix; +namespace AtomUI.Labs.Controls.Led.Matrix; internal static class MatrixValueSanitizer { diff --git a/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixDotShapeResolver.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixDotShapeResolver.cs similarity index 92% rename from src/AtomUI.Labs.Led/Matrix/Rendering/MatrixDotShapeResolver.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixDotShapeResolver.cs index ff377b8..7891dbf 100644 --- a/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixDotShapeResolver.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixDotShapeResolver.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Matrix.Rendering; +namespace AtomUI.Labs.Controls.Led.Matrix.Rendering; internal static class MatrixDotShapeResolver { diff --git a/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometry.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometry.cs similarity index 91% rename from src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometry.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometry.cs index e5dfab6..4439dd4 100644 --- a/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometry.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometry.cs @@ -1,6 +1,6 @@ using Avalonia.Media; -namespace AtomUI.Labs.Led.Matrix.Rendering; +namespace AtomUI.Labs.Controls.Led.Matrix.Rendering; internal sealed class MatrixGlyphGeometry { diff --git a/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometryCacheKey.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometryCacheKey.cs similarity index 77% rename from src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometryCacheKey.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometryCacheKey.cs index 1c51c77..8dd2ff3 100644 --- a/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometryCacheKey.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometryCacheKey.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Matrix.Rendering; +namespace AtomUI.Labs.Controls.Led.Matrix.Rendering; internal readonly record struct MatrixGlyphGeometryCacheKey( ulong Bits, diff --git a/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometryFactory.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometryFactory.cs similarity index 98% rename from src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometryFactory.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometryFactory.cs index f1e7e73..8fcd6ec 100644 --- a/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixGlyphGeometryFactory.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixGlyphGeometryFactory.cs @@ -1,8 +1,8 @@ -using AtomUI.Labs.Led.Matrix.Character; +using AtomUI.Labs.Controls.Led.Matrix.Character; using Avalonia; using Avalonia.Media; -namespace AtomUI.Labs.Led.Matrix.Rendering; +namespace AtomUI.Labs.Controls.Led.Matrix.Rendering; internal static class MatrixGlyphGeometryFactory { diff --git a/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixPanelBorderGeometryFactory.cs b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixPanelBorderGeometryFactory.cs similarity index 99% rename from src/AtomUI.Labs.Led/Matrix/Rendering/MatrixPanelBorderGeometryFactory.cs rename to src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixPanelBorderGeometryFactory.cs index 1bc7b5a..152d59f 100644 --- a/src/AtomUI.Labs.Led/Matrix/Rendering/MatrixPanelBorderGeometryFactory.cs +++ b/src/AtomUI.Labs.Controls.Led/Matrix/Rendering/MatrixPanelBorderGeometryFactory.cs @@ -1,7 +1,7 @@ using Avalonia; using Avalonia.Media; -namespace AtomUI.Labs.Led.Matrix.Rendering; +namespace AtomUI.Labs.Controls.Led.Matrix.Rendering; internal readonly record struct MatrixPanelBorderGeometryCacheKey( Size Size, diff --git a/src/AtomUI.Labs.Led/Matrix/Themes/MatrixDisplayTheme.axaml b/src/AtomUI.Labs.Controls.Led/Matrix/Themes/MatrixDisplayTheme.axaml similarity index 91% rename from src/AtomUI.Labs.Led/Matrix/Themes/MatrixDisplayTheme.axaml rename to src/AtomUI.Labs.Controls.Led/Matrix/Themes/MatrixDisplayTheme.axaml index c927a6c..b127cca 100644 --- a/src/AtomUI.Labs.Led/Matrix/Themes/MatrixDisplayTheme.axaml +++ b/src/AtomUI.Labs.Controls.Led/Matrix/Themes/MatrixDisplayTheme.axaml @@ -1,7 +1,7 @@ diff --git a/src/AtomUI.Labs.Led/Matrix/Themes/MatrixThemes.axaml b/src/AtomUI.Labs.Controls.Led/Matrix/Themes/MatrixThemes.axaml similarity index 100% rename from src/AtomUI.Labs.Led/Matrix/Themes/MatrixThemes.axaml rename to src/AtomUI.Labs.Controls.Led/Matrix/Themes/MatrixThemes.axaml diff --git a/src/AtomUI.Labs.Led/Properties/AssemblyInfo.cs b/src/AtomUI.Labs.Controls.Led/Properties/AssemblyInfo.cs similarity index 79% rename from src/AtomUI.Labs.Led/Properties/AssemblyInfo.cs rename to src/AtomUI.Labs.Controls.Led/Properties/AssemblyInfo.cs index b194e9c..707dc7e 100644 --- a/src/AtomUI.Labs.Led/Properties/AssemblyInfo.cs +++ b/src/AtomUI.Labs.Controls.Led/Properties/AssemblyInfo.cs @@ -1,6 +1,6 @@ using Avalonia.Metadata; [assembly: XmlnsPrefix("https://atomui.net/labs", "labs")] -[assembly: XmlnsDefinition("https://atomui.net/labs", "AtomUI.Labs.Led")] -[assembly: XmlnsDefinition("https://atomui.net/labs", "AtomUI.Labs.Led.Segment")] -[assembly: XmlnsDefinition("https://atomui.net/labs", "AtomUI.Labs.Led.Matrix")] +[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.Led/README.nuget.md b/src/AtomUI.Labs.Controls.Led/README.nuget.md similarity index 85% rename from src/AtomUI.Labs.Led/README.nuget.md rename to src/AtomUI.Labs.Controls.Led/README.nuget.md index bb25e78..db9da9f 100644 --- a/src/AtomUI.Labs.Led/README.nuget.md +++ b/src/AtomUI.Labs.Controls.Led/README.nuget.md @@ -1,6 +1,6 @@ ## AtomUI Labs LED -`AtomUI.Labs.Led` provides experimental LED-style display controls for AtomUI applications: +`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. @@ -8,7 +8,7 @@ ### Install ```bash -dotnet add package AtomUI.Labs.Led +dotnet add package AtomUI.Labs.Controls.Led ``` Use a package version that matches the AtomUI packages in the application. diff --git a/src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterKind.cs b/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterKind.cs similarity index 60% rename from src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterKind.cs rename to src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterKind.cs index 6a7f159..5ad2883 100644 --- a/src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterKind.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterKind.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Segment.Character; +namespace AtomUI.Labs.Controls.Led.Segment.Character; internal enum SegmentCharacterKind { diff --git a/src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterMap.cs b/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterMap.cs similarity index 98% rename from src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterMap.cs rename to src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterMap.cs index 6ec274d..f77b503 100644 --- a/src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterMap.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterMap.cs @@ -1,6 +1,6 @@ -using AtomUI.Labs.Led; +using AtomUI.Labs.Controls.Led; -namespace AtomUI.Labs.Led.Segment.Character; +namespace AtomUI.Labs.Controls.Led.Segment.Character; internal static class SegmentCharacterMap { diff --git a/src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterPattern.cs b/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterPattern.cs similarity index 71% rename from src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterPattern.cs rename to src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterPattern.cs index 04f6d59..f2ac42e 100644 --- a/src/AtomUI.Labs.Led/Segment/Character/SegmentCharacterPattern.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentCharacterPattern.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Segment.Character; +namespace AtomUI.Labs.Controls.Led.Segment.Character; internal readonly record struct SegmentCharacterPattern( char Character, diff --git a/src/AtomUI.Labs.Led/Segment/Character/SegmentParts.cs b/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentParts.cs similarity index 90% rename from src/AtomUI.Labs.Led/Segment/Character/SegmentParts.cs rename to src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentParts.cs index 073a05c..cb46e58 100644 --- a/src/AtomUI.Labs.Led/Segment/Character/SegmentParts.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/Character/SegmentParts.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Segment.Character; +namespace AtomUI.Labs.Controls.Led.Segment.Character; [Flags] internal enum SegmentParts diff --git a/src/AtomUI.Labs.Led/Segment/Layout/SegmentCharacterSlot.cs b/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentCharacterSlot.cs similarity index 55% rename from src/AtomUI.Labs.Led/Segment/Layout/SegmentCharacterSlot.cs rename to src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentCharacterSlot.cs index 96ffe77..8055e98 100644 --- a/src/AtomUI.Labs.Led/Segment/Layout/SegmentCharacterSlot.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentCharacterSlot.cs @@ -1,7 +1,7 @@ using Avalonia; -using AtomUI.Labs.Led.Segment.Character; +using AtomUI.Labs.Controls.Led.Segment.Character; -namespace AtomUI.Labs.Led.Segment.Layout; +namespace AtomUI.Labs.Controls.Led.Segment.Layout; internal readonly record struct SegmentCharacterSlot( SegmentCharacterPattern Pattern, diff --git a/src/AtomUI.Labs.Led/Segment/Layout/SegmentDisplayLayout.cs b/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentDisplayLayout.cs similarity index 86% rename from src/AtomUI.Labs.Led/Segment/Layout/SegmentDisplayLayout.cs rename to src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentDisplayLayout.cs index 670cc12..9445821 100644 --- a/src/AtomUI.Labs.Led/Segment/Layout/SegmentDisplayLayout.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentDisplayLayout.cs @@ -1,6 +1,6 @@ using Avalonia; -namespace AtomUI.Labs.Led.Segment.Layout; +namespace AtomUI.Labs.Controls.Led.Segment.Layout; internal sealed class SegmentDisplayLayout { diff --git a/src/AtomUI.Labs.Led/Segment/Layout/SegmentLayoutEngine.cs b/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentLayoutEngine.cs similarity index 94% rename from src/AtomUI.Labs.Led/Segment/Layout/SegmentLayoutEngine.cs rename to src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentLayoutEngine.cs index 327c2d9..a278fd5 100644 --- a/src/AtomUI.Labs.Led/Segment/Layout/SegmentLayoutEngine.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentLayoutEngine.cs @@ -1,8 +1,8 @@ using Avalonia; -using AtomUI.Labs.Led.Segment; -using AtomUI.Labs.Led.Segment.Character; +using AtomUI.Labs.Controls.Led.Segment; +using AtomUI.Labs.Controls.Led.Segment.Character; -namespace AtomUI.Labs.Led.Segment.Layout; +namespace AtomUI.Labs.Controls.Led.Segment.Layout; internal static class SegmentLayoutEngine { diff --git a/src/AtomUI.Labs.Led/Segment/Layout/SegmentLayoutOptions.cs b/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentLayoutOptions.cs similarity index 78% rename from src/AtomUI.Labs.Led/Segment/Layout/SegmentLayoutOptions.cs rename to src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentLayoutOptions.cs index 4f70ddb..b976a6d 100644 --- a/src/AtomUI.Labs.Led/Segment/Layout/SegmentLayoutOptions.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/Layout/SegmentLayoutOptions.cs @@ -1,6 +1,6 @@ using Avalonia; -namespace AtomUI.Labs.Led.Segment.Layout; +namespace AtomUI.Labs.Controls.Led.Segment.Layout; internal readonly record struct SegmentLayoutOptions( double CharacterHeight, diff --git a/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryFactory.cs b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryFactory.cs similarity index 98% rename from src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryFactory.cs rename to src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryFactory.cs index 07ebd5d..4790a09 100644 --- a/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryFactory.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryFactory.cs @@ -1,9 +1,9 @@ using Avalonia; -using AtomUI.Labs.Led.Segment; -using AtomUI.Labs.Led.Segment.Character; +using AtomUI.Labs.Controls.Led.Segment; +using AtomUI.Labs.Controls.Led.Segment.Character; using Avalonia.Media; -namespace AtomUI.Labs.Led.Segment.Rendering; +namespace AtomUI.Labs.Controls.Led.Segment.Rendering; internal static class SegmentGeometryFactory { diff --git a/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryItem.cs b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryItem.cs similarity index 54% rename from src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryItem.cs rename to src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryItem.cs index 69bb853..9faaf70 100644 --- a/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryItem.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryItem.cs @@ -1,7 +1,7 @@ -using AtomUI.Labs.Led.Segment.Character; +using AtomUI.Labs.Controls.Led.Segment.Character; using Avalonia.Media; -namespace AtomUI.Labs.Led.Segment.Rendering; +namespace AtomUI.Labs.Controls.Led.Segment.Rendering; internal readonly record struct SegmentGeometryItem( SegmentParts Part, diff --git a/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryOptions.cs b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryOptions.cs similarity index 73% rename from src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryOptions.cs rename to src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryOptions.cs index a3b4dd4..3f63900 100644 --- a/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometryOptions.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometryOptions.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Segment.Rendering; +namespace AtomUI.Labs.Controls.Led.Segment.Rendering; internal readonly record struct SegmentGeometryOptions( double Thickness, diff --git a/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometrySet.cs b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometrySet.cs similarity index 79% rename from src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometrySet.cs rename to src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometrySet.cs index cbc3957..f36c759 100644 --- a/src/AtomUI.Labs.Led/Segment/Rendering/SegmentGeometrySet.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentGeometrySet.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Segment.Rendering; +namespace AtomUI.Labs.Controls.Led.Segment.Rendering; internal sealed class SegmentGeometrySet { diff --git a/src/AtomUI.Labs.Led/Segment/Rendering/SegmentVisibleGeometry.cs b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentVisibleGeometry.cs similarity index 71% rename from src/AtomUI.Labs.Led/Segment/Rendering/SegmentVisibleGeometry.cs rename to src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentVisibleGeometry.cs index d199ee4..58a97d1 100644 --- a/src/AtomUI.Labs.Led/Segment/Rendering/SegmentVisibleGeometry.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/Rendering/SegmentVisibleGeometry.cs @@ -1,6 +1,6 @@ using Avalonia.Media; -namespace AtomUI.Labs.Led.Segment.Rendering; +namespace AtomUI.Labs.Controls.Led.Segment.Rendering; internal sealed record SegmentVisibleGeometry( Geometry? ActiveGeometry, diff --git a/src/AtomUI.Labs.Led/Segment/SegmentDisplay.cs b/src/AtomUI.Labs.Controls.Led/Segment/SegmentDisplay.cs similarity index 98% rename from src/AtomUI.Labs.Led/Segment/SegmentDisplay.cs rename to src/AtomUI.Labs.Controls.Led/Segment/SegmentDisplay.cs index 4749894..2acb8b9 100644 --- a/src/AtomUI.Labs.Led/Segment/SegmentDisplay.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/SegmentDisplay.cs @@ -2,15 +2,15 @@ using Avalonia.Automation.Peers; using Avalonia.Controls; using Avalonia.Layout; -using AtomUI.Labs.Led; -using AtomUI.Labs.Led.Glow; -using AtomUI.Labs.Led.Segment.Character; -using AtomUI.Labs.Led.Segment.Layout; -using AtomUI.Labs.Led.Segment.Rendering; +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.Led.Segment; +namespace AtomUI.Labs.Controls.Led.Segment; public class SegmentDisplay : Control { diff --git a/src/AtomUI.Labs.Led/Segment/SegmentDisplayAutomationPeer.cs b/src/AtomUI.Labs.Controls.Led/Segment/SegmentDisplayAutomationPeer.cs similarity index 93% rename from src/AtomUI.Labs.Led/Segment/SegmentDisplayAutomationPeer.cs rename to src/AtomUI.Labs.Controls.Led/Segment/SegmentDisplayAutomationPeer.cs index abba208..08ddb82 100644 --- a/src/AtomUI.Labs.Led/Segment/SegmentDisplayAutomationPeer.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/SegmentDisplayAutomationPeer.cs @@ -1,8 +1,8 @@ using Avalonia.Automation; using Avalonia.Automation.Peers; -using AtomUI.Labs.Led.Segment.Character; +using AtomUI.Labs.Controls.Led.Segment.Character; -namespace AtomUI.Labs.Led.Segment; +namespace AtomUI.Labs.Controls.Led.Segment; internal sealed class SegmentDisplayAutomationPeer : ControlAutomationPeer { diff --git a/src/AtomUI.Labs.Led/Segment/SegmentOverflowMode.cs b/src/AtomUI.Labs.Controls.Led/Segment/SegmentOverflowMode.cs similarity index 58% rename from src/AtomUI.Labs.Led/Segment/SegmentOverflowMode.cs rename to src/AtomUI.Labs.Controls.Led/Segment/SegmentOverflowMode.cs index 0db1335..c18b6b5 100644 --- a/src/AtomUI.Labs.Led/Segment/SegmentOverflowMode.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/SegmentOverflowMode.cs @@ -1,4 +1,4 @@ -namespace AtomUI.Labs.Led.Segment; +namespace AtomUI.Labs.Controls.Led.Segment; public enum SegmentOverflowMode { diff --git a/src/AtomUI.Labs.Led/Segment/SegmentValueSanitizer.cs b/src/AtomUI.Labs.Controls.Led/Segment/SegmentValueSanitizer.cs similarity index 97% rename from src/AtomUI.Labs.Led/Segment/SegmentValueSanitizer.cs rename to src/AtomUI.Labs.Controls.Led/Segment/SegmentValueSanitizer.cs index 9317ca8..edf0abb 100644 --- a/src/AtomUI.Labs.Led/Segment/SegmentValueSanitizer.cs +++ b/src/AtomUI.Labs.Controls.Led/Segment/SegmentValueSanitizer.cs @@ -1,6 +1,6 @@ using Avalonia; -namespace AtomUI.Labs.Led.Segment; +namespace AtomUI.Labs.Controls.Led.Segment; internal static class SegmentValueSanitizer { diff --git a/src/AtomUI.Labs.Led/Segment/Themes/SegmentDisplayTheme.axaml b/src/AtomUI.Labs.Controls.Led/Segment/Themes/SegmentDisplayTheme.axaml similarity index 91% rename from src/AtomUI.Labs.Led/Segment/Themes/SegmentDisplayTheme.axaml rename to src/AtomUI.Labs.Controls.Led/Segment/Themes/SegmentDisplayTheme.axaml index 80fda36..d5b156b 100644 --- a/src/AtomUI.Labs.Led/Segment/Themes/SegmentDisplayTheme.axaml +++ b/src/AtomUI.Labs.Controls.Led/Segment/Themes/SegmentDisplayTheme.axaml @@ -1,7 +1,7 @@ diff --git a/src/AtomUI.Labs.Led/Segment/Themes/SegmentThemes.axaml b/src/AtomUI.Labs.Controls.Led/Segment/Themes/SegmentThemes.axaml similarity index 100% rename from src/AtomUI.Labs.Led/Segment/Themes/SegmentThemes.axaml rename to src/AtomUI.Labs.Controls.Led/Segment/Themes/SegmentThemes.axaml diff --git a/src/AtomUI.Labs.Led/ThemeManagerBuilderExtensions.cs b/src/AtomUI.Labs.Controls.Led/ThemeManagerBuilderExtensions.cs similarity index 89% rename from src/AtomUI.Labs.Led/ThemeManagerBuilderExtensions.cs rename to src/AtomUI.Labs.Controls.Led/ThemeManagerBuilderExtensions.cs index 9250780..dca8515 100644 --- a/src/AtomUI.Labs.Led/ThemeManagerBuilderExtensions.cs +++ b/src/AtomUI.Labs.Controls.Led/ThemeManagerBuilderExtensions.cs @@ -1,6 +1,6 @@ using AtomUI.Theme; -namespace AtomUI.Labs.Led; +namespace AtomUI.Labs.Controls.Led; public static class LedThemeManagerBuilderExtensions { diff --git a/src/AtomUI.Labs.Led/Themes/LedThemes.axaml b/src/AtomUI.Labs.Controls.Led/Themes/LedThemes.axaml similarity index 100% rename from src/AtomUI.Labs.Led/Themes/LedThemes.axaml rename to src/AtomUI.Labs.Controls.Led/Themes/LedThemes.axaml diff --git a/tests/AtomUI.Labs.Led.Tests/AtomUI.Labs.Led.Tests.csproj b/tests/AtomUI.Labs.Controls.Led.Tests/AtomUI.Labs.Controls.Led.Tests.csproj similarity index 79% rename from tests/AtomUI.Labs.Led.Tests/AtomUI.Labs.Led.Tests.csproj rename to tests/AtomUI.Labs.Controls.Led.Tests/AtomUI.Labs.Controls.Led.Tests.csproj index b81a282..d35ab51 100644 --- a/tests/AtomUI.Labs.Led.Tests/AtomUI.Labs.Led.Tests.csproj +++ b/tests/AtomUI.Labs.Controls.Led.Tests/AtomUI.Labs.Controls.Led.Tests.csproj @@ -2,7 +2,7 @@ $(AtomUIDevelopTargetFramework) false - AtomUI.Labs.Led.Tests + AtomUI.Labs.Controls.Led.Tests @@ -14,7 +14,7 @@ - + diff --git a/tests/AtomUI.Labs.Led.Tests/AvaloniaTestApp.cs b/tests/AtomUI.Labs.Controls.Led.Tests/AvaloniaTestApp.cs similarity index 88% rename from tests/AtomUI.Labs.Led.Tests/AvaloniaTestApp.cs rename to tests/AtomUI.Labs.Controls.Led.Tests/AvaloniaTestApp.cs index 23eff91..fc2595d 100644 --- a/tests/AtomUI.Labs.Led.Tests/AvaloniaTestApp.cs +++ b/tests/AtomUI.Labs.Controls.Led.Tests/AvaloniaTestApp.cs @@ -1,14 +1,14 @@ using System.Threading; using AtomUI; -using AtomUI.Labs.Led; +using AtomUI.Labs.Controls.Led; using Avalonia; using Avalonia.Headless; using Xunit; -[assembly: AvaloniaTestApplication(typeof(AtomUI.Labs.Led.Tests.TestAppBuilder))] +[assembly: AvaloniaTestApplication(typeof(AtomUI.Labs.Controls.Led.Tests.TestAppBuilder))] [assembly: CollectionBehavior(DisableTestParallelization = true)] -namespace AtomUI.Labs.Led.Tests; +namespace AtomUI.Labs.Controls.Led.Tests; internal static class AvaloniaTestApp { diff --git a/tests/AtomUI.Labs.Led.Tests/Gallery/LedGlowWorkbenchLifecycleTests.cs b/tests/AtomUI.Labs.Controls.Led.Tests/Gallery/LedGlowWorkbenchLifecycleTests.cs similarity index 95% rename from tests/AtomUI.Labs.Led.Tests/Gallery/LedGlowWorkbenchLifecycleTests.cs rename to tests/AtomUI.Labs.Controls.Led.Tests/Gallery/LedGlowWorkbenchLifecycleTests.cs index 7c88343..f40648c 100644 --- a/tests/AtomUI.Labs.Led.Tests/Gallery/LedGlowWorkbenchLifecycleTests.cs +++ b/tests/AtomUI.Labs.Controls.Led.Tests/Gallery/LedGlowWorkbenchLifecycleTests.cs @@ -4,7 +4,7 @@ using Shouldly; using Xunit; -namespace AtomUI.Labs.Led.Tests.Gallery; +namespace AtomUI.Labs.Controls.Led.Tests.Gallery; public class LedGlowWorkbenchLifecycleTests { diff --git a/tests/AtomUI.Labs.Led.Tests/Glow/LedGlowPixelTests.cs b/tests/AtomUI.Labs.Controls.Led.Tests/Glow/LedGlowPixelTests.cs similarity index 97% rename from tests/AtomUI.Labs.Led.Tests/Glow/LedGlowPixelTests.cs rename to tests/AtomUI.Labs.Controls.Led.Tests/Glow/LedGlowPixelTests.cs index 8b56437..31f50ed 100644 --- a/tests/AtomUI.Labs.Led.Tests/Glow/LedGlowPixelTests.cs +++ b/tests/AtomUI.Labs.Controls.Led.Tests/Glow/LedGlowPixelTests.cs @@ -1,6 +1,6 @@ using System.Runtime.InteropServices; -using AtomUI.Labs.Led.Matrix; -using AtomUI.Labs.Led.Segment; +using AtomUI.Labs.Controls.Led.Matrix; +using AtomUI.Labs.Controls.Led.Segment; using Avalonia; using Avalonia.Controls; using Avalonia.Headless; @@ -10,7 +10,7 @@ using Shouldly; using Xunit; -namespace AtomUI.Labs.Led.Tests.Glow; +namespace AtomUI.Labs.Controls.Led.Tests.Glow; public class LedGlowPixelTests { diff --git a/tests/AtomUI.Labs.Led.Tests/Glow/LedGlowRendererTests.cs b/tests/AtomUI.Labs.Controls.Led.Tests/Glow/LedGlowRendererTests.cs similarity index 96% rename from tests/AtomUI.Labs.Led.Tests/Glow/LedGlowRendererTests.cs rename to tests/AtomUI.Labs.Controls.Led.Tests/Glow/LedGlowRendererTests.cs index 99b798c..49c8418 100644 --- a/tests/AtomUI.Labs.Led.Tests/Glow/LedGlowRendererTests.cs +++ b/tests/AtomUI.Labs.Controls.Led.Tests/Glow/LedGlowRendererTests.cs @@ -1,12 +1,12 @@ -using AtomUI.Labs.Led.Glow; -using AtomUI.Labs.Led.Matrix; -using AtomUI.Labs.Led.Segment; +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.Led.Tests.Glow; +namespace AtomUI.Labs.Controls.Led.Tests.Glow; public class LedGlowRendererTests { diff --git a/tests/AtomUI.Labs.Led.Tests/LedCharacterNormalizerTests.cs b/tests/AtomUI.Labs.Controls.Led.Tests/LedCharacterNormalizerTests.cs similarity index 86% rename from tests/AtomUI.Labs.Led.Tests/LedCharacterNormalizerTests.cs rename to tests/AtomUI.Labs.Controls.Led.Tests/LedCharacterNormalizerTests.cs index 0b50151..c5648a5 100644 --- a/tests/AtomUI.Labs.Led.Tests/LedCharacterNormalizerTests.cs +++ b/tests/AtomUI.Labs.Controls.Led.Tests/LedCharacterNormalizerTests.cs @@ -1,8 +1,8 @@ -using AtomUI.Labs.Led; +using AtomUI.Labs.Controls.Led; using Shouldly; using Xunit; -namespace AtomUI.Labs.Led.Tests; +namespace AtomUI.Labs.Controls.Led.Tests; public class LedCharacterNormalizerTests { diff --git a/tests/AtomUI.Labs.Led.Tests/LedDisplayLayoutMathTests.cs b/tests/AtomUI.Labs.Controls.Led.Tests/LedDisplayLayoutMathTests.cs similarity index 97% rename from tests/AtomUI.Labs.Led.Tests/LedDisplayLayoutMathTests.cs rename to tests/AtomUI.Labs.Controls.Led.Tests/LedDisplayLayoutMathTests.cs index 8ea2194..5038d52 100644 --- a/tests/AtomUI.Labs.Led.Tests/LedDisplayLayoutMathTests.cs +++ b/tests/AtomUI.Labs.Controls.Led.Tests/LedDisplayLayoutMathTests.cs @@ -1,10 +1,10 @@ -using AtomUI.Labs.Led; +using AtomUI.Labs.Controls.Led; using Avalonia; using Avalonia.Layout; using Shouldly; using Xunit; -namespace AtomUI.Labs.Led.Tests; +namespace AtomUI.Labs.Controls.Led.Tests; public class LedDisplayLayoutMathTests { diff --git a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixAxamlHost.axaml b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixAxamlHost.axaml similarity index 90% rename from tests/AtomUI.Labs.Led.Tests/Matrix/MatrixAxamlHost.axaml rename to tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixAxamlHost.axaml index 4ab7f46..4b5532c 100644 --- a/tests/AtomUI.Labs.Led.Tests/Matrix/MatrixAxamlHost.axaml +++ b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixAxamlHost.axaml @@ -1,7 +1,7 @@ + x:Class="AtomUI.Labs.Controls.Led.Tests.Matrix.MatrixAxamlHost"> + x:Class="AtomUI.Labs.Controls.Led.Tests.Segment.SegmentAxamlHost"> false enable enable - AtomUI.Labs.Led.GlowPrototype.Desktop + AtomUI.Labs.Controls.Led.GlowPrototype.Desktop @@ -13,6 +13,6 @@ - + diff --git a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/FormalGlowDesktop.cs b/tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/FormalGlowDesktop.cs similarity index 98% rename from tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/FormalGlowDesktop.cs rename to tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/FormalGlowDesktop.cs index 34421e5..8a3fc8b 100644 --- a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/FormalGlowDesktop.cs +++ b/tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/FormalGlowDesktop.cs @@ -1,8 +1,8 @@ using System.Diagnostics; using System.Globalization; using System.Text; -using AtomUI.Labs.Led.Matrix; -using AtomUI.Labs.Led.Segment; +using AtomUI.Labs.Controls.Led.Matrix; +using AtomUI.Labs.Controls.Led.Segment; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -10,7 +10,7 @@ using Avalonia.Media; using Avalonia.Threading; -namespace AtomUI.Labs.Led.GlowPrototype.Desktop; +namespace AtomUI.Labs.Controls.Led.GlowPrototype.Desktop; internal enum FormalGlowDesktopControl { diff --git a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowDesktopBenchmark.cs b/tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/GlowDesktopBenchmark.cs similarity index 99% rename from tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowDesktopBenchmark.cs rename to tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/GlowDesktopBenchmark.cs index 4973b17..8ee59be 100644 --- a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowDesktopBenchmark.cs +++ b/tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/GlowDesktopBenchmark.cs @@ -1,7 +1,7 @@ using System.Diagnostics; using System.Globalization; using System.Text; -using AtomUI.Labs.Led.Performance; +using AtomUI.Labs.Controls.Led.Performance; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -9,7 +9,7 @@ using Avalonia.Media; using Avalonia.Threading; -namespace AtomUI.Labs.Led.GlowPrototype.Desktop; +namespace AtomUI.Labs.Controls.Led.GlowPrototype.Desktop; internal enum GlowDesktopBenchmarkRoute { diff --git a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowLifecycle.cs b/tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/GlowLifecycle.cs similarity index 99% rename from tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowLifecycle.cs rename to tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/GlowLifecycle.cs index 01e5603..a013492 100644 --- a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowLifecycle.cs +++ b/tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/GlowLifecycle.cs @@ -7,7 +7,7 @@ using Avalonia.Media; using Avalonia.Threading; -namespace AtomUI.Labs.Led.GlowPrototype.Desktop; +namespace AtomUI.Labs.Controls.Led.GlowPrototype.Desktop; internal sealed record GlowLifecycleOptions( int Cycles, diff --git a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowPrototypeApplication.cs b/tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/GlowPrototypeApplication.cs similarity index 93% rename from tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowPrototypeApplication.cs rename to tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/GlowPrototypeApplication.cs index 1434c37..fe48298 100644 --- a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowPrototypeApplication.cs +++ b/tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/GlowPrototypeApplication.cs @@ -1,7 +1,7 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; -namespace AtomUI.Labs.Led.GlowPrototype.Desktop; +namespace AtomUI.Labs.Controls.Led.GlowPrototype.Desktop; internal sealed class GlowPrototypeApplication : Application { diff --git a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowPrototypeWindow.cs b/tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/GlowPrototypeWindow.cs similarity index 98% rename from tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowPrototypeWindow.cs rename to tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/GlowPrototypeWindow.cs index 77939fc..15baf10 100644 --- a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/GlowPrototypeWindow.cs +++ b/tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/GlowPrototypeWindow.cs @@ -1,11 +1,11 @@ -using AtomUI.Labs.Led.Performance; +using AtomUI.Labs.Controls.Led.Performance; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Layout; using Avalonia.Media; -namespace AtomUI.Labs.Led.GlowPrototype.Desktop; +namespace AtomUI.Labs.Controls.Led.GlowPrototype.Desktop; internal sealed class GlowPrototypeWindow : Window { diff --git a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/Program.cs b/tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/Program.cs similarity index 87% rename from tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/Program.cs rename to tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/Program.cs index 7ff9430..64cd0c8 100644 --- a/tools/performances/AtomUI.Labs.Led.GlowPrototype.Desktop/Program.cs +++ b/tools/performances/AtomUI.Labs.Controls.Led.GlowPrototype.Desktop/Program.cs @@ -1,6 +1,6 @@ using Avalonia; -namespace AtomUI.Labs.Led.GlowPrototype.Desktop; +namespace AtomUI.Labs.Controls.Led.GlowPrototype.Desktop; internal static class Program { diff --git a/tools/performances/AtomUI.Labs.Led.Performance/AtomUI.Labs.Led.Performance.csproj b/tools/performances/AtomUI.Labs.Controls.Led.Performance/AtomUI.Labs.Controls.Led.Performance.csproj similarity index 65% rename from tools/performances/AtomUI.Labs.Led.Performance/AtomUI.Labs.Led.Performance.csproj rename to tools/performances/AtomUI.Labs.Controls.Led.Performance/AtomUI.Labs.Controls.Led.Performance.csproj index 55c35b6..7a994c1 100644 --- a/tools/performances/AtomUI.Labs.Led.Performance/AtomUI.Labs.Led.Performance.csproj +++ b/tools/performances/AtomUI.Labs.Controls.Led.Performance/AtomUI.Labs.Controls.Led.Performance.csproj @@ -5,7 +5,7 @@ false enable enable - AtomUI.Labs.Led.Performance + AtomUI.Labs.Controls.Led.Performance @@ -14,10 +14,10 @@ - + - + diff --git a/tools/performances/AtomUI.Labs.Led.Performance/Glow/FormalGlowPerformanceRunner.cs b/tools/performances/AtomUI.Labs.Controls.Led.Performance/Glow/FormalGlowPerformanceRunner.cs similarity index 99% rename from tools/performances/AtomUI.Labs.Led.Performance/Glow/FormalGlowPerformanceRunner.cs rename to tools/performances/AtomUI.Labs.Controls.Led.Performance/Glow/FormalGlowPerformanceRunner.cs index bbf4e51..9e1f30a 100644 --- a/tools/performances/AtomUI.Labs.Led.Performance/Glow/FormalGlowPerformanceRunner.cs +++ b/tools/performances/AtomUI.Labs.Controls.Led.Performance/Glow/FormalGlowPerformanceRunner.cs @@ -1,13 +1,13 @@ using System.Diagnostics; using System.Globalization; using System.Text; -using AtomUI.Labs.Led.Matrix; -using AtomUI.Labs.Led.Segment; +using AtomUI.Labs.Controls.Led.Matrix; +using AtomUI.Labs.Controls.Led.Segment; using Avalonia; using Avalonia.Controls; using Avalonia.Media; -namespace AtomUI.Labs.Led.Performance; +namespace AtomUI.Labs.Controls.Led.Performance; internal static class FormalGlowPerformanceRunner { diff --git a/tools/performances/AtomUI.Labs.Led.Performance/Glow/GlowPrototypeRunner.cs b/tools/performances/AtomUI.Labs.Controls.Led.Performance/Glow/GlowPrototypeRunner.cs similarity index 99% rename from tools/performances/AtomUI.Labs.Led.Performance/Glow/GlowPrototypeRunner.cs rename to tools/performances/AtomUI.Labs.Controls.Led.Performance/Glow/GlowPrototypeRunner.cs index a69aa04..c2c20ee 100644 --- a/tools/performances/AtomUI.Labs.Led.Performance/Glow/GlowPrototypeRunner.cs +++ b/tools/performances/AtomUI.Labs.Controls.Led.Performance/Glow/GlowPrototypeRunner.cs @@ -9,7 +9,7 @@ using Avalonia.Platform; using Avalonia.Threading; -namespace AtomUI.Labs.Led.Performance; +namespace AtomUI.Labs.Controls.Led.Performance; internal static class GlowPrototypeRunner { diff --git a/tools/performances/AtomUI.Labs.Led.Performance/Program.cs b/tools/performances/AtomUI.Labs.Controls.Led.Performance/Program.cs similarity index 99% rename from tools/performances/AtomUI.Labs.Led.Performance/Program.cs rename to tools/performances/AtomUI.Labs.Controls.Led.Performance/Program.cs index 23f9a8d..0e0b046 100644 --- a/tools/performances/AtomUI.Labs.Led.Performance/Program.cs +++ b/tools/performances/AtomUI.Labs.Controls.Led.Performance/Program.cs @@ -1,12 +1,12 @@ using System.Diagnostics; using System.Globalization; using System.Text; -using AtomUI.Labs.Led.Matrix; +using AtomUI.Labs.Controls.Led.Matrix; using Avalonia; using Avalonia.Headless; using Avalonia.Media; -namespace AtomUI.Labs.Led.Performance; +namespace AtomUI.Labs.Controls.Led.Performance; internal static class Program { @@ -539,7 +539,7 @@ private static string RenderMarkdown( builder.AppendLine(); builder.AppendLine($"- Date: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}"); builder.AppendLine("- Configuration: Release, .NET 10"); - builder.AppendLine("- Runner: `tools/performances/AtomUI.Labs.Led.Performance --count --frames --soak-frames `"); + builder.AppendLine("- Runner: `tools/performances/AtomUI.Labs.Controls.Led.Performance --count --frames --soak-frames `"); builder.AppendLine("- Scope: CPU-side layout and DrawingGroup command submission; excludes GPU/platform presentation cost"); builder.AppendLine(); builder.AppendLine("| Scenario | Characters | Updates | Total ms | us/update | KB total | bytes/update | Geometry commands |"); From d2ebaf386f957914f14118527fa99f32e98a3bc9 Mon Sep 17 00:00:00 2001 From: youname Date: Fri, 24 Jul 2026 12:58:22 +0800 Subject: [PATCH 31/33] optimized Segment Glow Workbench examples --- .../Led/ShowCaseControls/LedGlowWorkbench.cs | 325 +++++++++++++++--- .../Led/Views/LedSegmentShowCase.axaml | 2 +- .../AvaloniaTestApp.cs | 3 +- .../Gallery/LedGlowWorkbenchLifecycleTests.cs | 100 ++++++ 4 files changed, 377 insertions(+), 53 deletions(-) diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs index 9941ecb..c54a8dc 100644 --- a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs +++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/ShowCaseControls/LedGlowWorkbench.cs @@ -9,6 +9,11 @@ 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; @@ -25,57 +30,124 @@ public sealed class LedGlowWorkbench : StackPanel private readonly MatrixDisplay _matrix; private readonly SegmentDisplay _segment; - private readonly CheckBox _enabled; - private readonly ComboBox _brush; - private readonly ComboBox _mode; - private readonly Slider _opacity; - private readonly Slider _radius; + 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 CheckBox { Content = "Enabled", IsChecked = true }; - _brush = new ComboBox + _enabled = new AtomToggleSwitch + { + OnContent = "Enabled", + OffContent = "Disabled", + IsChecked = true + }; + _brushPreset = new AtomComboBox { ItemsSource = BrushOptions.Select(option => option.Name).ToArray(), SelectedIndex = 0, - MinWidth = 130 + Width = 150 }; - _mode = new ComboBox + _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 = 130 + MinWidth = 300, + IsExpanding = true, + HorizontalAlignment = HorizontalAlignment.Stretch }; - _opacity = new Slider + _opacity = new AtomSlider { Minimum = 0, Maximum = 1, Value = 0.35, TickFrequency = 0.05, - Width = 220 + HorizontalAlignment = HorizontalAlignment.Stretch }; - _radius = new Slider + _radius = new AtomSlider { Minimum = 0, Maximum = 24, Value = 6, TickFrequency = 1, - Width = 220 + HorizontalAlignment = HorizontalAlignment.Stretch }; _opacityValue = new TextBlock { Width = 48, VerticalAlignment = VerticalAlignment.Center }; _radiusValue = new TextBlock { Width = 48, VerticalAlignment = VerticalAlignment.Center }; @@ -86,19 +158,7 @@ public LedGlowWorkbench() FontSize = 18, FontWeight = FontWeight.SemiBold }); - Children.Add(new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 16, - Children = - { - CreateLabeledControl("State", _enabled), - CreateLabeledControl("Brush", _brush), - CreateLabeledControl("Mode", _mode) - } - }); - Children.Add(CreateSliderRow("Opacity", _opacity, _opacityValue)); - Children.Add(CreateSliderRow("Radius", _radius, _radiusValue)); + Children.Add(CreateConfigurationPanel()); Children.Add(new Grid { ColumnDefinitions = new ColumnDefinitions("*,*"), @@ -111,7 +171,8 @@ public LedGlowWorkbench() }); _enabled.IsCheckedChanged += HandleConfigurationChanged; - _brush.SelectionChanged += HandleConfigurationChanged; + _brushPreset.SelectionChanged += HandleBrushPresetChanged; + _brushHex.PropertyChanged += HandleBrushHexPropertyChanged; _mode.SelectionChanged += HandleConfigurationChanged; _opacity.PropertyChanged += HandleSliderPropertyChanged; _radius.PropertyChanged += HandleSliderPropertyChanged; @@ -120,6 +181,78 @@ public LedGlowWorkbench() 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; @@ -137,6 +270,58 @@ 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) @@ -282,41 +467,79 @@ private void CancelAnimations() private IBrush CreateSelectedBrush() { - var index = Math.Clamp(_brush.SelectedIndex, 0, BrushOptions.Length - 1); - return new SolidColorBrush(BrushOptions[index].Color); + return new SolidColorBrush(_selectedBrushColor); } - private static Control CreateLabeledControl(string label, Control control) + private void SetBrushValidationError(bool hasError) { - return new StackPanel + _brushValidation.IsVisible = hasError; + } + + private static int FindBrushPresetIndex(Color color) + { + for (var index = 0; index < BrushOptions.Length; index++) { - Spacing = 4, - Children = + if (BrushOptions[index].Color == color) { - new TextBlock { Text = label, FontWeight = FontWeight.SemiBold }, - control + return index; } - }; + } + + return -1; } - private static Control CreateSliderRow(string label, Slider slider, TextBlock value) + private static string FormatHexColor(Color color) { - return new StackPanel + 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] != '#') { - Orientation = Orientation.Horizontal, - Spacing = 10, - Children = + 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)) { - new TextBlock - { - Text = label, - Width = 64, - VerticalAlignment = VerticalAlignment.Center - }, - slider, - value + 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) diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml index df2ee54..00593c3 100644 --- a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml +++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedSegmentShowCase.axaml @@ -117,7 +117,7 @@ diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/AvaloniaTestApp.cs b/tests/AtomUI.Labs.Controls.Led.Tests/AvaloniaTestApp.cs index fc2595d..756ced4 100644 --- a/tests/AtomUI.Labs.Controls.Led.Tests/AvaloniaTestApp.cs +++ b/tests/AtomUI.Labs.Controls.Led.Tests/AvaloniaTestApp.cs @@ -1,5 +1,6 @@ using System.Threading; using AtomUI; +using AtomUI.Desktop.Controls; using AtomUI.Labs.Controls.Led; using Avalonia; using Avalonia.Headless; @@ -52,6 +53,6 @@ internal sealed class TestApplication : Application { public override void Initialize() { - this.UseAtomUI(builder => builder.UseLed()); + 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 index f40648c..c399a4b 100644 --- a/tests/AtomUI.Labs.Controls.Led.Tests/Gallery/LedGlowWorkbenchLifecycleTests.cs +++ b/tests/AtomUI.Labs.Controls.Led.Tests/Gallery/LedGlowWorkbenchLifecycleTests.cs @@ -1,5 +1,6 @@ using AtomUILabsGallery.ShowCases.Led; using Avalonia.Controls; +using Avalonia.Media; using Avalonia.Threading; using Shouldly; using Xunit; @@ -25,6 +26,100 @@ public void DetachAndReattach_ShouldCancelAndRestartConfiguredAnimation() 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 }; @@ -41,4 +136,9 @@ private static void ShowAndClose(LedGlowWorkbench workbench) Dispatcher.UIThread.RunJobs(); } } + + private static Color GetColor(IBrush? brush) + { + return brush.ShouldBeOfType().Color; + } } From 8a7fbfa1576e7ea1f4c854d6af47fe1fd965e4f9 Mon Sep 17 00:00:00 2001 From: youname Date: Fri, 24 Jul 2026 14:51:00 +0800 Subject: [PATCH 32/33] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=BA=86Marquee=20Matr?= =?UTF-8?q?ixDisplay=20Width=3D"620"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DataDisplay/Led/Views/LedMatrixShowCase.axaml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml index b20d1ef..0356f30 100644 --- a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml +++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml @@ -149,7 +149,6 @@ - - - - - - - From b0d113fd9c005a36547b1357f1606aa3f499d3a8 Mon Sep 17 00:00:00 2001 From: 294740219 <294740219@qq.com> Date: Thu, 30 Jul 2026 21:53:45 +0800 Subject: [PATCH 33/33] rename labs namespace prefix to atom.labs --- .../Led/Views/LedMatrixShowCase.axaml | 26 +++++++++---------- .../Led/Views/LedSegmentShowCase.axaml | 14 +++++----- docs/controls/led/matrix-implementation.md | 2 +- .../Properties/AssemblyInfo.cs | 2 +- src/AtomUI.Labs.Controls.Led/README.nuget.md | 6 ++--- .../Matrix/MatrixAxamlHost.axaml | 4 +-- .../Segment/SegmentAxamlHost.axaml | 4 +-- 7 files changed, 29 insertions(+), 29 deletions(-) diff --git a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml index 0356f30..96f95cc 100644 --- a/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml +++ b/controlgallery/AtomUILabsGallery/ShowCases/DataDisplay/Led/Views/LedMatrixShowCase.axaml @@ -2,7 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:atom="https://atomui.net" xmlns:gallery="https://atomui.net/oss-controls/gallery" - xmlns:labs="https://atomui.net/labs" + xmlns:atom.labs="https://atomui.net/labs" xmlns:led="using:AtomUILabsGallery.ShowCases.Led" x:Class="AtomUILabsGallery.ShowCases.Led.LedMatrixShowCase" x:DataType="led:LedMatrixViewModel"> @@ -32,8 +32,8 @@ - - + - @@ -57,16 +57,16 @@ - - - - - - - - - @@ -32,12 +32,12 @@ - - - - - - ``` diff --git a/src/AtomUI.Labs.Controls.Led/Properties/AssemblyInfo.cs b/src/AtomUI.Labs.Controls.Led/Properties/AssemblyInfo.cs index 707dc7e..3af59f6 100644 --- a/src/AtomUI.Labs.Controls.Led/Properties/AssemblyInfo.cs +++ b/src/AtomUI.Labs.Controls.Led/Properties/AssemblyInfo.cs @@ -1,6 +1,6 @@ using Avalonia.Metadata; -[assembly: XmlnsPrefix("https://atomui.net/labs", "labs")] +[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 index db9da9f..c3c3c31 100644 --- a/src/AtomUI.Labs.Controls.Led/README.nuget.md +++ b/src/AtomUI.Labs.Controls.Led/README.nuget.md @@ -23,10 +23,10 @@ this.UseAtomUI(builder => builder.UseLed()); ```xml + xmlns:atom.labs="https://atomui.net/labs"> - - + + ``` diff --git a/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixAxamlHost.axaml b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixAxamlHost.axaml index 4b5532c..c333747 100644 --- a/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixAxamlHost.axaml +++ b/tests/AtomUI.Labs.Controls.Led.Tests/Matrix/MatrixAxamlHost.axaml @@ -1,8 +1,8 @@ - -