diff --git a/docs/AI/generated/llms/controls/float-button/index-cn.md b/docs/AI/generated/llms/controls/float-button/index-cn.md
index 6382bec72..63bca6ccc 100644
--- a/docs/AI/generated/llms/controls/float-button/index-cn.md
+++ b/docs/AI/generated/llms/controls/float-button/index-cn.md
@@ -167,19 +167,14 @@ Gallery key:`ExamplesContent` / item `12`
-
-
-
-
+
+
diff --git a/docs/AI/generated/llms/llms-full-cn.txt b/docs/AI/generated/llms/llms-full-cn.txt
index ef7f81667..6a54347dd 100644
--- a/docs/AI/generated/llms/llms-full-cn.txt
+++ b/docs/AI/generated/llms/llms-full-cn.txt
@@ -486,19 +486,14 @@ Gallery key:`ExamplesContent` / item `12`
-
-
-
-
+
+
diff --git a/docs/controls/desktop/general/float-button/implementation.md b/docs/controls/desktop/general/float-button/implementation.md
index 45d223e40..24ccf04a7 100644
--- a/docs/controls/desktop/general/float-button/implementation.md
+++ b/docs/controls/desktop/general/float-button/implementation.md
@@ -178,8 +178,9 @@ FloatButton 的交互事件应从输入源收敛到控件级语义事件:
- 进度计算:`maxScroll = max(Extent.Height - Viewport.Height, 0)`,`ScrollProgress = maxScroll > 0 ? Clamp(Offset.Y / maxScroll, 0, 1) : 0`。进度只在 `Target` 的 `ScrollChanged` 与控件 `OnLoaded` 两个入口更新;Avalonia `ScrollChanged` 在 extent/viewport 变化时同样触发,内容尺寸变化(等效 resize 场景)已被覆盖,不引入额外的 SizeChanged 监听。
- 绘制实现:`BackTopProgressRing` 自定义 `Render` 绘制轨道与进度指示。轨道是圆角矩形描边,`CornerRadius` 为高度一半(Circle 形状由基类尺寸同步设置)时即为整圆;进度指示是以环中心为顶点、半径取环矩形半对角线的扇形填充,从 12 点方向(-90°)顺时针扫 `360 × progress` 度(进度满 100% 时扇形退化,直接用进度色填充整个环带),再裁剪到环带。
+- 环几何不变量:`CalculateRingMetrics` 统一推导三个边界——外边界半径等于按钮自身圆角半径(与按钮轮廓完全重合,圆角处不留缝隙),中心线半径 = 外边界半径 − 半线宽,内边界半径 = 外边界半径 − 线宽;对应矩形依次为 `bounds`、`bounds.Deflate(半线宽)`、`bounds.Deflate(线宽)`。`CornerRadius` 是**外边界**的半径语义,不是中心线半径:把它当中心线使用会让外缘比按钮轮廓大出半线宽,在四个圆角处露出背景与环之间的缝隙(圆形因半径被 clamp 到尺寸一半而恰好不暴露)。
- ControlTheme 兜底理由:扇形角度是随滚动连续变化的运行时值,ControlTheme selector/Setter 无法表达,故按主题绑定优先约束的兜底条款采用自定义 Render;线宽、颜色与显隐等静态契约仍由 `BackTopFloatButtonTheme.axaml` 注入——`StrokeThickness` 取 `LineWidthBold`、`TrackBrush` 取 `ColorBorderSecondary`、`IndicatorBrush` 取 `ColorPrimary`,显隐由 `^[IsShowProgress=True] /template/ atomc|BackTopProgressRing#ProgressRing` selector 控制,默认 `IsVisible=False`。
-- Square 环带裁剪:进度扇形通过 `PushGeometryClip` 裁剪到「外圆角矩形 − 内圆角矩形」的 `CombinedGeometry`(`Exclude`)环带,Circle 与 Square 复用同一渲染路径。扇形半径取半对角线以保证覆盖环带最外点;环带以 `ringRect` 为中心线、厚度为线宽:外边界为 `ringRect.Inflate(半线宽)`、内边界为 `ringRect.Deflate(半线宽)`,与轨道描边带重合。Circle 形状下环带为同心圆环,扇形与环带的交集即原圆弧进度带。
+- Square 环带裁剪:进度扇形通过 `PushGeometryClip` 裁剪到「外圆角矩形 − 内圆角矩形」的 `CombinedGeometry`(`Exclude`)环带,Circle 与 Square 复用同一渲染路径。扇形半径取半对角线以保证覆盖环带最外点。Circle 形状下环带为同心圆环,扇形与环带的交集即原圆弧进度带。
实现文档不逐行解释私有方法。若某个私有算法成为稳定维护入口,应在本节补充算法不变量,而不是把代码复述为说明书。
diff --git a/src/AtomUI.Controls/FloatButton/BackTopProgressRing.cs b/src/AtomUI.Controls/FloatButton/BackTopProgressRing.cs
index 58b5e761b..06316a31a 100644
--- a/src/AtomUI.Controls/FloatButton/BackTopProgressRing.cs
+++ b/src/AtomUI.Controls/FloatButton/BackTopProgressRing.cs
@@ -68,6 +68,28 @@ internal class BackTopProgressRing : Control
private IPen? _trackPen;
+ ///
+ /// 计算环的几何:外/中心线/内边界矩形与对应圆角半径。
+ /// 外边界半径必须等于按钮自身的圆角半径,使环的外缘与按钮轮廓完全重合;
+ /// 中心线半径为外边界半径减去半线宽,内边界半径为外边界半径减去线宽。
+ ///
+ internal static BackTopProgressRingMetrics CalculateRingMetrics(
+ Size size, double cornerRadius, double thickness)
+ {
+ var outerRect = new Rect(size);
+ var centerlineRect = outerRect.Deflate(thickness / 2);
+ var innerRect = outerRect.Deflate(thickness);
+ var halfThickness = thickness / 2;
+
+ var outerLimit = Math.Min(outerRect.Width, outerRect.Height) / 2;
+ var outerRadius = Math.Clamp(cornerRadius, 0d, outerLimit);
+ var centerlineRadius = Math.Max(0d, outerRadius - halfThickness);
+ var innerRadius = Math.Max(0d, outerRadius - thickness);
+
+ return new BackTopProgressRingMetrics(outerRect, centerlineRect, innerRect,
+ outerRadius, centerlineRadius, innerRadius);
+ }
+
public override void Render(DrawingContext context)
{
var bounds = new Rect(Bounds.Size);
@@ -77,15 +99,14 @@ internal class BackTopProgressRing : Control
return;
}
- // 描边带完全落在控件边界内,等价 antd border-box 内的边框环
- var ringRect = bounds.Deflate(thickness / 2);
- var radius = EffectiveRadius(ringRect);
+ var metrics = CalculateRingMetrics(bounds.Size, CornerRadius.TopLeft, thickness);
PenUtils.TryModifyOrCreate(ref _trackPen, TrackBrush, thickness);
if (_trackPen is not null)
{
- // CornerRadius=尺寸一半(Circle 由 AbstractFloatButton.OnSizeChanged 设置)时即为圆
- context.DrawRectangle(null, _trackPen, ringRect, radius, radius);
+ // 轨道描边以中心线为基准;外缘与按钮轮廓重合,两种形状共用同一路径
+ context.DrawRectangle(null, _trackPen, metrics.CenterlineRect,
+ metrics.CenterlineRadius, metrics.CenterlineRadius);
}
var sweepAngle = 360d * Math.Clamp(Progress, 0d, 1d);
@@ -95,14 +116,11 @@ internal class BackTopProgressRing : Control
}
// 进度扇形填充裁剪到「外圆角矩形−内圆角矩形」环带,Circle/Square 统一路径。
- // 环带以 ringRect 为中心线、厚度为线宽,与轨道描边带重合:
- // 外边界 = ringRect.Inflate(半线宽),内边界 = ringRect.Deflate(半线宽)。
- // 指示器必须是扇形填充而非 ringRect 的内切圆弧描边:Square 形状下圆角方向的
+ // 指示器必须是扇形填充而非环矩形的内切圆弧描边:Square 形状下圆角方向的
// 内切圆弧半径小于外边界,会落入内孔几何被裁掉,进度环退化为四段侧边短线
- var halfThickness = thickness / 2;
- var bandGeometry = new CombinedGeometry(GeometryCombineMode.Exclude,
- BuildRoundedRectGeometry(ringRect.Inflate(halfThickness), radius + halfThickness),
- BuildRoundedRectGeometry(ringRect.Deflate(halfThickness), Math.Max(0d, radius - halfThickness)));
+ var bandGeometry = new CombinedGeometry(GeometryCombineMode.Exclude,
+ BuildRoundedRectGeometry(metrics.OuterRect, metrics.OuterRadius),
+ BuildRoundedRectGeometry(metrics.InnerRect, metrics.InnerRadius));
if (sweepAngle >= 360d)
{
@@ -111,12 +129,12 @@ internal class BackTopProgressRing : Control
return;
}
- // 以 ringRect.Center 为顶点的扇形,半径取半对角线以覆盖环带最外点,
+ // 以环中心为顶点的扇形,半径取半对角线以覆盖环带最外点,
// 从 -90°(12 点方向)顺时针扫 sweepAngle。
// 角度→点换算与 CommonShapeBuilder.GetRingPoint 同约定:x = cx + r·cos(deg),y = cy + r·sin(deg)
- var center = ringRect.Center;
- var sectorRadius = Math.Sqrt(ringRect.Width * ringRect.Width / 4
- + ringRect.Height * ringRect.Height / 4);
+ var center = metrics.CenterlineRect.Center;
+ var sectorRadius = Math.Sqrt(metrics.CenterlineRect.Width * metrics.CenterlineRect.Width / 4
+ + metrics.CenterlineRect.Height * metrics.CenterlineRect.Height / 4);
var startPoint = new Point(
center.X + sectorRadius * Math.Cos(MathUtils.Deg2Rad(-90d)),
@@ -141,11 +159,6 @@ internal class BackTopProgressRing : Control
}
}
- private double EffectiveRadius(Rect rect)
- {
- return Math.Clamp(CornerRadius.TopLeft, 0d, Math.Min(rect.Width, rect.Height) / 2);
- }
-
private static StreamGeometry BuildRoundedRectGeometry(Rect rect, double radius)
{
var geometry = new StreamGeometry();
@@ -183,3 +196,15 @@ internal class BackTopProgressRing : Control
return geometry;
}
}
+
+///
+/// 进度环的几何参数。外边界为按钮轮廓(半径等于按钮圆角半径),
+/// 中心线与内边界由线宽向内偏移得到。
+///
+internal readonly record struct BackTopProgressRingMetrics(
+ Rect OuterRect,
+ Rect CenterlineRect,
+ Rect InnerRect,
+ double OuterRadius,
+ double CenterlineRadius,
+ double InnerRadius);
diff --git a/tests/AtomUI.Desktop.Controls.Tests/FloatButton/BackTopFloatButtonProgressTests.cs b/tests/AtomUI.Desktop.Controls.Tests/FloatButton/BackTopFloatButtonProgressTests.cs
index 89626176e..2a2b8a013 100644
--- a/tests/AtomUI.Desktop.Controls.Tests/FloatButton/BackTopFloatButtonProgressTests.cs
+++ b/tests/AtomUI.Desktop.Controls.Tests/FloatButton/BackTopFloatButtonProgressTests.cs
@@ -1,6 +1,7 @@
using AtomUI.Controls;
using AtomUI.Controls.Commons;
using AtomUI.Controls.Primitives;
+using AtomUI.Utils;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Threading;
@@ -293,4 +294,83 @@ public class BackTopFloatButtonProgressTests
window.Close();
}
}
+
+ [Theory]
+ [InlineData(40d, 8d)] // Square:CornerRadius = BorderRadiusLG
+ [InlineData(40d, 20d)] // Circle:CornerRadius = Height / 2
+ public void BackTop_Ring_OuterEdge_Should_Match_Button_CornerRadius(double size, double cornerRadius)
+ {
+ // 环的外缘必须与按钮轮廓完全重合(半径 = 按钮圆角半径),
+ // 否则圆角处会露出背景与环之间的缝隙。
+ var metrics = BackTopProgressRing.CalculateRingMetrics(new Size(size, size), cornerRadius, 2d);
+
+ metrics.OuterRadius.ShouldBe(cornerRadius, 0.0001d);
+ metrics.CenterlineRadius.ShouldBe(cornerRadius - 1d, 0.0001d);
+ metrics.InnerRadius.ShouldBe(cornerRadius - 2d, 0.0001d);
+ metrics.OuterRect.ShouldBe(new Rect(new Size(size, size)));
+ }
+
+ [Theory]
+ [InlineData(40d, 8d)] // Square
+ [InlineData(40d, 20d)] // Circle
+ public void BackTop_Ring_Band_Should_Cover_Button_Outline(double size, double cornerRadius)
+ {
+ // 沿按钮轮廓的四个圆角采样:这些点必须落在环带内(在外边界内、不在内孔里)。
+ // 这是圆角缝隙的直接几何判据——缝隙正是「按钮轮廓上未被环带覆盖」的区域。
+ // 使用自建圆角矩形包含判据而非 geometry.FillContains:后者在 headless 环境
+ // 下对 StreamGeometry 的命中测试不可靠(几何中心点都会被判为外部)。
+ const double thickness = 2d;
+ var metrics = BackTopProgressRing.CalculateRingMetrics(new Size(size, size), cornerRadius, thickness);
+
+ // 稍向内采样 0.05px,避免恰好落在边界上的浮点歧义。
+ // 每个圆角只采样其「向外」的 90° 象限:这些点才真正落在按钮轮廓上。
+ var sampleRadius = cornerRadius - 0.05d;
+ var cornerSamples = new[]
+ {
+ (Center: new Point(cornerRadius, cornerRadius), StartAngle: 180d), // 左上
+ (Center: new Point(size - cornerRadius, cornerRadius), StartAngle: 270d), // 右上
+ (Center: new Point(size - cornerRadius, size - cornerRadius), StartAngle: 0d), // 右下
+ (Center: new Point(cornerRadius, size - cornerRadius), StartAngle: 90d) // 左下
+ };
+
+ foreach (var (cornerCenter, startAngle) in cornerSamples)
+ {
+ for (var offset = 0d; offset <= 90d; offset += 10d)
+ {
+ var rad = MathUtils.Deg2Rad(startAngle + offset);
+ var point = new Point(
+ cornerCenter.X + sampleRadius * Math.Cos(rad),
+ cornerCenter.Y + sampleRadius * Math.Sin(rad));
+
+ InsideRoundedRect(metrics.OuterRect, metrics.OuterRadius, point)
+ .ShouldBeTrue($"outline point {point} must be covered by ring outer boundary");
+ InsideRoundedRect(metrics.InnerRect, metrics.InnerRadius, point)
+ .ShouldBeFalse($"outline point {point} must not fall inside ring inner hole");
+ }
+ }
+ }
+
+ private static bool InsideRoundedRect(Rect rect, double radius, Point point)
+ {
+ const double epsilon = 1e-9;
+ if (point.X < rect.Left - epsilon || point.X > rect.Right + epsilon ||
+ point.Y < rect.Top - epsilon || point.Y > rect.Bottom + epsilon)
+ {
+ return false;
+ }
+
+ radius = Math.Clamp(radius, 0d, Math.Min(rect.Width, rect.Height) / 2);
+ var centerX = Math.Clamp(point.X, rect.Left + radius, rect.Right - radius);
+ var centerY = Math.Clamp(point.Y, rect.Top + radius, rect.Bottom - radius);
+ var inCornerX = point.X < rect.Left + radius || point.X > rect.Right - radius;
+ var inCornerY = point.Y < rect.Top + radius || point.Y > rect.Bottom - radius;
+ if (inCornerX && inCornerY)
+ {
+ var dx = point.X - centerX;
+ var dy = point.Y - centerY;
+ return dx * dx + dy * dy <= radius * radius + epsilon;
+ }
+
+ return true;
+ }
}