初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Effects;
|
||||
using PCL.Core.UI.Effects;
|
||||
|
||||
// 该部分源码来自或修改于 https://github.com/OrgEleCho/EleCho.WpfSuite
|
||||
// 项目: EleCho.WpfSuite
|
||||
// 作者: EleCho
|
||||
// 协议: MIT License
|
||||
|
||||
namespace PCL.Core.UI.Controls;
|
||||
|
||||
// ReSharper disable All
|
||||
|
||||
public class BlurBorder : Border
|
||||
{
|
||||
|
||||
private const double DoubleEpsilon = 2.2204460492503131e-016;
|
||||
|
||||
private static bool _IsZero(double value) => Math.Abs(value) < 10.0 * DoubleEpsilon;
|
||||
|
||||
private readonly Stack<UIElement> _panelStack = new();
|
||||
|
||||
/// <summary>
|
||||
/// A geometry to clip the content of this border correctly
|
||||
/// </summary>
|
||||
public Geometry? ContentClip
|
||||
{
|
||||
get { return (Geometry)GetValue(ContentClipProperty); }
|
||||
set { SetValue(ContentClipProperty, value); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum depth of the visual tree to render.
|
||||
/// </summary>
|
||||
public int MaxDepth
|
||||
{
|
||||
get { return (int)GetValue(MaxDepthProperty); }
|
||||
set { SetValue(MaxDepthProperty, value); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the radius of the blur effect applied to the background.
|
||||
/// </summary>
|
||||
public double BlurRadius
|
||||
{
|
||||
get { return (double)GetValue(BlurRadiusProperty); }
|
||||
set { SetValue(BlurRadiusProperty, value); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the type of kernel used for the blur effect.
|
||||
/// </summary>
|
||||
public KernelType BlurKernelType
|
||||
{
|
||||
get { return (KernelType)GetValue(BlurKernelTypeProperty); }
|
||||
set { SetValue(BlurKernelTypeProperty, value); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the rendering bias for the blur effect, which can affect performance and quality.
|
||||
/// </summary>
|
||||
public RenderingBias BlurRenderingBias
|
||||
{
|
||||
get { return (RenderingBias)GetValue(BlurRenderingBiasProperty); }
|
||||
set { SetValue(BlurRenderingBiasProperty, value); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the sampling rate for blur effect (0.1-1.0).
|
||||
/// Lower values significantly improve performance: 0.3 = 70% performance boost.
|
||||
/// Default is 0.7 for balanced quality and performance.
|
||||
/// </summary>
|
||||
public double BlurSamplingRate
|
||||
{
|
||||
get { return (double)GetValue(BlurSamplingRateProperty); }
|
||||
set { SetValue(BlurSamplingRateProperty, Math.Max(0.1, Math.Min(1.0, value))); }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Size ArrangeOverride(Size finalSize)
|
||||
{
|
||||
SetValue(ContentClipPropertyKey, CalculateContentClip(this));
|
||||
|
||||
return base.ArrangeOverride(finalSize);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Geometry? GetLayoutClip(Size layoutSlotSize)
|
||||
{
|
||||
if (!ClipToBounds)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return CalculateLayoutClip(layoutSlotSize, BorderThickness, CornerRadius);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void OnVisualParentChanged(DependencyObject oldParentObject)
|
||||
{
|
||||
if (oldParentObject is UIElement oldParent)
|
||||
{
|
||||
oldParent.LayoutUpdated -= ParentLayoutUpdated;
|
||||
}
|
||||
|
||||
if (Parent is UIElement newParent)
|
||||
{
|
||||
newParent.LayoutUpdated += ParentLayoutUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
private void ParentLayoutUpdated(object? sender, EventArgs e)
|
||||
{
|
||||
// cannot use 'InvalidateVisual' here, because it will cause infinite loop
|
||||
|
||||
BackgroundPresenter.ForceRender(this);
|
||||
|
||||
// Debug.WriteLine("Parent layout updated, forcing render of BackgroundPresenter.");
|
||||
}
|
||||
|
||||
private static Geometry? CalculateContentClip(Border border)
|
||||
{
|
||||
var borderThickness = border.BorderThickness;
|
||||
var cornerRadius = border.CornerRadius;
|
||||
var renderSize = border.RenderSize;
|
||||
|
||||
var contentWidth = renderSize.Width - borderThickness.Left - borderThickness.Right;
|
||||
var contentHeight = renderSize.Height - borderThickness.Top - borderThickness.Bottom;
|
||||
|
||||
if (contentWidth > 0 && contentHeight > 0)
|
||||
{
|
||||
var rect = new Rect(0, 0, contentWidth, contentHeight);
|
||||
var radii = new Radii(cornerRadius, borderThickness, false);
|
||||
|
||||
var contentGeometry = new StreamGeometry();
|
||||
using StreamGeometryContext ctx = contentGeometry.Open();
|
||||
GenerateGeometry(ctx, rect, radii);
|
||||
|
||||
contentGeometry.Freeze();
|
||||
return contentGeometry;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void OnRender(DrawingContext dc)
|
||||
{
|
||||
// 防止无意义渲染
|
||||
if (BlurRadius == 0
|
||||
|| Opacity == 0
|
||||
|| Visibility is Visibility.Collapsed or Visibility.Hidden)
|
||||
{
|
||||
base.OnRender(dc);
|
||||
return;
|
||||
}
|
||||
|
||||
DrawingVisual drawingVisual = new DrawingVisual()
|
||||
{
|
||||
Clip = new RectangleGeometry(new Rect(0, 0, RenderSize.Width, RenderSize.Height)),
|
||||
Effect = CreateOptimizedBlurEffect()
|
||||
};
|
||||
|
||||
using (DrawingContext visualContext = drawingVisual.RenderOpen())
|
||||
{
|
||||
BackgroundPresenter.DrawBackground(visualContext, this, _panelStack, MaxDepth, false);
|
||||
}
|
||||
|
||||
if (drawingVisual.Drawing is not null)
|
||||
{
|
||||
var layoutClip = CalculateLayoutClip(RenderSize, BorderThickness, CornerRadius);
|
||||
if (layoutClip is not null)
|
||||
{
|
||||
dc.PushClip(layoutClip);
|
||||
}
|
||||
|
||||
BackgroundPresenter.DrawVisual(dc, drawingVisual, default);
|
||||
|
||||
if (layoutClip is not null)
|
||||
{
|
||||
dc.Pop();
|
||||
}
|
||||
}
|
||||
|
||||
base.OnRender(dc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建优化的模糊效果实例
|
||||
/// </summary>
|
||||
private Effect CreateOptimizedBlurEffect()
|
||||
{
|
||||
// 根据模糊半径和采样率智能选择算法
|
||||
if (BlurRadius <= 2.0 || BlurSamplingRate >= 0.95)
|
||||
{
|
||||
// 小半径或高采样率:使用原生 BlurEffect 获得最佳质量
|
||||
return new BlurEffect
|
||||
{
|
||||
Radius = BlurRadius,
|
||||
KernelType = BlurKernelType,
|
||||
RenderingBias = BlurRenderingBias
|
||||
};
|
||||
}
|
||||
else if (BlurRadius >= 50.0 && BlurSamplingRate <= 0.3)
|
||||
{
|
||||
// 大半径低采样率:使用极速优化版本
|
||||
var ultraFastBlur = OptimizedBlurFactory.CreateRealTimePreview(BlurRadius);
|
||||
ultraFastBlur.SamplingRate = Math.Max(0.1, BlurSamplingRate);
|
||||
ultraFastBlur.RenderingBias = RenderingBias.Performance;
|
||||
return ultraFastBlur.GetEffectInstance();
|
||||
}
|
||||
else
|
||||
{
|
||||
// 中等情况:使用我们的自适应优化算法
|
||||
var adaptiveBlur = OptimizedBlurFactory.CreateAdaptive(BlurRadius);
|
||||
adaptiveBlur.SamplingRate = BlurSamplingRate;
|
||||
adaptiveBlur.RenderingBias = BlurRenderingBias;
|
||||
adaptiveBlur.KernelType = BlurKernelType;
|
||||
return adaptiveBlur.GetEffectInstance();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The key needed set a read-only property
|
||||
/// </summary>
|
||||
private static readonly DependencyPropertyKey ContentClipPropertyKey =
|
||||
DependencyProperty.RegisterReadOnly(nameof(ContentClip), typeof(Geometry), typeof(BlurBorder), new FrameworkPropertyMetadata(default(Geometry)));
|
||||
|
||||
/// <summary>
|
||||
/// The DependencyProperty for the ContentClip property. <br/>
|
||||
/// Flags: None <br/>
|
||||
/// Default value: null
|
||||
/// </summary>
|
||||
public static readonly DependencyProperty ContentClipProperty =
|
||||
ContentClipPropertyKey.DependencyProperty;
|
||||
|
||||
/// <summary>
|
||||
/// The maximum depth of the visual tree to render.
|
||||
/// </summary>
|
||||
public static readonly DependencyProperty MaxDepthProperty =
|
||||
BackgroundPresenter.MaxDepthProperty.AddOwner(typeof(BlurBorder));
|
||||
|
||||
/// <summary>
|
||||
/// The radius of the blur effect applied to the background.
|
||||
/// </summary>
|
||||
public static readonly DependencyProperty BlurRadiusProperty =
|
||||
DependencyProperty.Register(nameof(BlurRadius), typeof(double), typeof(BlurBorder), new FrameworkPropertyMetadata(16.0, propertyChangedCallback: OnRenderPropertyChanged));
|
||||
|
||||
/// <summary>
|
||||
/// The type of kernel used for the blur effect.
|
||||
/// </summary>
|
||||
public static readonly DependencyProperty BlurKernelTypeProperty =
|
||||
DependencyProperty.Register(nameof(BlurKernelType), typeof(KernelType), typeof(BlurBorder), new FrameworkPropertyMetadata(KernelType.Gaussian, propertyChangedCallback: OnRenderPropertyChanged));
|
||||
|
||||
/// <summary>
|
||||
/// The rendering bias for the blur effect, which can affect performance and quality.
|
||||
/// </summary>
|
||||
public static readonly DependencyProperty BlurRenderingBiasProperty =
|
||||
DependencyProperty.Register(nameof(BlurRenderingBias), typeof(RenderingBias), typeof(BlurBorder), new FrameworkPropertyMetadata(RenderingBias.Performance, propertyChangedCallback: OnRenderPropertyChanged));
|
||||
|
||||
/// <summary>
|
||||
/// The sampling rate for blur effect, controlling performance vs quality trade-off.
|
||||
/// </summary>
|
||||
public static readonly DependencyProperty BlurSamplingRateProperty =
|
||||
DependencyProperty.Register(nameof(BlurSamplingRate), typeof(double), typeof(BlurBorder), new FrameworkPropertyMetadata(0.9, propertyChangedCallback: OnRenderPropertyChanged));
|
||||
|
||||
private static void OnRenderPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is UIElement element)
|
||||
{
|
||||
BackgroundPresenter.ForceRender(element);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a StreamGeometry.
|
||||
/// </summary>
|
||||
/// <param name="ctx">An already opened StreamGeometryContext.</param>
|
||||
/// <param name="rect">Rectangle for geometry conversion.</param>
|
||||
/// <param name="radii">Corner radii.</param>
|
||||
/// <returns>Result geometry.</returns>
|
||||
internal static void GenerateGeometry(StreamGeometryContext ctx, Rect rect, Radii radii)
|
||||
{
|
||||
//
|
||||
// compute the coordinates of the key points
|
||||
//
|
||||
|
||||
Point topLeft = new Point(radii.LeftTop, 0);
|
||||
Point topRight = new Point(rect.Width - radii.RightTop, 0);
|
||||
Point rightTop = new Point(rect.Width, radii.TopRight);
|
||||
Point rightBottom = new Point(rect.Width, rect.Height - radii.BottomRight);
|
||||
Point bottomRight = new Point(rect.Width - radii.RightBottom, rect.Height);
|
||||
Point bottomLeft = new Point(radii.LeftBottom, rect.Height);
|
||||
Point leftBottom = new Point(0, rect.Height - radii.BottomLeft);
|
||||
Point leftTop = new Point(0, radii.TopLeft);
|
||||
|
||||
//
|
||||
// check key points for overlap and resolve by partitioning radii according to
|
||||
// the percentage of each one.
|
||||
//
|
||||
|
||||
// top edge is handled here
|
||||
if (topLeft.X > topRight.X)
|
||||
{
|
||||
double v = (radii.LeftTop) / (radii.LeftTop + radii.RightTop) * rect.Width;
|
||||
topLeft.X = v;
|
||||
topRight.X = v;
|
||||
}
|
||||
|
||||
// right edge
|
||||
if (rightTop.Y > rightBottom.Y)
|
||||
{
|
||||
double v = (radii.TopRight) / (radii.TopRight + radii.BottomRight) * rect.Height;
|
||||
rightTop.Y = v;
|
||||
rightBottom.Y = v;
|
||||
}
|
||||
|
||||
// bottom edge
|
||||
if (bottomRight.X < bottomLeft.X)
|
||||
{
|
||||
double v = (radii.LeftBottom) / (radii.LeftBottom + radii.RightBottom) * rect.Width;
|
||||
bottomRight.X = v;
|
||||
bottomLeft.X = v;
|
||||
}
|
||||
|
||||
// left edge
|
||||
if (leftBottom.Y < leftTop.Y)
|
||||
{
|
||||
double v = (radii.TopLeft) / (radii.TopLeft + radii.BottomLeft) * rect.Height;
|
||||
leftBottom.Y = v;
|
||||
leftTop.Y = v;
|
||||
}
|
||||
|
||||
//
|
||||
// add on offsets
|
||||
//
|
||||
|
||||
Vector offset = new Vector(rect.TopLeft.X, rect.TopLeft.Y);
|
||||
topLeft += offset;
|
||||
topRight += offset;
|
||||
rightTop += offset;
|
||||
rightBottom += offset;
|
||||
bottomRight += offset;
|
||||
bottomLeft += offset;
|
||||
leftBottom += offset;
|
||||
leftTop += offset;
|
||||
|
||||
//
|
||||
// create the border geometry
|
||||
//
|
||||
ctx.BeginFigure(topLeft, true /* is filled */, true /* is closed */);
|
||||
|
||||
// Top line
|
||||
ctx.LineTo(topRight, true /* is stroked */, false /* is smooth join */);
|
||||
|
||||
// Upper-right corner
|
||||
double radiusX = rect.TopRight.X - topRight.X;
|
||||
double radiusY = rightTop.Y - rect.TopRight.Y;
|
||||
if (!_IsZero(radiusX)
|
||||
|| !_IsZero(radiusY))
|
||||
{
|
||||
ctx.ArcTo(rightTop, new Size(radiusX, radiusY), 0, false, SweepDirection.Clockwise, true, false);
|
||||
}
|
||||
|
||||
// Right line
|
||||
ctx.LineTo(rightBottom, true /* is stroked */, false /* is smooth join */);
|
||||
|
||||
// Lower-right corner
|
||||
radiusX = rect.BottomRight.X - bottomRight.X;
|
||||
radiusY = rect.BottomRight.Y - rightBottom.Y;
|
||||
if (!_IsZero(radiusX)
|
||||
|| !_IsZero(radiusY))
|
||||
{
|
||||
ctx.ArcTo(bottomRight, new Size(radiusX, radiusY), 0, false, SweepDirection.Clockwise, true, false);
|
||||
}
|
||||
|
||||
// Bottom line
|
||||
ctx.LineTo(bottomLeft, true /* is stroked */, false /* is smooth join */);
|
||||
|
||||
// Lower-left corner
|
||||
radiusX = bottomLeft.X - rect.BottomLeft.X;
|
||||
radiusY = rect.BottomLeft.Y - leftBottom.Y;
|
||||
if (!_IsZero(radiusX)
|
||||
|| !_IsZero(radiusY))
|
||||
{
|
||||
ctx.ArcTo(leftBottom, new Size(radiusX, radiusY), 0, false, SweepDirection.Clockwise, true, false);
|
||||
}
|
||||
|
||||
// Left line
|
||||
ctx.LineTo(leftTop, true /* is stroked */, false /* is smooth join */);
|
||||
|
||||
// Upper-left corner
|
||||
radiusX = topLeft.X - rect.TopLeft.X;
|
||||
radiusY = leftTop.Y - rect.TopLeft.Y;
|
||||
if (!_IsZero(radiusX)
|
||||
|| !_IsZero(radiusY))
|
||||
{
|
||||
ctx.ArcTo(topLeft, new Size(radiusX, radiusY), 0, false, SweepDirection.Clockwise, true, false);
|
||||
}
|
||||
}
|
||||
|
||||
internal static Geometry? CalculateLayoutClip(Size layoutSlotSize, Thickness borderThickness, CornerRadius cornerRadius)
|
||||
{
|
||||
if (layoutSlotSize.Width <= 0 ||
|
||||
layoutSlotSize.Height <= 0)
|
||||
{
|
||||
return new RectangleGeometry(new Rect(0, 0, 0, 0));
|
||||
}
|
||||
|
||||
var rect = new Rect(0, 0, layoutSlotSize.Width, layoutSlotSize.Height);
|
||||
var radii = new Radii(cornerRadius, borderThickness, true);
|
||||
|
||||
var layoutGeometry = new StreamGeometry();
|
||||
using StreamGeometryContext ctx = layoutGeometry.Open();
|
||||
GenerateGeometry(ctx, rect, radii);
|
||||
|
||||
layoutGeometry.Freeze();
|
||||
return layoutGeometry;
|
||||
}
|
||||
|
||||
internal struct Radii
|
||||
{
|
||||
internal Radii(CornerRadius radii, Thickness borders, bool outer)
|
||||
{
|
||||
double left = 0.5 * borders.Left;
|
||||
double top = 0.5 * borders.Top;
|
||||
double right = 0.5 * borders.Right;
|
||||
double bottom = 0.5 * borders.Bottom;
|
||||
|
||||
if (outer)
|
||||
{
|
||||
if (_IsZero(radii.TopLeft))
|
||||
{
|
||||
LeftTop = TopLeft = 0.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
LeftTop = radii.TopLeft + left;
|
||||
TopLeft = radii.TopLeft + top;
|
||||
}
|
||||
if (_IsZero(radii.TopRight))
|
||||
{
|
||||
TopRight = RightTop = 0.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
TopRight = radii.TopRight + top;
|
||||
RightTop = radii.TopRight + right;
|
||||
}
|
||||
if (_IsZero(radii.BottomRight))
|
||||
{
|
||||
RightBottom = BottomRight = 0.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
RightBottom = radii.BottomRight + right;
|
||||
BottomRight = radii.BottomRight + bottom;
|
||||
}
|
||||
if (_IsZero(radii.BottomLeft))
|
||||
{
|
||||
BottomLeft = LeftBottom = 0.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
BottomLeft = radii.BottomLeft + bottom;
|
||||
LeftBottom = radii.BottomLeft + left;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
LeftTop = Math.Max(0.0, radii.TopLeft - left);
|
||||
TopLeft = Math.Max(0.0, radii.TopLeft - top);
|
||||
TopRight = Math.Max(0.0, radii.TopRight - top);
|
||||
RightTop = Math.Max(0.0, radii.TopRight - right);
|
||||
RightBottom = Math.Max(0.0, radii.BottomRight - right);
|
||||
BottomRight = Math.Max(0.0, radii.BottomRight - bottom);
|
||||
BottomLeft = Math.Max(0.0, radii.BottomLeft - bottom);
|
||||
LeftBottom = Math.Max(0.0, radii.BottomLeft - left);
|
||||
}
|
||||
}
|
||||
|
||||
internal double LeftTop;
|
||||
internal double TopLeft;
|
||||
internal double TopRight;
|
||||
internal double RightTop;
|
||||
internal double RightBottom;
|
||||
internal double BottomRight;
|
||||
internal double BottomLeft;
|
||||
internal double LeftBottom;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<UserControl x:Class="PCL.Core.UI.Controls.MotdRenderer"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
mc:Ignorable="d">
|
||||
<Canvas x:Name="MotdCanvas" IsHitTestVisible="False" HorizontalAlignment="Center" Width="350" Height="34"/>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,442 @@
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.UI.Controls;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using System.Linq;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
public partial class MotdRenderer {
|
||||
// Default Color for originalColorMap: #808080
|
||||
// Minecraft color code mapping
|
||||
private static readonly Dictionary<string, Brush> _ColorMapWithBlackBackground = new() {
|
||||
{ "0", Brushes.Black }, // Black
|
||||
{ "1", new SolidColorBrush(Color.FromRgb(0, 0, 170)) }, // Dark Blue
|
||||
{ "2", new SolidColorBrush(Color.FromRgb(0, 170, 0)) }, // Dark Green
|
||||
{ "3", new SolidColorBrush(Color.FromRgb(0, 170, 170)) }, // Cyan
|
||||
{ "4", new SolidColorBrush(Color.FromRgb(170, 0, 0)) }, // Dark Red
|
||||
{ "5", new SolidColorBrush(Color.FromRgb(170, 0, 170)) }, // Purple
|
||||
{ "6", new SolidColorBrush(Color.FromRgb(255, 170, 0)) }, // Gold
|
||||
{ "7", Brushes.LightGray }, // Gray
|
||||
{ "8", Brushes.DarkGray }, // Dark Gray
|
||||
{ "9", Brushes.Blue }, // Blue
|
||||
{ "a", Brushes.Lime }, // Green
|
||||
{ "b", Brushes.Cyan }, // Cyan
|
||||
{ "c", Brushes.Red }, // Red
|
||||
{ "d", Brushes.Magenta }, // Magenta
|
||||
{ "e", Brushes.Yellow }, // Yellow
|
||||
{ "f", Brushes.White } // White
|
||||
};
|
||||
|
||||
// Color code mapping optimized for white background (#f3f6fa)
|
||||
private static readonly Dictionary<string, Brush> _ColorMapWithWhiteBackground = new() {
|
||||
{ "0", new SolidColorBrush(Color.FromRgb(51, 51, 51)) }, // Deep Gray #333333
|
||||
{ "1", new SolidColorBrush(Color.FromRgb(0, 48, 135)) }, // Navy Blue #003087
|
||||
{ "2", new SolidColorBrush(Color.FromRgb(0, 128, 0)) }, // Forest Green #008000
|
||||
{ "3", new SolidColorBrush(Color.FromRgb(0, 122, 122)) }, // Cyan #007A7A
|
||||
{ "4", new SolidColorBrush(Color.FromRgb(161, 0, 0)) }, // Deep Red #A10000
|
||||
{ "5", new SolidColorBrush(Color.FromRgb(128, 0, 128)) }, // Deep Purple #800080
|
||||
{ "6", new SolidColorBrush(Color.FromRgb(204, 112, 0)) }, // Deep Orange #CC7000
|
||||
{ "7", new SolidColorBrush(Color.FromRgb(102, 102, 102)) }, // Medium Gray #666666
|
||||
{ "8", new SolidColorBrush(Color.FromRgb(68, 68, 68)) }, // Charcoal #444444
|
||||
{ "9", new SolidColorBrush(Color.FromRgb(0, 68, 204)) }, // Royal Blue #0044CC
|
||||
{ "a", new SolidColorBrush(Color.FromRgb(0, 153, 0)) }, // Green #009900
|
||||
{ "b", new SolidColorBrush(Color.FromRgb(0, 161, 161)) }, // Cyan #00A1A1
|
||||
{ "c", new SolidColorBrush(Color.FromRgb(204, 0, 0)) }, // Red #CC0000
|
||||
{ "d", new SolidColorBrush(Color.FromRgb(194, 0, 194)) }, // Magenta #C200C2
|
||||
{ "e", new SolidColorBrush(Color.FromRgb(179, 160, 0)) }, // Deep Yellow #B3A000
|
||||
{ "f", new SolidColorBrush(Color.FromRgb(136, 136, 136)) } // White
|
||||
};
|
||||
|
||||
// Format code mapping
|
||||
private readonly Dictionary<string, bool> _formatMap = new() {
|
||||
{ "l", true }, // Bold
|
||||
{ "o", true }, // Italic
|
||||
{ "n", true }, // Underline
|
||||
{ "m", true }, // Strikethrough
|
||||
{ "k", true }, // Obfuscated (not supported)
|
||||
{ "r", false } // Reset
|
||||
};
|
||||
|
||||
// Store TextBlock and original text for §k obfuscated text
|
||||
private readonly List<(TextBlock TextBlock, string OriginalText)> _obfuscatedTextBlocks = [];
|
||||
|
||||
private readonly Random _random = new();
|
||||
private const string RandomChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()";
|
||||
private readonly Color _backgroundColor = Color.FromRgb(243, 246, 250); // #f3f6fa
|
||||
|
||||
public MotdRenderer() {
|
||||
InitializeComponent(); // 初始化 XAML 定义的控件
|
||||
// Start timer to update §k text
|
||||
var timer = new DispatcherTimer {
|
||||
Interval = TimeSpan.FromMilliseconds(20)
|
||||
};
|
||||
timer.Tick += _UpdateObfuscatedText;
|
||||
timer.Start();
|
||||
}
|
||||
|
||||
private void _UpdateObfuscatedText(object? sender, EventArgs e) {
|
||||
foreach (var (textBlock, originalText) in _obfuscatedTextBlocks) {
|
||||
// Generate random characters of the same length as the original text
|
||||
var obfuscated = string.Join("",
|
||||
Enumerable.Range(0, originalText.Length).Select(_ => RandomChars[_random.Next(RandomChars.Length)]));
|
||||
textBlock.Text = obfuscated;
|
||||
}
|
||||
}
|
||||
|
||||
private static double _GetRelativeLuminance(Color color) {
|
||||
var r = color.R / 255.0;
|
||||
var g = color.G / 255.0;
|
||||
var b = color.B / 255.0;
|
||||
var rL = r <= 0.03928 ? r / 12.92 : Math.Pow((r + 0.055) / 1.055, 2.4);
|
||||
var gL = g <= 0.03928 ? g / 12.92 : Math.Pow((g + 0.055) / 1.055, 2.4);
|
||||
var bL = b <= 0.03928 ? b / 12.92 : Math.Pow((b + 0.055) / 1.055, 2.4);
|
||||
return 0.2126 * rL + 0.7152 * gL + 0.0722 * bL;
|
||||
}
|
||||
|
||||
private static double _GetContrastRatio(Color foreground, Color background) {
|
||||
var l1 = _GetRelativeLuminance(foreground);
|
||||
var l2 = _GetRelativeLuminance(background);
|
||||
return (Math.Max(l1, l2) + 0.05) / (Math.Min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
private Color _AdjustColorForContrast(Color inputColor) {
|
||||
var contrastRatio = _GetContrastRatio(inputColor, _backgroundColor);
|
||||
if (contrastRatio >= 4.5) return inputColor; // Contrast is sufficient
|
||||
|
||||
// Convert RGB to HSL
|
||||
var r = inputColor.R / 255.0;
|
||||
var g = inputColor.G / 255.0;
|
||||
var b = inputColor.B / 255.0;
|
||||
var max = Math.Max(Math.Max(r, g), b);
|
||||
var min = Math.Min(Math.Min(r, g), b);
|
||||
var l = (max + min) / 2.0;
|
||||
double s;
|
||||
double h;
|
||||
|
||||
if (Math.Abs(max - min) < double.Epsilon) {
|
||||
h = 0.0;
|
||||
s = 0.0;
|
||||
} else {
|
||||
var d = max - min;
|
||||
s = l > 0.5 ? d / (2.0 - max - min) : d / (max + min);
|
||||
h = max switch {
|
||||
_ when Math.Abs(max - r) < double.Epsilon => (g - b) / d + (g < b ? 6.0 : 0.0),
|
||||
_ when Math.Abs(max - g) < double.Epsilon => (b - r) / d + 2.0,
|
||||
_ => (r - g) / d + 4.0
|
||||
};
|
||||
h /= 6.0;
|
||||
}
|
||||
|
||||
// Decrease lightness until contrast ratio ≥ 4.5:1
|
||||
var newL = l;
|
||||
var adjustedColor = inputColor;
|
||||
while (newL > 0.1 && _GetContrastRatio(adjustedColor, _backgroundColor) < 4.5) {
|
||||
newL -= 0.05; // Gradually decrease lightness
|
||||
double newR, newG, newB;
|
||||
if (s == 0) {
|
||||
newR = newL;
|
||||
newG = newL;
|
||||
newB = newL;
|
||||
} else {
|
||||
var q = newL < 0.5 ? newL * (1.0 + s) : newL + s - newL * s;
|
||||
var p = 2.0 * newL - q;
|
||||
newR = _HueToRgb(p, q, h + 1.0 / 3.0);
|
||||
newG = _HueToRgb(p, q, h);
|
||||
newB = _HueToRgb(p, q, h - 1.0 / 3.0);
|
||||
}
|
||||
|
||||
adjustedColor = Color.FromRgb((byte)(newR * 255), (byte)(newG * 255), (byte)(newB * 255));
|
||||
}
|
||||
|
||||
// If contrast is still insufficient, use default color #555555
|
||||
return _GetContrastRatio(adjustedColor, _backgroundColor) < 4.5 ? Color.FromRgb(85, 85, 85) : adjustedColor;
|
||||
}
|
||||
|
||||
private static double _HueToRgb(double p, double q, double t) {
|
||||
if (t < 0) t += 1.0;
|
||||
if (t > 1) t -= 1.0;
|
||||
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
|
||||
if (t < 0.5) return q;
|
||||
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
|
||||
return p;
|
||||
}
|
||||
|
||||
public static bool TryGetColorFromCode(string code, bool isDarkMode, out String? color) {
|
||||
var colorMap = isDarkMode ? _ColorMapWithBlackBackground : _ColorMapWithWhiteBackground;
|
||||
var success = colorMap.TryGetValue(code.ToLower(), out var brush);
|
||||
if (!success) {
|
||||
color = null;
|
||||
return false;
|
||||
}
|
||||
var solidColorBrush = ((SolidColorBrush)brush!).Color;
|
||||
color = $"#{solidColorBrush.R:X2}{solidColorBrush.G:X2}{solidColorBrush.B:X2}";
|
||||
return success;
|
||||
}
|
||||
|
||||
public void RenderMotd(string motd, bool isDarkMode = true, int maxLines = int.MaxValue, double fontSize = 12, bool isCentered = true) {
|
||||
MotdCanvas.Children.Clear();
|
||||
_obfuscatedTextBlocks.Clear();
|
||||
|
||||
motd ??= string.Empty;
|
||||
|
||||
var colorMap = isDarkMode ? _ColorMapWithBlackBackground : _ColorMapWithWhiteBackground;
|
||||
var font = Config.Preference.MotdFont;
|
||||
var fontFamily = new FontFamily(string.IsNullOrWhiteSpace(font)
|
||||
? "./Resources/#PCL English, Segoe UI, Microsoft YaHei UI"
|
||||
: font);
|
||||
var canvasWidth = MotdCanvas.ActualWidth > 0 ? MotdCanvas.ActualWidth : 300; // Prevent zero width
|
||||
var canvasHeight = MotdCanvas.ActualHeight > 0 ? MotdCanvas.ActualHeight : 34; // Prevent zero height
|
||||
double y = 10;
|
||||
|
||||
// Split multi-line MOTD
|
||||
motd = motd.Replace("\n", "\r\n");
|
||||
var lines = motd.Split("\r\n");
|
||||
var currentColor = colorMap["f"];
|
||||
var isBold = false;
|
||||
var isItalic = false;
|
||||
var isUnderline = false;
|
||||
var isStrikethrough = false;
|
||||
var isObfuscated = false;
|
||||
|
||||
// 限制显示的行数不超过maxLines
|
||||
var lineCount = Math.Min(lines.Length, maxLines);
|
||||
for (var lineIndex = 0; lineIndex < lineCount; lineIndex++) {
|
||||
var line = lines[lineIndex].Trim();
|
||||
var parts = RegexPatterns.MotdCode.Split(line);
|
||||
|
||||
// Calculate line width
|
||||
double lineWidth = 0;
|
||||
double lineHeight = 0;
|
||||
double tempX = 0; // Temporary x-coordinate for width calculation
|
||||
var textBlocks = new List<TextBlock>(); // Store TextBlocks for the line
|
||||
var positions = new List<double>(); // Store x-coordinates for each TextBlock
|
||||
|
||||
// 找出第一个和最后一个不是格式符的文本部分的索引
|
||||
var firstNonFormatPartIndex = -1;
|
||||
var lastNonFormatPartIndex = -1;
|
||||
for (var j = 0; j < parts.Length; j++)
|
||||
{
|
||||
var part = parts[j];
|
||||
if (!string.IsNullOrEmpty(part) && !(part.StartsWith('§') && part.Length == 2) && !RegexPatterns.HexColor.IsMatch(part))
|
||||
{
|
||||
if (firstNonFormatPartIndex == -1)
|
||||
{
|
||||
firstNonFormatPartIndex = j;
|
||||
}
|
||||
lastNonFormatPartIndex = j;
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < parts.Length; i++) {
|
||||
var part = parts[i];
|
||||
var partTrimmed = part;
|
||||
if (i == firstNonFormatPartIndex) {
|
||||
partTrimmed = part.TrimStart();
|
||||
} else if (i == lastNonFormatPartIndex) {
|
||||
partTrimmed = part.TrimEnd();
|
||||
}
|
||||
if (string.IsNullOrEmpty(partTrimmed)) continue;
|
||||
|
||||
// Handle § color codes
|
||||
if (partTrimmed.StartsWith('§') && partTrimmed.Length == 2) {
|
||||
var code = partTrimmed[1..].ToLower();
|
||||
if (colorMap.TryGetValue(code, out var brush)) {
|
||||
currentColor = brush;
|
||||
isBold = false;
|
||||
isItalic = false;
|
||||
isUnderline = false;
|
||||
isStrikethrough = false;
|
||||
isObfuscated = false;
|
||||
} else if (_formatMap.ContainsKey(code)) {
|
||||
switch (code) {
|
||||
case "l":
|
||||
isBold = true;
|
||||
break;
|
||||
case "o":
|
||||
isItalic = true;
|
||||
break;
|
||||
case "n":
|
||||
isUnderline = true;
|
||||
break;
|
||||
case "m":
|
||||
isStrikethrough = true;
|
||||
break;
|
||||
case "k":
|
||||
isObfuscated = true;
|
||||
break;
|
||||
case "r":
|
||||
currentColor = colorMap["f"];
|
||||
isBold = false;
|
||||
isItalic = false;
|
||||
isUnderline = false;
|
||||
isStrikethrough = false;
|
||||
isObfuscated = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle RGB color codes
|
||||
if (RegexPatterns.HexColor.IsMatch(partTrimmed)) {
|
||||
try {
|
||||
var hex = partTrimmed[1..];
|
||||
var r = Convert.ToByte(hex[..2], 16);
|
||||
var g = Convert.ToByte(hex.Substring(2, 2), 16);
|
||||
var b = Convert.ToByte(hex.Substring(4, 2), 16);
|
||||
var inputColor = Color.FromRgb(r, g, b);
|
||||
currentColor = new SolidColorBrush(_AdjustColorForContrast(inputColor));
|
||||
isBold = false;
|
||||
isItalic = false;
|
||||
isUnderline = false;
|
||||
isStrikethrough = false;
|
||||
isObfuscated = false;
|
||||
} catch {
|
||||
// Invalid RGB color, keep current color
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Render text, always use original text for width calculation
|
||||
var displayText = partTrimmed;
|
||||
TextBlock textBlock;
|
||||
if (isObfuscated) {
|
||||
// Generate initial random characters for §k text
|
||||
foreach (var singleChar in partTrimmed) {
|
||||
displayText = RandomChars[_random.Next(RandomChars.Length)].ToString();
|
||||
textBlock = _RenderText(displayText, fontFamily, fontSize, currentColor, isBold, isItalic,
|
||||
isUnderline, isStrikethrough, tempX, y, true,
|
||||
_MeasureTextWidth(singleChar.ToString(), fontFamily, fontSize, isBold, isItalic));
|
||||
_obfuscatedTextBlocks.Add((textBlock, singleChar.ToString()));
|
||||
textBlocks.Add(textBlock);
|
||||
positions.Add(tempX);
|
||||
tempX += _MeasureTextWidth(singleChar.ToString(), fontFamily, fontSize, isBold, isItalic);
|
||||
}
|
||||
} else {
|
||||
textBlock = _RenderText(displayText, fontFamily, fontSize, currentColor, isBold, isItalic,
|
||||
isUnderline, isStrikethrough, tempX, y);
|
||||
textBlocks.Add(textBlock);
|
||||
positions.Add(tempX);
|
||||
}
|
||||
|
||||
// Update tempX coordinate using original text width
|
||||
if (!isObfuscated) {
|
||||
tempX += _MeasureTextWidth(partTrimmed, fontFamily, fontSize, isBold, isItalic);
|
||||
}
|
||||
|
||||
var textHeight = _MeasureTextHeight(partTrimmed, fontFamily, fontSize, isBold, isItalic);
|
||||
lineHeight = textHeight > lineHeight ? textHeight : lineHeight;
|
||||
lineWidth = tempX; // Update line width
|
||||
}
|
||||
|
||||
if (isCentered) {
|
||||
// Center-align: Adjust x-coordinates for each TextBlock
|
||||
var offsetX = (canvasWidth - lineWidth) / 2;
|
||||
for (var i = 0; i < textBlocks.Count; i++) {
|
||||
Canvas.SetLeft(textBlocks[i], positions[i] + offsetX);
|
||||
}
|
||||
}
|
||||
|
||||
// 计算当前行的垂直位置
|
||||
double offsetY;
|
||||
if (lineCount == 1) {
|
||||
// 单行文本居中
|
||||
offsetY = (canvasHeight - lineHeight) / 2;
|
||||
} else {
|
||||
// 多行文本的位置计算
|
||||
var totalHeight = lineCount * lineHeight; // 使用实际显示的行数计算总高度
|
||||
var startOffset = (canvasHeight - totalHeight) / 2;
|
||||
offsetY = startOffset + (lineIndex * lineHeight);
|
||||
}
|
||||
|
||||
// 设置所有文本块的垂直位置
|
||||
foreach (var textBlock in textBlocks) {
|
||||
Canvas.SetTop(textBlock, offsetY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private TextBlock _RenderText(string text, FontFamily fontFamily, double fontSize, Brush color,
|
||||
bool isBold, bool isItalic, bool isUnderline, bool isStrikethrough,
|
||||
double x, double y, bool withClip = false, double clipWidth = 15) {
|
||||
var textBlock = new TextBlock {
|
||||
Text = text,
|
||||
FontFamily = fontFamily,
|
||||
FontSize = fontSize,
|
||||
Foreground = color,
|
||||
FontWeight = isBold ? FontWeights.Bold : FontWeights.Normal,
|
||||
FontStyle = isItalic ? FontStyles.Italic : FontStyles.Normal
|
||||
};
|
||||
|
||||
if (isUnderline || isStrikethrough) {
|
||||
textBlock.TextDecorations = new TextDecorationCollection();
|
||||
if (isUnderline) textBlock.TextDecorations.Add(TextDecorations.Underline);
|
||||
if (isStrikethrough) textBlock.TextDecorations.Add(TextDecorations.Strikethrough);
|
||||
}
|
||||
|
||||
if (withClip) {
|
||||
var clipRect = new RectangleGeometry {
|
||||
Rect = new Rect(0, 0, clipWidth, _MeasureTextHeight(text, fontFamily, fontSize, isBold, isItalic))
|
||||
};
|
||||
textBlock.Clip = clipRect;
|
||||
}
|
||||
|
||||
Canvas.SetLeft(textBlock, x);
|
||||
Canvas.SetTop(textBlock, y);
|
||||
if (Content is Canvas canvas) {
|
||||
canvas.Children.Add(textBlock);
|
||||
}
|
||||
|
||||
return textBlock;
|
||||
}
|
||||
|
||||
private static FormattedText _CreateFormattedText(string text, FontFamily fontFamily, double fontSize, bool isBold, bool isItalic) {
|
||||
return new FormattedText(
|
||||
text,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
FlowDirection.LeftToRight,
|
||||
new Typeface(fontFamily, isItalic ? FontStyles.Italic : FontStyles.Normal,
|
||||
isBold ? FontWeights.Bold : FontWeights.Normal, FontStretches.Normal),
|
||||
fontSize,
|
||||
Brushes.White,
|
||||
96);
|
||||
}
|
||||
|
||||
private static double _MeasureTextWidth(string text, FontFamily fontFamily, double fontSize, bool isBold, bool isItalic) {
|
||||
return _CreateFormattedText(text, fontFamily, fontSize, isBold, isItalic).WidthIncludingTrailingWhitespace;
|
||||
}
|
||||
|
||||
private static double _MeasureTextHeight(string text, FontFamily fontFamily, double fontSize, bool isBold, bool isItalic) {
|
||||
return _CreateFormattedText(text, fontFamily, fontSize, isBold, isItalic).Height;
|
||||
}
|
||||
|
||||
public void RenderCanvas() {
|
||||
// Ensure Canvas is rendered
|
||||
MotdCanvas.UpdateLayout();
|
||||
|
||||
// Generate static random characters for §k text
|
||||
foreach (var (textBlock, originalText) in _obfuscatedTextBlocks) {
|
||||
textBlock.Text = string.Join("",
|
||||
Enumerable.Range(0, originalText.Length).Select(_ => RandomChars[_random.Next(RandomChars.Length)]));
|
||||
}
|
||||
|
||||
// Capture Canvas using RenderTargetBitmap
|
||||
var rtb = new RenderTargetBitmap(
|
||||
(int)MotdCanvas.Width, (int)MotdCanvas.Height, 96, 96, PixelFormats.Pbgra32);
|
||||
rtb.Render(MotdCanvas);
|
||||
}
|
||||
|
||||
public void ClearCanvas() {
|
||||
MotdCanvas.Children.Clear();
|
||||
_obfuscatedTextBlocks.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using PCL.Core.UI.Animation;
|
||||
using PCL.Core.UI.Animation.Animatable;
|
||||
using PCL.Core.UI.Animation.Core;
|
||||
using PCL.Core.UI.Animation.Easings;
|
||||
|
||||
namespace PCL.Core.UI.Controls.SvgIcon;
|
||||
|
||||
public class SvgIcon : FrameworkElement
|
||||
{
|
||||
public static readonly DependencyProperty IconProperty = DependencyProperty.Register(
|
||||
nameof(Icon),
|
||||
typeof(string),
|
||||
typeof(SvgIcon),
|
||||
new FrameworkPropertyMetadata(
|
||||
string.Empty,
|
||||
FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsRender,
|
||||
_OnIconChanged));
|
||||
|
||||
public static readonly DependencyProperty DefaultPackProperty = DependencyProperty.Register(
|
||||
nameof(DefaultPack),
|
||||
typeof(string),
|
||||
typeof(SvgIcon),
|
||||
new FrameworkPropertyMetadata(
|
||||
SvgIconLoader.DefaultIconPack,
|
||||
FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsRender,
|
||||
_OnIconChanged));
|
||||
|
||||
public static readonly DependencyProperty IconBrushProperty = DependencyProperty.Register(
|
||||
nameof(IconBrush),
|
||||
typeof(Brush),
|
||||
typeof(SvgIcon),
|
||||
new FrameworkPropertyMetadata(
|
||||
SystemColors.ControlTextBrush,
|
||||
FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public static readonly DependencyProperty StrokeThicknessProperty = DependencyProperty.Register(
|
||||
nameof(StrokeThickness),
|
||||
typeof(double),
|
||||
typeof(SvgIcon),
|
||||
new FrameworkPropertyMetadata(
|
||||
2D,
|
||||
FrameworkPropertyMetadataOptions.AffectsRender),
|
||||
value => value is double number && !double.IsNaN(number) && number >= 0D);
|
||||
|
||||
public static readonly DependencyProperty UseOriginalColorProperty = DependencyProperty.Register(
|
||||
nameof(UseOriginalColor),
|
||||
typeof(bool),
|
||||
typeof(SvgIcon),
|
||||
new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public static readonly DependencyProperty StretchProperty = DependencyProperty.Register(
|
||||
nameof(Stretch),
|
||||
typeof(Stretch),
|
||||
typeof(SvgIcon),
|
||||
new FrameworkPropertyMetadata(
|
||||
Stretch.Uniform,
|
||||
FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
private SvgIconModel? _model;
|
||||
private bool _modelLoaded;
|
||||
|
||||
public string Icon
|
||||
{
|
||||
get => (string)GetValue(IconProperty);
|
||||
set => SetValue(IconProperty, value);
|
||||
}
|
||||
|
||||
public string DefaultPack
|
||||
{
|
||||
get => (string)GetValue(DefaultPackProperty);
|
||||
set => SetValue(DefaultPackProperty, value);
|
||||
}
|
||||
|
||||
public Brush IconBrush
|
||||
{
|
||||
get => (Brush)GetValue(IconBrushProperty);
|
||||
set => SetValue(IconBrushProperty, value);
|
||||
}
|
||||
|
||||
public double StrokeThickness
|
||||
{
|
||||
get => (double)GetValue(StrokeThicknessProperty);
|
||||
set => SetValue(StrokeThicknessProperty, value);
|
||||
}
|
||||
|
||||
public bool UseOriginalColor
|
||||
{
|
||||
get => (bool)GetValue(UseOriginalColorProperty);
|
||||
set => SetValue(UseOriginalColorProperty, value);
|
||||
}
|
||||
|
||||
public Stretch Stretch
|
||||
{
|
||||
get => (Stretch)GetValue(StretchProperty);
|
||||
set => SetValue(StretchProperty, value);
|
||||
}
|
||||
|
||||
public IAnimation AnimateIconBrushTo(
|
||||
NColor color,
|
||||
TimeSpan? duration = null,
|
||||
IEasing? easing = null,
|
||||
string? animationKey = null)
|
||||
{
|
||||
_EnsureAnimatableIconBrush();
|
||||
|
||||
var animation = new NColorFromToAnimation
|
||||
{
|
||||
Name = animationKey ?? $"SvgIconColor {RuntimeHelpers.GetHashCode(this)}",
|
||||
To = color,
|
||||
Duration = duration ?? TimeSpan.FromMilliseconds(120),
|
||||
Easing = easing ?? CubicEaseOut.Shared
|
||||
};
|
||||
|
||||
return animation.RunFireAndForget(new WpfAnimatable(this, IconBrushProperty));
|
||||
}
|
||||
|
||||
protected override Size MeasureOverride(Size availableSize)
|
||||
{
|
||||
var model = _GetModel();
|
||||
var naturalSize = model is null
|
||||
? new Size(24D, 24D)
|
||||
: new Size(model.Width, model.Height);
|
||||
|
||||
if (double.IsInfinity(availableSize.Width) && double.IsInfinity(availableSize.Height))
|
||||
return naturalSize;
|
||||
|
||||
if (double.IsInfinity(availableSize.Width))
|
||||
return new Size(naturalSize.Width * availableSize.Height / naturalSize.Height, availableSize.Height);
|
||||
|
||||
if (double.IsInfinity(availableSize.Height))
|
||||
return new Size(availableSize.Width, naturalSize.Height * availableSize.Width / naturalSize.Width);
|
||||
|
||||
return availableSize;
|
||||
}
|
||||
|
||||
protected override void OnRender(DrawingContext drawingContext)
|
||||
{
|
||||
base.OnRender(drawingContext);
|
||||
|
||||
var model = _GetModel();
|
||||
if (model is null || model.Elements.Count == 0 || RenderSize.Width <= 0D || RenderSize.Height <= 0D)
|
||||
return;
|
||||
|
||||
var target = _CalculateTargetRect(new Size(model.Width, model.Height), RenderSize, Stretch);
|
||||
if (target.Width <= 0D || target.Height <= 0D)
|
||||
return;
|
||||
|
||||
var scaleX = target.Width / model.Width;
|
||||
var scaleY = target.Height / model.Height;
|
||||
|
||||
drawingContext.PushTransform(new TranslateTransform(target.X, target.Y));
|
||||
drawingContext.PushTransform(new ScaleTransform(scaleX, scaleY));
|
||||
drawingContext.PushTransform(new TranslateTransform(-model.MinX, -model.MinY));
|
||||
|
||||
var options = new SvgIconPaintOptions(IconBrush, StrokeThickness, UseOriginalColor);
|
||||
foreach (var element in model.Elements)
|
||||
element.Draw(drawingContext, options);
|
||||
|
||||
drawingContext.Pop();
|
||||
drawingContext.Pop();
|
||||
drawingContext.Pop();
|
||||
}
|
||||
|
||||
private SvgIconModel? _GetModel()
|
||||
{
|
||||
if (_modelLoaded)
|
||||
return _model;
|
||||
|
||||
_model = SvgIconLoader.Load(Icon, DefaultPack);
|
||||
_modelLoaded = true;
|
||||
return _model;
|
||||
}
|
||||
|
||||
private static void _OnIconChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs args)
|
||||
{
|
||||
var icon = (SvgIcon)dependencyObject;
|
||||
icon._model = null;
|
||||
icon._modelLoaded = false;
|
||||
}
|
||||
|
||||
private void _EnsureAnimatableIconBrush()
|
||||
{
|
||||
if (IconBrush is SolidColorBrush { IsFrozen: false })
|
||||
return;
|
||||
|
||||
IconBrush = IconBrush switch
|
||||
{
|
||||
SolidColorBrush solidColorBrush => new SolidColorBrush(solidColorBrush.Color),
|
||||
_ => new SolidColorBrush(Colors.Black)
|
||||
};
|
||||
}
|
||||
|
||||
private static Rect _CalculateTargetRect(Size sourceSize, Size renderSize, Stretch stretch)
|
||||
{
|
||||
if (sourceSize.Width <= 0D || sourceSize.Height <= 0D)
|
||||
sourceSize = new Size(24D, 24D);
|
||||
|
||||
if (stretch == Stretch.None)
|
||||
{
|
||||
var x = (renderSize.Width - sourceSize.Width) / 2D;
|
||||
var y = (renderSize.Height - sourceSize.Height) / 2D;
|
||||
return new Rect(x, y, sourceSize.Width, sourceSize.Height);
|
||||
}
|
||||
|
||||
var scaleX = renderSize.Width / sourceSize.Width;
|
||||
var scaleY = renderSize.Height / sourceSize.Height;
|
||||
|
||||
var scale = stretch switch
|
||||
{
|
||||
Stretch.Fill => double.NaN,
|
||||
Stretch.UniformToFill => Math.Max(scaleX, scaleY),
|
||||
_ => Math.Min(scaleX, scaleY)
|
||||
};
|
||||
|
||||
var width = stretch == Stretch.Fill ? renderSize.Width : sourceSize.Width * scale;
|
||||
var height = stretch == Stretch.Fill ? renderSize.Height : sourceSize.Height * scale;
|
||||
var left = (renderSize.Width - width) / 2D;
|
||||
var top = (renderSize.Height - height) / 2D;
|
||||
|
||||
return new Rect(left, top, width, height);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace PCL.Core.UI.Controls.SvgIcon;
|
||||
|
||||
internal sealed class SvgIconElement
|
||||
{
|
||||
public required SvgIconElementKind Kind { get; init; }
|
||||
public required Geometry Geometry { get; init; }
|
||||
public required SvgIconStyle Style { get; init; }
|
||||
|
||||
public bool PreferStrokeByDefault => Kind is SvgIconElementKind.Line or SvgIconElementKind.Polyline;
|
||||
|
||||
public void Draw(DrawingContext context, SvgIconPaintOptions options)
|
||||
{
|
||||
if (Style.Opacity <= 0D)
|
||||
return;
|
||||
|
||||
var fill = _ResolveFill(options);
|
||||
var pen = _ResolvePen(options);
|
||||
|
||||
if (fill is null && pen is null)
|
||||
return;
|
||||
|
||||
context.DrawGeometry(fill, pen, Geometry);
|
||||
}
|
||||
|
||||
private Brush? _ResolveFill(SvgIconPaintOptions options)
|
||||
{
|
||||
var hasFill = _HasPaint(Style.Fill);
|
||||
var hasStroke = _HasPaint(Style.Stroke);
|
||||
var explicitlyNoFill = _IsNone(Style.Fill);
|
||||
Brush? brush;
|
||||
|
||||
if (!options.UseOriginalColor)
|
||||
{
|
||||
if (explicitlyNoFill)
|
||||
return null;
|
||||
|
||||
if (!hasFill && (hasStroke || PreferStrokeByDefault))
|
||||
return null;
|
||||
|
||||
brush = options.IconBrush;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (explicitlyNoFill)
|
||||
return null;
|
||||
|
||||
if (hasFill)
|
||||
brush = SvgPaintParser.ParseBrush(Style.Fill, options.IconBrush);
|
||||
else if (!hasStroke && !PreferStrokeByDefault)
|
||||
brush = Brushes.Black;
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
return _ApplyOpacity(brush, Style.Opacity * Style.FillOpacity);
|
||||
}
|
||||
|
||||
private Pen? _ResolvePen(SvgIconPaintOptions options)
|
||||
{
|
||||
var hasStroke = _HasPaint(Style.Stroke);
|
||||
var explicitlyNoStroke = _IsNone(Style.Stroke);
|
||||
Brush? brush;
|
||||
|
||||
if (!options.UseOriginalColor)
|
||||
{
|
||||
if (explicitlyNoStroke)
|
||||
return null;
|
||||
|
||||
if (!hasStroke && !PreferStrokeByDefault)
|
||||
return null;
|
||||
|
||||
brush = options.IconBrush;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (explicitlyNoStroke)
|
||||
return null;
|
||||
|
||||
if (hasStroke)
|
||||
brush = SvgPaintParser.ParseBrush(Style.Stroke, options.IconBrush);
|
||||
else if (PreferStrokeByDefault)
|
||||
brush = Brushes.Black;
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
return _CreatePen(_ApplyOpacity(brush, Style.Opacity * Style.StrokeOpacity),
|
||||
Style.StrokeWidth ?? options.StrokeThickness);
|
||||
}
|
||||
|
||||
private Pen? _CreatePen(Brush? brush, double thickness)
|
||||
{
|
||||
if (brush is null || thickness <= 0D)
|
||||
return null;
|
||||
|
||||
return new Pen(brush, thickness)
|
||||
{
|
||||
StartLineCap = _ParseLineCap(Style.StrokeLineCap),
|
||||
EndLineCap = _ParseLineCap(Style.StrokeLineCap),
|
||||
LineJoin = _ParseLineJoin(Style.StrokeLineJoin)
|
||||
};
|
||||
}
|
||||
|
||||
private static Brush? _ApplyOpacity(Brush? brush, double opacity)
|
||||
{
|
||||
if (brush is null)
|
||||
return null;
|
||||
|
||||
opacity = Math.Clamp(opacity, 0D, 1D);
|
||||
if (opacity <= 0D)
|
||||
return null;
|
||||
|
||||
if (Math.Abs(opacity - 1D) < 0.0001D)
|
||||
return brush;
|
||||
|
||||
var clone = brush.CloneCurrentValue();
|
||||
clone.Opacity *= opacity;
|
||||
if (clone.CanFreeze)
|
||||
clone.Freeze();
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static bool _HasPaint(string? value)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(value) && !_IsNone(value);
|
||||
}
|
||||
|
||||
private static bool _IsNone(string? value)
|
||||
{
|
||||
return string.Equals(value?.Trim(), "none", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static PenLineCap _ParseLineCap(string? value)
|
||||
{
|
||||
return value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"butt" => PenLineCap.Flat,
|
||||
"square" => PenLineCap.Square,
|
||||
"round" => PenLineCap.Round,
|
||||
_ => PenLineCap.Round
|
||||
};
|
||||
}
|
||||
|
||||
private static PenLineJoin _ParseLineJoin(string? value)
|
||||
{
|
||||
return value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"miter" => PenLineJoin.Miter,
|
||||
"bevel" => PenLineJoin.Bevel,
|
||||
"round" => PenLineJoin.Round,
|
||||
_ => PenLineJoin.Round
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace PCL.Core.UI.Controls.SvgIcon;
|
||||
|
||||
internal enum SvgIconElementKind
|
||||
{
|
||||
Path,
|
||||
Line,
|
||||
Circle,
|
||||
Ellipse,
|
||||
Rect,
|
||||
Polyline,
|
||||
Polygon
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using PCL.Core.Logging;
|
||||
|
||||
namespace PCL.Core.UI.Controls.SvgIcon;
|
||||
|
||||
public static class SvgIconLoader
|
||||
{
|
||||
public const string DefaultIconPack = "default";
|
||||
|
||||
private static readonly string _AssemblyName =
|
||||
typeof(SvgIconLoader).Assembly.GetName().Name ?? "PCL.Core";
|
||||
|
||||
private static readonly ConcurrentDictionary<string, Lazy<SvgIconModel?>> _Cache =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
internal static SvgIconModel? Load(string? icon, string? defaultPack = null)
|
||||
{
|
||||
var key = SvgIconKey.TryParse(icon, defaultPack ?? DefaultIconPack);
|
||||
if (key is null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(icon))
|
||||
_LogDebug($"无效的 SVG 图标标识:{icon}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var cacheKey = key.Value.ToString();
|
||||
return _Cache.GetOrAdd(cacheKey, _ => new Lazy<SvgIconModel?>(() => _LoadCore(key.Value))).Value;
|
||||
}
|
||||
|
||||
public static void ClearCache()
|
||||
{
|
||||
_Cache.Clear();
|
||||
}
|
||||
|
||||
private static SvgIconModel? _LoadCore(SvgIconKey key)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(
|
||||
$"pack://application:,,,/{_AssemblyName};component/UI/Assets/IconPacks/{key.Pack}/{key.Name}.svg",
|
||||
UriKind.Absolute);
|
||||
var info = Application.GetResourceStream(uri);
|
||||
if (info is null)
|
||||
{
|
||||
_LogDebug($"缺少 SVG 图标资源:{key} ({uri})");
|
||||
return null;
|
||||
}
|
||||
|
||||
using var stream = info.Stream;
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, true);
|
||||
var svg = reader.ReadToEnd();
|
||||
return SvgIconParser.Parse(svg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_LogDebug($"加载 SVG 图标失败:{key}", ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void _LogDebug(string message, Exception? ex = null)
|
||||
{
|
||||
#if DEBUG
|
||||
if (ex is null)
|
||||
LogWrapper.Debug("SvgIcon", message);
|
||||
else
|
||||
LogWrapper.Debug(ex, "SvgIcon", message);
|
||||
#endif
|
||||
}
|
||||
|
||||
private readonly record struct SvgIconKey(string Pack, string Name)
|
||||
{
|
||||
public static SvgIconKey? TryParse(string? icon, string defaultPack)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(icon))
|
||||
return null;
|
||||
|
||||
var normalized = icon.Trim().Replace('\\', '/');
|
||||
if (normalized.EndsWith(".svg", StringComparison.OrdinalIgnoreCase))
|
||||
normalized = normalized[..^4];
|
||||
|
||||
normalized = normalized.Trim('/');
|
||||
if (normalized.Length == 0 || normalized.Contains("..", StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
var parts = normalized.Split('/', 2,
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var pack = parts.Length == 2 ? parts[0] : defaultPack;
|
||||
var name = parts.Length == 2 ? parts[1] : parts[0];
|
||||
|
||||
if (!_IsSafeResourcePath(pack) || !_IsSafeResourcePath(name))
|
||||
return null;
|
||||
|
||||
return new SvgIconKey(pack, name);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{Pack}/{Name}";
|
||||
}
|
||||
|
||||
private static bool _IsSafeResourcePath(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value.StartsWith('/') || value.EndsWith('/'))
|
||||
return false;
|
||||
|
||||
return value.Split('/', StringSplitOptions.RemoveEmptyEntries).All(_IsSafeResourceSegment);
|
||||
}
|
||||
|
||||
private static bool _IsSafeResourceSegment(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value is "." or "..")
|
||||
return false;
|
||||
|
||||
return value.All(ch => char.IsLetterOrDigit(ch) || ch is '-' or '_' or '.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PCL.Core.UI.Controls.SvgIcon;
|
||||
|
||||
internal sealed class SvgIconModel
|
||||
{
|
||||
public double MinX { get; init; }
|
||||
public double MinY { get; init; }
|
||||
public double Width { get; init; } = 24D;
|
||||
public double Height { get; init; } = 24D;
|
||||
public IReadOnlyList<SvgIconElement> Elements { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace PCL.Core.UI.Controls.SvgIcon;
|
||||
|
||||
internal readonly record struct SvgIconPaintOptions(
|
||||
Brush IconBrush,
|
||||
double StrokeThickness,
|
||||
bool UseOriginalColor);
|
||||
@@ -0,0 +1,280 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using PCL.Core.Logging;
|
||||
|
||||
namespace PCL.Core.UI.Controls.SvgIcon;
|
||||
|
||||
internal static class SvgIconParser
|
||||
{
|
||||
public static SvgIconModel Parse(string svg)
|
||||
{
|
||||
using var stringReader = new StringReader(svg);
|
||||
using var xmlReader = XmlReader.Create(stringReader, new XmlReaderSettings
|
||||
{
|
||||
DtdProcessing = DtdProcessing.Prohibit,
|
||||
XmlResolver = null
|
||||
});
|
||||
|
||||
var document = XDocument.Load(xmlReader, LoadOptions.None);
|
||||
var root = document.Root ?? throw new FormatException("SVG 文件缺少根节点。");
|
||||
|
||||
var (minX, minY, width, height) = _ReadViewBox(root);
|
||||
var elements = new List<SvgIconElement>();
|
||||
|
||||
_ReadElements(root, elements, new SvgIconStyle());
|
||||
|
||||
return new SvgIconModel
|
||||
{
|
||||
MinX = minX,
|
||||
MinY = minY,
|
||||
Width = width <= 0D ? 24D : width,
|
||||
Height = height <= 0D ? 24D : height,
|
||||
Elements = elements
|
||||
};
|
||||
}
|
||||
|
||||
private static void _ReadElements(
|
||||
XElement parent,
|
||||
ICollection<SvgIconElement> result,
|
||||
SvgIconStyle inheritedStyle)
|
||||
{
|
||||
var parentStyle = inheritedStyle.Merge(parent);
|
||||
|
||||
foreach (var element in parent.Elements())
|
||||
{
|
||||
var name = element.Name.LocalName;
|
||||
|
||||
if (name is "g" or "svg")
|
||||
{
|
||||
_ReadElements(element, result, parentStyle);
|
||||
continue;
|
||||
}
|
||||
|
||||
var style = parentStyle.Merge(element);
|
||||
var iconElement = _CreateElement(name, element, style);
|
||||
if (iconElement is not null)
|
||||
result.Add(iconElement);
|
||||
}
|
||||
}
|
||||
|
||||
private static SvgIconElement? _CreateElement(string name, XElement element, SvgIconStyle style)
|
||||
{
|
||||
try
|
||||
{
|
||||
return name switch
|
||||
{
|
||||
"path" => _CreatePath(element, style),
|
||||
"line" => _CreateLine(element, style),
|
||||
"circle" => _CreateCircle(element, style),
|
||||
"ellipse" => _CreateEllipse(element, style),
|
||||
"rect" => _CreateRect(element, style),
|
||||
"polyline" => _CreatePolyline(element, style),
|
||||
"polygon" => _CreatePolygon(element, style),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
#if DEBUG
|
||||
var descriptor = name;
|
||||
var d = _Attr(element, "d");
|
||||
if (!string.IsNullOrWhiteSpace(d))
|
||||
descriptor += $" d=\"{(d.Length > 80 ? d[..80] + "..." : d)}\"";
|
||||
|
||||
LogWrapper.Debug(ex, "SvgIcon", $"跳过无法解析的 SVG 元素:{descriptor}");
|
||||
#endif
|
||||
// 单个图元解析失败时跳过,避免一个不兼容节点导致整个图标不可用。
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static SvgIconElement? _CreatePath(XElement element, SvgIconStyle style)
|
||||
{
|
||||
var d = _Attr(element, "d");
|
||||
if (string.IsNullOrWhiteSpace(d))
|
||||
return null;
|
||||
|
||||
var geometry = SvgPathGeometryParser.Parse(d);
|
||||
_ApplyFillRule(geometry, style);
|
||||
_TryFreeze(geometry);
|
||||
|
||||
return new SvgIconElement
|
||||
{
|
||||
Kind = SvgIconElementKind.Path,
|
||||
Geometry = geometry,
|
||||
Style = style
|
||||
};
|
||||
}
|
||||
|
||||
private static SvgIconElement _CreateLine(XElement element, SvgIconStyle style)
|
||||
{
|
||||
var geometry = new LineGeometry(
|
||||
new Point(_Number(element, "x1"), _Number(element, "y1")),
|
||||
new Point(_Number(element, "x2"), _Number(element, "y2")));
|
||||
|
||||
_TryFreeze(geometry);
|
||||
|
||||
return new SvgIconElement
|
||||
{
|
||||
Kind = SvgIconElementKind.Line,
|
||||
Geometry = geometry,
|
||||
Style = style
|
||||
};
|
||||
}
|
||||
|
||||
private static SvgIconElement _CreateCircle(XElement element, SvgIconStyle style)
|
||||
{
|
||||
var geometry = new EllipseGeometry(
|
||||
new Point(_Number(element, "cx"), _Number(element, "cy")),
|
||||
_Number(element, "r"),
|
||||
_Number(element, "r"));
|
||||
|
||||
_TryFreeze(geometry);
|
||||
|
||||
return new SvgIconElement
|
||||
{
|
||||
Kind = SvgIconElementKind.Circle,
|
||||
Geometry = geometry,
|
||||
Style = style
|
||||
};
|
||||
}
|
||||
|
||||
private static SvgIconElement _CreateEllipse(XElement element, SvgIconStyle style)
|
||||
{
|
||||
var geometry = new EllipseGeometry(
|
||||
new Point(_Number(element, "cx"), _Number(element, "cy")),
|
||||
_Number(element, "rx"),
|
||||
_Number(element, "ry"));
|
||||
|
||||
_TryFreeze(geometry);
|
||||
|
||||
return new SvgIconElement
|
||||
{
|
||||
Kind = SvgIconElementKind.Ellipse,
|
||||
Geometry = geometry,
|
||||
Style = style
|
||||
};
|
||||
}
|
||||
|
||||
private static SvgIconElement _CreateRect(XElement element, SvgIconStyle style)
|
||||
{
|
||||
var x = _Number(element, "x");
|
||||
var y = _Number(element, "y");
|
||||
var width = _Number(element, "width");
|
||||
var height = _Number(element, "height");
|
||||
var rx = _Number(element, "rx", _Number(element, "ry"));
|
||||
var ry = _Number(element, "ry", rx);
|
||||
|
||||
var geometry = new RectangleGeometry(new Rect(x, y, width, height), rx, ry);
|
||||
_TryFreeze(geometry);
|
||||
|
||||
return new SvgIconElement
|
||||
{
|
||||
Kind = SvgIconElementKind.Rect,
|
||||
Geometry = geometry,
|
||||
Style = style
|
||||
};
|
||||
}
|
||||
|
||||
private static SvgIconElement? _CreatePolyline(XElement element, SvgIconStyle style)
|
||||
{
|
||||
var geometry = _CreatePointsGeometry(_Attr(element, "points"), false);
|
||||
if (geometry is null)
|
||||
return null;
|
||||
|
||||
return new SvgIconElement
|
||||
{
|
||||
Kind = SvgIconElementKind.Polyline,
|
||||
Geometry = geometry,
|
||||
Style = style
|
||||
};
|
||||
}
|
||||
|
||||
private static SvgIconElement? _CreatePolygon(XElement element, SvgIconStyle style)
|
||||
{
|
||||
var geometry = _CreatePointsGeometry(_Attr(element, "points"), true);
|
||||
if (geometry is null)
|
||||
return null;
|
||||
|
||||
return new SvgIconElement
|
||||
{
|
||||
Kind = SvgIconElementKind.Polygon,
|
||||
Geometry = geometry,
|
||||
Style = style
|
||||
};
|
||||
}
|
||||
|
||||
private static StreamGeometry? _CreatePointsGeometry(string? points, bool close)
|
||||
{
|
||||
var numbers = SvgNumberParser.ParseNumberList(points);
|
||||
if (numbers.Length < 4)
|
||||
return null;
|
||||
|
||||
var geometry = new StreamGeometry
|
||||
{
|
||||
FillRule = close ? FillRule.Nonzero : FillRule.EvenOdd
|
||||
};
|
||||
using (var context = geometry.Open())
|
||||
{
|
||||
context.BeginFigure(new Point(numbers[0], numbers[1]), close, close);
|
||||
for (var i = 2; i + 1 < numbers.Length; i += 2)
|
||||
context.LineTo(new Point(numbers[i], numbers[i + 1]), true, false);
|
||||
}
|
||||
|
||||
_TryFreeze(geometry);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
|
||||
private static (double MinX, double MinY, double Width, double Height) _ReadViewBox(XElement root)
|
||||
{
|
||||
var viewBox = _Attr(root, "viewBox");
|
||||
var numbers = SvgNumberParser.ParseNumberList(viewBox);
|
||||
if (numbers.Length == 4)
|
||||
return (numbers[0], numbers[1], numbers[2], numbers[3]);
|
||||
|
||||
var width = SvgNumberParser.TryParseNullable(_Attr(root, "width")) ?? 24D;
|
||||
var height = SvgNumberParser.TryParseNullable(_Attr(root, "height")) ?? 24D;
|
||||
return (0D, 0D, width, height);
|
||||
}
|
||||
|
||||
private static double _Number(XElement element, string name, double fallback = 0D)
|
||||
{
|
||||
return SvgNumberParser.TryParse(_Attr(element, name), fallback);
|
||||
}
|
||||
|
||||
private static string? _Attr(XElement element, string name)
|
||||
{
|
||||
return element.Attribute(name)?.Value;
|
||||
}
|
||||
|
||||
private static void _ApplyFillRule(Geometry geometry, SvgIconStyle style)
|
||||
{
|
||||
var fillRule = style.FillRule?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"evenodd" => FillRule.EvenOdd,
|
||||
_ => FillRule.Nonzero
|
||||
};
|
||||
|
||||
switch (geometry)
|
||||
{
|
||||
case StreamGeometry streamGeometry:
|
||||
streamGeometry.FillRule = fillRule;
|
||||
break;
|
||||
case PathGeometry pathGeometry:
|
||||
pathGeometry.FillRule = fillRule;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void _TryFreeze(Freezable freezable)
|
||||
{
|
||||
if (freezable.CanFreeze)
|
||||
freezable.Freeze();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace PCL.Core.UI.Controls.SvgIcon;
|
||||
|
||||
internal sealed record SvgIconStyle
|
||||
{
|
||||
public string? Fill { get; init; }
|
||||
public string? Stroke { get; init; }
|
||||
public string? StrokeLineCap { get; init; }
|
||||
public string? StrokeLineJoin { get; init; }
|
||||
public string? FillRule { get; init; }
|
||||
public double? StrokeWidth { get; init; }
|
||||
public double Opacity { get; init; } = 1D;
|
||||
public double FillOpacity { get; init; } = 1D;
|
||||
public double StrokeOpacity { get; init; } = 1D;
|
||||
|
||||
public SvgIconStyle Merge(XElement element)
|
||||
{
|
||||
var inlineStyle = _ParseStyle(element.Attribute("style")?.Value);
|
||||
|
||||
var opacity = _GetAttributeOrStyle(element, inlineStyle, "opacity");
|
||||
var fillOpacity = _GetAttributeOrStyle(element, inlineStyle, "fill-opacity");
|
||||
var strokeOpacity = _GetAttributeOrStyle(element, inlineStyle, "stroke-opacity");
|
||||
|
||||
return new SvgIconStyle
|
||||
{
|
||||
Fill = _GetAttributeOrStyle(element, inlineStyle, "fill") ?? Fill,
|
||||
Stroke = _GetAttributeOrStyle(element, inlineStyle, "stroke") ?? Stroke,
|
||||
StrokeLineCap = _GetAttributeOrStyle(element, inlineStyle, "stroke-linecap") ?? StrokeLineCap,
|
||||
StrokeLineJoin = _GetAttributeOrStyle(element, inlineStyle, "stroke-linejoin") ?? StrokeLineJoin,
|
||||
FillRule = _GetAttributeOrStyle(element, inlineStyle, "fill-rule") ?? FillRule,
|
||||
StrokeWidth = SvgNumberParser.TryParseNullable(
|
||||
_GetAttributeOrStyle(element, inlineStyle, "stroke-width")) ?? StrokeWidth,
|
||||
Opacity = Opacity * SvgNumberParser.TryParse(opacity, 1D),
|
||||
FillOpacity = SvgNumberParser.TryParseNullable(fillOpacity) ?? FillOpacity,
|
||||
StrokeOpacity = SvgNumberParser.TryParseNullable(strokeOpacity) ?? StrokeOpacity
|
||||
};
|
||||
}
|
||||
|
||||
private static string? _GetAttributeOrStyle(
|
||||
XElement element,
|
||||
IReadOnlyDictionary<string, string> style,
|
||||
string name)
|
||||
{
|
||||
var directValue = element.Attribute(name)?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(directValue))
|
||||
return directValue.Trim();
|
||||
|
||||
return style.TryGetValue(name, out var styleValue) && !string.IsNullOrWhiteSpace(styleValue)
|
||||
? styleValue.Trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> _ParseStyle(string? value)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return result;
|
||||
|
||||
foreach (var part in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var pair = part.Split(':', 2, StringSplitOptions.TrimEntries);
|
||||
if (pair is [{ Length: > 0 }, _])
|
||||
result[pair[0]] = pair[1];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace PCL.Core.UI.Controls.SvgIcon;
|
||||
|
||||
internal static partial class SvgNumberParser
|
||||
{
|
||||
[GeneratedRegex(@"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?")]
|
||||
private static partial Regex _NumberRegex();
|
||||
|
||||
public static double TryParse(string? value, double fallback = 0D)
|
||||
{
|
||||
var parsed = TryParseNullable(value);
|
||||
return parsed ?? fallback;
|
||||
}
|
||||
|
||||
public static double? TryParseNullable(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
var match = _NumberRegex().Match(value.Trim());
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
return double.TryParse(match.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result)
|
||||
? result
|
||||
: null;
|
||||
}
|
||||
|
||||
public static double[] ParseNumberList(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return [];
|
||||
|
||||
var matches = _NumberRegex().Matches(value);
|
||||
var result = new double[matches.Count];
|
||||
for (var i = 0; i < matches.Count; i++)
|
||||
result[i] = double.Parse(matches[i].Value, NumberStyles.Float, CultureInfo.InvariantCulture);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace PCL.Core.UI.Controls.SvgIcon;
|
||||
|
||||
internal static class SvgPaintParser
|
||||
{
|
||||
private static readonly BrushConverter _BrushConverter = new();
|
||||
|
||||
public static Brush? ParseBrush(string? value, Brush currentColorBrush)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
var normalized = value.Trim();
|
||||
if (normalized.Equals("none", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
if (normalized.Equals("currentColor", StringComparison.OrdinalIgnoreCase))
|
||||
return currentColorBrush;
|
||||
|
||||
if (_TryParseRgbFunction(normalized, out var rgbBrush))
|
||||
return rgbBrush;
|
||||
|
||||
try
|
||||
{
|
||||
return _BrushConverter.ConvertFromInvariantString(normalized) as Brush;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return currentColorBrush;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool _TryParseRgbFunction(string value, out Brush brush)
|
||||
{
|
||||
brush = null!;
|
||||
|
||||
if (!value.StartsWith("rgb", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var start = value.IndexOf('(');
|
||||
var end = value.LastIndexOf(')');
|
||||
if (start < 0 || end <= start)
|
||||
return false;
|
||||
|
||||
var parts = value[(start + 1)..end]
|
||||
.Split([',', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
if (parts.Length < 3)
|
||||
return false;
|
||||
|
||||
var r = _ParseColorComponent(parts[0]);
|
||||
var g = _ParseColorComponent(parts[1]);
|
||||
var b = _ParseColorComponent(parts[2]);
|
||||
var a = parts.Length >= 4 ? _ParseAlpha(parts[3]) : (byte)255;
|
||||
|
||||
if (r is null || g is null || b is null)
|
||||
return false;
|
||||
|
||||
brush = new SolidColorBrush(Color.FromArgb(a, r.Value, g.Value, b.Value));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static byte? _ParseColorComponent(string value)
|
||||
{
|
||||
if (value.EndsWith('%'))
|
||||
{
|
||||
if (!double.TryParse(value[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent))
|
||||
return null;
|
||||
|
||||
return (byte)Math.Clamp(Math.Round(percent / 100D * 255D), 0D, 255D);
|
||||
}
|
||||
|
||||
return double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var raw)
|
||||
? (byte)Math.Clamp(Math.Round(raw), 0D, 255D)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static byte _ParseAlpha(string value)
|
||||
{
|
||||
if (value.EndsWith('%'))
|
||||
{
|
||||
if (double.TryParse(value[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent))
|
||||
return (byte)Math.Clamp(Math.Round(percent / 100D * 255D), 0D, 255D);
|
||||
|
||||
return 255;
|
||||
}
|
||||
|
||||
return double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var raw)
|
||||
? (byte)Math.Clamp(Math.Round(raw <= 1D ? raw * 255D : raw), 0D, 255D)
|
||||
: (byte)255;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace PCL.Core.UI.Controls.SvgIcon;
|
||||
|
||||
internal static class SvgPathGeometryParser
|
||||
{
|
||||
public static Geometry Parse(string data)
|
||||
{
|
||||
var parser = new Parser(data);
|
||||
return parser.Parse();
|
||||
}
|
||||
|
||||
private static List<Token> _Tokenize(string data)
|
||||
{
|
||||
var tokens = new List<Token>();
|
||||
var index = 0;
|
||||
|
||||
while (index < data.Length)
|
||||
{
|
||||
var ch = data[index];
|
||||
if (char.IsWhiteSpace(ch) || ch == ',')
|
||||
{
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_IsCommand(ch))
|
||||
{
|
||||
tokens.Add(new Token(TokenKind.Command, ch, 0D));
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var start = index;
|
||||
if (ch is '+' or '-')
|
||||
index++;
|
||||
|
||||
var hasDigit = false;
|
||||
while (index < data.Length && char.IsDigit(data[index]))
|
||||
{
|
||||
index++;
|
||||
hasDigit = true;
|
||||
}
|
||||
|
||||
if (index < data.Length && data[index] == '.')
|
||||
{
|
||||
index++;
|
||||
while (index < data.Length && char.IsDigit(data[index]))
|
||||
{
|
||||
index++;
|
||||
hasDigit = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasDigit)
|
||||
throw new FormatException($"Invalid SVG path number near index {start}.");
|
||||
|
||||
if (index < data.Length && data[index] is 'e' or 'E')
|
||||
{
|
||||
var exponentStart = index;
|
||||
index++;
|
||||
if (index < data.Length && data[index] is '+' or '-')
|
||||
index++;
|
||||
|
||||
var exponentHasDigit = false;
|
||||
while (index < data.Length && char.IsDigit(data[index]))
|
||||
{
|
||||
index++;
|
||||
exponentHasDigit = true;
|
||||
}
|
||||
|
||||
if (!exponentHasDigit)
|
||||
index = exponentStart;
|
||||
}
|
||||
|
||||
var numberText = data[start..index];
|
||||
tokens.Add(new Token(
|
||||
TokenKind.Number,
|
||||
'\0',
|
||||
double.Parse(numberText, NumberStyles.Float, CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static bool _IsCommand(char ch)
|
||||
{
|
||||
return ch is 'M' or 'm'
|
||||
or 'Z' or 'z'
|
||||
or 'L' or 'l'
|
||||
or 'H' or 'h'
|
||||
or 'V' or 'v'
|
||||
or 'C' or 'c'
|
||||
or 'S' or 's'
|
||||
or 'Q' or 'q'
|
||||
or 'T' or 't'
|
||||
or 'A' or 'a';
|
||||
}
|
||||
|
||||
private enum TokenKind
|
||||
{
|
||||
Command,
|
||||
Number
|
||||
}
|
||||
|
||||
private readonly record struct Token(TokenKind Kind, char Command, double Number);
|
||||
|
||||
private sealed class Parser(string data)
|
||||
{
|
||||
private readonly PathGeometry _geometry = new();
|
||||
private readonly List<Token> _tokens = _Tokenize(data);
|
||||
private Point _current;
|
||||
private PathFigure? _figure;
|
||||
private bool _figureOpen;
|
||||
private Point _figureStart;
|
||||
private int _index;
|
||||
private char _lastCommand;
|
||||
private Point? _lastCubicControl;
|
||||
private Point? _lastQuadraticControl;
|
||||
|
||||
public Geometry Parse()
|
||||
{
|
||||
var command = '\0';
|
||||
while (_index < _tokens.Count)
|
||||
{
|
||||
if (_PeekCommand(out var nextCommand))
|
||||
{
|
||||
command = nextCommand;
|
||||
_index++;
|
||||
}
|
||||
else if (command == '\0')
|
||||
{
|
||||
throw new FormatException("SVG path data must start with a command.");
|
||||
}
|
||||
|
||||
_ExecuteCommand(command);
|
||||
}
|
||||
|
||||
return _geometry;
|
||||
}
|
||||
|
||||
private void _ExecuteCommand(char command)
|
||||
{
|
||||
switch (command)
|
||||
{
|
||||
case 'M':
|
||||
case 'm':
|
||||
_MoveTo(command == 'm');
|
||||
break;
|
||||
case 'L':
|
||||
case 'l':
|
||||
_LineTo(command == 'l');
|
||||
break;
|
||||
case 'H':
|
||||
case 'h':
|
||||
_HorizontalLineTo(command == 'h');
|
||||
break;
|
||||
case 'V':
|
||||
case 'v':
|
||||
_VerticalLineTo(command == 'v');
|
||||
break;
|
||||
case 'C':
|
||||
case 'c':
|
||||
_CubicBezierTo(command == 'c');
|
||||
break;
|
||||
case 'S':
|
||||
case 's':
|
||||
_SmoothCubicBezierTo(command == 's');
|
||||
break;
|
||||
case 'Q':
|
||||
case 'q':
|
||||
_QuadraticBezierTo(command == 'q');
|
||||
break;
|
||||
case 'T':
|
||||
case 't':
|
||||
_SmoothQuadraticBezierTo(command == 't');
|
||||
break;
|
||||
case 'A':
|
||||
case 'a':
|
||||
_ArcTo(command == 'a');
|
||||
break;
|
||||
case 'Z':
|
||||
case 'z':
|
||||
_CloseFigure();
|
||||
break;
|
||||
default:
|
||||
throw new FormatException($"Unsupported SVG path command: {command}");
|
||||
}
|
||||
}
|
||||
|
||||
private void _MoveTo(bool relative)
|
||||
{
|
||||
if (!_HasNumber())
|
||||
return;
|
||||
|
||||
var first = _ReadPoint(relative);
|
||||
_BeginFigure(first);
|
||||
_SetLastCommand('M');
|
||||
|
||||
// SVG 规范:M/m 后续坐标对等同 L/l。
|
||||
while (_HasNumber())
|
||||
_AddLine(_ReadPoint(relative));
|
||||
}
|
||||
|
||||
private void _LineTo(bool relative)
|
||||
{
|
||||
while (_HasNumber())
|
||||
_AddLine(_ReadPoint(relative));
|
||||
_SetLastCommand('L');
|
||||
}
|
||||
|
||||
private void _HorizontalLineTo(bool relative)
|
||||
{
|
||||
while (_HasNumber())
|
||||
{
|
||||
var x = _ReadNumber();
|
||||
_AddLine(new Point(relative ? _current.X + x : x, _current.Y));
|
||||
}
|
||||
|
||||
_SetLastCommand('H');
|
||||
}
|
||||
|
||||
private void _VerticalLineTo(bool relative)
|
||||
{
|
||||
while (_HasNumber())
|
||||
{
|
||||
var y = _ReadNumber();
|
||||
_AddLine(_current with { Y = relative ? _current.Y + y : y });
|
||||
}
|
||||
|
||||
_SetLastCommand('V');
|
||||
}
|
||||
|
||||
private void _CubicBezierTo(bool relative)
|
||||
{
|
||||
while (_HasNumber())
|
||||
{
|
||||
var control1 = _ReadPoint(relative);
|
||||
var control2 = _ReadPoint(relative);
|
||||
var end = _ReadPoint(relative);
|
||||
_EnsureFigure();
|
||||
|
||||
_figure!.Segments.Add(new BezierSegment(control1, control2, end, true));
|
||||
_current = end;
|
||||
_lastCubicControl = control2;
|
||||
_lastQuadraticControl = null;
|
||||
_lastCommand = 'C';
|
||||
}
|
||||
}
|
||||
|
||||
private void _SmoothCubicBezierTo(bool relative)
|
||||
{
|
||||
while (_HasNumber())
|
||||
{
|
||||
var control1 = _lastCommand is 'C' or 'S' && _lastCubicControl is not null
|
||||
? _Reflect(_lastCubicControl.Value, _current)
|
||||
: _current;
|
||||
var control2 = _ReadPoint(relative);
|
||||
var end = _ReadPoint(relative);
|
||||
|
||||
_EnsureFigure();
|
||||
_figure!.Segments.Add(new BezierSegment(control1, control2, end, true));
|
||||
_current = end;
|
||||
_lastCubicControl = control2;
|
||||
_lastQuadraticControl = null;
|
||||
_lastCommand = 'S';
|
||||
}
|
||||
}
|
||||
|
||||
private void _QuadraticBezierTo(bool relative)
|
||||
{
|
||||
while (_HasNumber())
|
||||
{
|
||||
var control = _ReadPoint(relative);
|
||||
var end = _ReadPoint(relative);
|
||||
_EnsureFigure();
|
||||
|
||||
_figure!.Segments.Add(new QuadraticBezierSegment(control, end, true));
|
||||
_current = end;
|
||||
_lastQuadraticControl = control;
|
||||
_lastCubicControl = null;
|
||||
_lastCommand = 'Q';
|
||||
}
|
||||
}
|
||||
|
||||
private void _SmoothQuadraticBezierTo(bool relative)
|
||||
{
|
||||
while (_HasNumber())
|
||||
{
|
||||
var control = _lastCommand is 'Q' or 'T' && _lastQuadraticControl is not null
|
||||
? _Reflect(_lastQuadraticControl.Value, _current)
|
||||
: _current;
|
||||
var end = _ReadPoint(relative);
|
||||
_EnsureFigure();
|
||||
|
||||
_figure!.Segments.Add(new QuadraticBezierSegment(control, end, true));
|
||||
_current = end;
|
||||
_lastQuadraticControl = control;
|
||||
_lastCubicControl = null;
|
||||
_lastCommand = 'T';
|
||||
}
|
||||
}
|
||||
|
||||
private void _ArcTo(bool relative)
|
||||
{
|
||||
while (_HasNumber())
|
||||
{
|
||||
var rx = Math.Abs(_ReadNumber());
|
||||
var ry = Math.Abs(_ReadNumber());
|
||||
var rotation = _ReadNumber();
|
||||
var isLargeArc = Math.Abs(_ReadNumber()) > 0D;
|
||||
var isClockwise = Math.Abs(_ReadNumber()) > 0D;
|
||||
var end = _ReadPoint(relative);
|
||||
|
||||
_EnsureFigure();
|
||||
if (rx <= 0D || ry <= 0D)
|
||||
_figure!.Segments.Add(new LineSegment(end, true));
|
||||
else
|
||||
_figure!.Segments.Add(new ArcSegment(
|
||||
end,
|
||||
new Size(rx, ry),
|
||||
rotation,
|
||||
isLargeArc,
|
||||
isClockwise ? SweepDirection.Clockwise : SweepDirection.Counterclockwise,
|
||||
true));
|
||||
|
||||
_current = end;
|
||||
_lastCubicControl = null;
|
||||
_lastQuadraticControl = null;
|
||||
_lastCommand = 'A';
|
||||
}
|
||||
}
|
||||
|
||||
private void _CloseFigure()
|
||||
{
|
||||
if (_figureOpen && _figure is not null)
|
||||
{
|
||||
_figure.IsClosed = true;
|
||||
_current = _figureStart;
|
||||
_figureOpen = false;
|
||||
}
|
||||
|
||||
_SetLastCommand('Z');
|
||||
}
|
||||
|
||||
private void _BeginFigure(Point point)
|
||||
{
|
||||
_figure = new PathFigure
|
||||
{
|
||||
StartPoint = point,
|
||||
IsClosed = false,
|
||||
IsFilled = true
|
||||
};
|
||||
_geometry.Figures.Add(_figure);
|
||||
_current = point;
|
||||
_figureStart = point;
|
||||
_figureOpen = true;
|
||||
_lastCubicControl = null;
|
||||
_lastQuadraticControl = null;
|
||||
}
|
||||
|
||||
private void _EnsureFigure()
|
||||
{
|
||||
if (_figureOpen && _figure is not null)
|
||||
return;
|
||||
|
||||
_BeginFigure(_current);
|
||||
}
|
||||
|
||||
private void _AddLine(Point point)
|
||||
{
|
||||
_EnsureFigure();
|
||||
_figure!.Segments.Add(new LineSegment(point, true));
|
||||
_current = point;
|
||||
_lastCubicControl = null;
|
||||
_lastQuadraticControl = null;
|
||||
_lastCommand = 'L';
|
||||
}
|
||||
|
||||
private Point _ReadPoint(bool relative)
|
||||
{
|
||||
var x = _ReadNumber();
|
||||
var y = _ReadNumber();
|
||||
return relative ? new Point(_current.X + x, _current.Y + y) : new Point(x, y);
|
||||
}
|
||||
|
||||
private double _ReadNumber()
|
||||
{
|
||||
return _HasNumber()
|
||||
? _tokens[_index++].Number
|
||||
: throw new FormatException("Expected number in SVG path data.");
|
||||
}
|
||||
|
||||
private bool _HasNumber()
|
||||
{
|
||||
return _index < _tokens.Count && _tokens[_index].Kind == TokenKind.Number;
|
||||
}
|
||||
|
||||
private bool _PeekCommand(out char command)
|
||||
{
|
||||
if (_index < _tokens.Count && _tokens[_index].Kind == TokenKind.Command)
|
||||
{
|
||||
command = _tokens[_index].Command;
|
||||
return true;
|
||||
}
|
||||
|
||||
command = '\0';
|
||||
return false;
|
||||
}
|
||||
|
||||
private void _SetLastCommand(char command)
|
||||
{
|
||||
_lastCommand = command;
|
||||
if (command is not ('C' or 'S'))
|
||||
_lastCubicControl = null;
|
||||
if (command is not ('Q' or 'T'))
|
||||
_lastQuadraticControl = null;
|
||||
}
|
||||
|
||||
private static Point _Reflect(Point point, Point around)
|
||||
{
|
||||
return new Point(around.X * 2D - point.X, around.Y * 2D - point.Y);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Effects;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace PCL.Core.UI.Controls;
|
||||
|
||||
public static class Tooltip
|
||||
{
|
||||
#region Attached Properties
|
||||
|
||||
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
|
||||
"IsEnabled", typeof(bool), typeof(Tooltip), new PropertyMetadata(true));
|
||||
|
||||
public static void SetIsEnabled(DependencyObject element, bool value) =>
|
||||
element.SetValue(IsEnabledProperty, value);
|
||||
|
||||
public static bool GetIsEnabled(DependencyObject element) =>
|
||||
(bool)element.GetValue(IsEnabledProperty);
|
||||
|
||||
public static readonly DependencyProperty FollowCursorProperty = DependencyProperty.RegisterAttached(
|
||||
"FollowCursor", typeof(bool), typeof(Tooltip), new PropertyMetadata(true));
|
||||
|
||||
public static void SetFollowCursor(DependencyObject element, bool value) =>
|
||||
element.SetValue(FollowCursorProperty, value);
|
||||
|
||||
public static bool GetFollowCursor(DependencyObject element) =>
|
||||
(bool)element.GetValue(FollowCursorProperty);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constants
|
||||
|
||||
private const double ScaleClosed = 0.97;
|
||||
private const double ShadowBlur = 18;
|
||||
private const double ShadowAlpha = 0.15;
|
||||
private const int MaxContentWidth = 676;
|
||||
private const double TipFontSize = 12.5;
|
||||
private const double TipLineHeight = 17;
|
||||
private const int AnimLength = 80;
|
||||
private const int AnimExit = 80;
|
||||
|
||||
private static readonly Thickness _InnerPad = new(12, 10, 12, 10);
|
||||
private static readonly DropShadowEffect _Shadow = new()
|
||||
{
|
||||
Opacity = ShadowAlpha,
|
||||
BlurRadius = ShadowBlur,
|
||||
ShadowDepth = 0,
|
||||
Color = Colors.Black
|
||||
};
|
||||
|
||||
#endregion
|
||||
|
||||
#region Per-Element Bookkeeping (Attached)
|
||||
|
||||
private static readonly DependencyProperty _KeyCombo = DependencyProperty.RegisterAttached(
|
||||
"KeyCombo", typeof(bool), typeof(Tooltip), new PropertyMetadata(false));
|
||||
|
||||
#endregion
|
||||
|
||||
#region Global State
|
||||
|
||||
private static bool _running;
|
||||
private static int _gen;
|
||||
private static bool _closing;
|
||||
private static Point _cursor;
|
||||
private static FrameworkElement? _target;
|
||||
private static Popup? _flyout;
|
||||
private static Border? _shell;
|
||||
private static ScaleTransform? _scaler;
|
||||
private static Storyboard? _openStory;
|
||||
private static Storyboard? _closeStory;
|
||||
private static DispatcherTimer? _latch;
|
||||
private static HwndSourceHook? _transparentHook;
|
||||
|
||||
private static readonly MouseEventHandler _OnEnterHandler = OnEnter;
|
||||
private static readonly MouseEventHandler _OnMoveHandler = OnMove;
|
||||
private static readonly MouseEventHandler _OnLeaveHandler = OnLeave;
|
||||
private static readonly MouseButtonEventHandler _OnReleaseHandler = OnRelease;
|
||||
private static readonly ToolTipEventHandler _OnOpeningHandler = OnOpening;
|
||||
private static readonly RoutedEventHandler _OnUnloadedHandler = OnUnloaded;
|
||||
private static readonly RoutedEventHandler _OnComboLoadedHandler = OnComboInit;
|
||||
private static readonly MouseButtonEventHandler _OnComboMouseDownHandler = OnComboInit;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Entry Point
|
||||
|
||||
public static void Enable()
|
||||
{
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
|
||||
_Shadow.Freeze();
|
||||
|
||||
_PrebuildStoryboards();
|
||||
|
||||
EventManager.RegisterClassHandler(typeof(FrameworkElement),
|
||||
UIElement.MouseEnterEvent, _OnEnterHandler, true);
|
||||
EventManager.RegisterClassHandler(typeof(FrameworkElement),
|
||||
UIElement.MouseMoveEvent, _OnMoveHandler, true);
|
||||
EventManager.RegisterClassHandler(typeof(FrameworkElement),
|
||||
UIElement.MouseLeaveEvent, _OnLeaveHandler, true);
|
||||
EventManager.RegisterClassHandler(typeof(FrameworkElement),
|
||||
UIElement.PreviewMouseUpEvent, _OnReleaseHandler, true);
|
||||
EventManager.RegisterClassHandler(typeof(FrameworkElement),
|
||||
ToolTipService.ToolTipOpeningEvent, _OnOpeningHandler, true);
|
||||
EventManager.RegisterClassHandler(typeof(FrameworkElement),
|
||||
FrameworkElement.UnloadedEvent, _OnUnloadedHandler, true);
|
||||
|
||||
EventManager.RegisterClassHandler(typeof(ComboBox),
|
||||
FrameworkElement.LoadedEvent, _OnComboLoadedHandler, true);
|
||||
EventManager.RegisterClassHandler(typeof(ComboBox),
|
||||
UIElement.PreviewMouseDownEvent, _OnComboMouseDownHandler, true);
|
||||
|
||||
EventManager.RegisterClassHandler(typeof(Window),
|
||||
UIElement.MouseLeaveEvent, new MouseEventHandler(OnWindowLeave), true);
|
||||
}
|
||||
|
||||
private static void OnWindowLeave(object s, MouseEventArgs e)
|
||||
{
|
||||
if (_target is not null)
|
||||
_WindDown();
|
||||
}
|
||||
|
||||
public static void Disable()
|
||||
{
|
||||
if (!_running) return;
|
||||
_running = false;
|
||||
_Hush();
|
||||
}
|
||||
|
||||
public static void Dismiss()
|
||||
{
|
||||
_WindDown();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Storyboard Setup
|
||||
|
||||
private static void _PrebuildStoryboards()
|
||||
{
|
||||
static DoubleAnimation MakeAnim(double to, string prop, int ms)
|
||||
{
|
||||
var a = new DoubleAnimation(to, new Duration(TimeSpan.FromMilliseconds(ms)))
|
||||
{
|
||||
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
|
||||
};
|
||||
Storyboard.SetTargetProperty(a, new PropertyPath(prop));
|
||||
return a;
|
||||
}
|
||||
|
||||
_openStory = new Storyboard();
|
||||
_openStory.Children.Add(MakeAnim(1, nameof(UIElement.Opacity), AnimLength));
|
||||
_openStory.Children.Add(MakeAnim(1, "RenderTransform.ScaleX", AnimLength));
|
||||
_openStory.Children.Add(MakeAnim(1, "RenderTransform.ScaleY", AnimLength));
|
||||
|
||||
_closeStory = new Storyboard();
|
||||
_closeStory.Children.Add(MakeAnim(0, nameof(UIElement.Opacity), AnimExit));
|
||||
_closeStory.Children.Add(MakeAnim(ScaleClosed, "RenderTransform.ScaleX", AnimExit));
|
||||
_closeStory.Children.Add(MakeAnim(ScaleClosed, "RenderTransform.ScaleY", AnimExit));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Trampolines
|
||||
|
||||
private static void OnEnter(object s, MouseEventArgs e)
|
||||
{
|
||||
if (!_running || s is not FrameworkElement fe) return;
|
||||
fe.Dispatcher.BeginInvoke(() => _TryClaim(fe));
|
||||
}
|
||||
|
||||
private static bool _IsCursorPlaced(FrameworkElement el) =>
|
||||
GetFollowCursor(el) && ToolTipService.GetPlacement(el) is PlacementMode.Mouse or PlacementMode.MousePoint;
|
||||
|
||||
private static void OnMove(object s, MouseEventArgs e)
|
||||
{
|
||||
if (!_running || s is not FrameworkElement fe) return;
|
||||
|
||||
if (Mouse.LeftButton == MouseButtonState.Pressed)
|
||||
{
|
||||
if (_target is not null)
|
||||
{
|
||||
_cursor = Mouse.GetPosition(_target);
|
||||
if (_IsCursorPlaced(_target) && _flyout is { IsOpen: true })
|
||||
_PlaceNear(_target, _cursor);
|
||||
}
|
||||
else if (_flyout is { IsOpen: true, PlacementTarget: FrameworkElement ft } && _IsCursorPlaced(ft))
|
||||
{
|
||||
_cursor = Mouse.GetPosition(ft);
|
||||
_PlaceNear(ft, _cursor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_TryClaim(fe);
|
||||
|
||||
if (_target is not null)
|
||||
{
|
||||
_cursor = Mouse.GetPosition(_target);
|
||||
if (_IsCursorPlaced(_target) && _flyout is { IsOpen: true })
|
||||
_PlaceNear(_target, _cursor);
|
||||
}
|
||||
else if (_flyout is { IsOpen: true, PlacementTarget: FrameworkElement ft } && _IsCursorPlaced(ft))
|
||||
{
|
||||
_cursor = Mouse.GetPosition(ft);
|
||||
_PlaceNear(ft, _cursor);
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnLeave(object s, MouseEventArgs e)
|
||||
{
|
||||
if (!_running || s is not FrameworkElement fe || !ReferenceEquals(fe, _target)) return;
|
||||
|
||||
if (_PointInside(fe, Mouse.GetPosition(fe)))
|
||||
{
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var next = _SeekOwner(_Over());
|
||||
if (next is not null && !ReferenceEquals(next, _target))
|
||||
{
|
||||
_StartCycle(next, Mouse.GetPosition(next));
|
||||
return;
|
||||
}
|
||||
|
||||
_WindDown();
|
||||
}
|
||||
|
||||
private static void OnRelease(object s, MouseButtonEventArgs e)
|
||||
{
|
||||
if (!_running || s is not FrameworkElement fe) return;
|
||||
fe.Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
if (_target is null) return;
|
||||
var owner = _SeekOwner(fe);
|
||||
if (owner is null)
|
||||
_WindDown();
|
||||
else
|
||||
_StartCycle(owner, Mouse.GetPosition(owner));
|
||||
}, DispatcherPriority.Input);
|
||||
}
|
||||
|
||||
private static void OnOpening(object s, ToolTipEventArgs e)
|
||||
{
|
||||
if (!_running || s is not FrameworkElement fe || !_Eligible(fe) || !_FetchContent(fe)) return;
|
||||
e.Handled = true;
|
||||
|
||||
if (_DragHush(fe))
|
||||
{
|
||||
_Hush();
|
||||
return;
|
||||
}
|
||||
|
||||
if (fe.IsEnabled) return;
|
||||
|
||||
if (!ReferenceEquals(_target, fe)) _Hush();
|
||||
_target = fe;
|
||||
_latch?.Stop();
|
||||
_cursor = Mouse.GetPosition(fe);
|
||||
_PopUp(fe, _cursor);
|
||||
}
|
||||
|
||||
private static void OnUnloaded(object s, RoutedEventArgs e)
|
||||
{
|
||||
if (!_running) return;
|
||||
if (s is FrameworkElement fe && ReferenceEquals(fe, _target))
|
||||
_WindDown();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Owner Resolution
|
||||
|
||||
private static void _TryClaim(FrameworkElement pivot)
|
||||
{
|
||||
var owner = _SeekOwner(_Over());
|
||||
var candidate = owner ?? pivot;
|
||||
|
||||
if (ReferenceEquals(candidate, pivot) && !_PointInside(pivot, Mouse.GetPosition(pivot)))
|
||||
return;
|
||||
|
||||
if (!_Eligible(candidate) || !_FetchContent(candidate))
|
||||
{
|
||||
if (_target is not null)
|
||||
_WindDown();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_DragHush(candidate))
|
||||
{
|
||||
if (_target is not null &&
|
||||
_Captured() is { } cap &&
|
||||
_ShareAncestor(cap, _target) &&
|
||||
_PointInside(_target, Mouse.GetPosition(_target)))
|
||||
return;
|
||||
|
||||
_Hush();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_closing) return;
|
||||
|
||||
_StartCycle(candidate, Mouse.GetPosition(candidate));
|
||||
}
|
||||
|
||||
private static DependencyObject? _Over() => Mouse.DirectlyOver as DependencyObject;
|
||||
private static DependencyObject? _Captured() => Mouse.Captured as DependencyObject;
|
||||
|
||||
private static FrameworkElement? _SeekOwner(DependencyObject? leaf)
|
||||
{
|
||||
for (var cur = leaf; cur is not null; cur = cur is Visual ? VisualTreeHelper.GetParent(cur) : null)
|
||||
{
|
||||
if (cur is FrameworkElement fe && _Eligible(fe) && _FetchContent(fe))
|
||||
return fe;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool _Eligible(FrameworkElement fe) =>
|
||||
GetIsEnabled(fe) && ToolTipService.GetIsEnabled(fe) &&
|
||||
(fe.IsEnabled || ToolTipService.GetShowOnDisabled(fe));
|
||||
|
||||
private static bool _FetchContent(FrameworkElement src)
|
||||
{
|
||||
var raw = src.ToolTip;
|
||||
if (raw is null) return false;
|
||||
|
||||
var payload = raw is ToolTip tip ? tip.Content : raw;
|
||||
return payload is not null && (payload is not string s || s.Length > 0);
|
||||
}
|
||||
|
||||
private static bool _DragHush(FrameworkElement? candidate)
|
||||
{
|
||||
if (Mouse.LeftButton == MouseButtonState.Pressed || Mouse.Captured is null) return false;
|
||||
if (candidate is null) return true;
|
||||
var cap = _Captured();
|
||||
if (cap is null) return true;
|
||||
return !_ShareAncestor(cap, candidate);
|
||||
}
|
||||
|
||||
private static bool _PointInside(FrameworkElement el, Point p) =>
|
||||
p.X >= 0 && p.Y >= 0 && p.X <= el.ActualWidth && p.Y <= el.ActualHeight;
|
||||
|
||||
private static bool _ShareAncestor(DependencyObject a, DependencyObject b)
|
||||
{
|
||||
if (ReferenceEquals(a, b)) return true;
|
||||
for (var cur = VisualTreeHelper.GetParent(b); cur is not null; cur = VisualTreeHelper.GetParent(cur))
|
||||
if (ReferenceEquals(cur, a)) return true;
|
||||
for (var cur = VisualTreeHelper.GetParent(a); cur is not null; cur = VisualTreeHelper.GetParent(cur))
|
||||
if (ReferenceEquals(cur, b)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cycle Management
|
||||
|
||||
private static void _StartCycle(FrameworkElement target, Point pt)
|
||||
{
|
||||
if (!_Eligible(target) || !_FetchContent(target)) return;
|
||||
if (_DragHush(target))
|
||||
{
|
||||
_Hush();
|
||||
return;
|
||||
}
|
||||
|
||||
_Stitch(target as ComboBox);
|
||||
|
||||
if (ReferenceEquals(_target, target))
|
||||
{
|
||||
_cursor = pt;
|
||||
if (_flyout is not { IsOpen: true } && _latch is null)
|
||||
_KickTimer(target);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tooltip 已打开时切换到新元素,先淡出旧内容再淡入新内容
|
||||
if (_flyout is { IsOpen: true })
|
||||
{
|
||||
_closing = false;
|
||||
_target = target;
|
||||
_cursor = pt;
|
||||
var mark = ++_gen;
|
||||
var sb = _closeStory!.Clone();
|
||||
sb.Completed += (_, _) =>
|
||||
{
|
||||
if (mark == _gen)
|
||||
{
|
||||
_flyout.IsOpen = false;
|
||||
_PopUp(target, pt);
|
||||
}
|
||||
sb.Remove(_shell!);
|
||||
};
|
||||
_shell!.BeginStoryboard(sb);
|
||||
return;
|
||||
}
|
||||
|
||||
_Hush();
|
||||
_target = target;
|
||||
_cursor = pt;
|
||||
_KickTimer(target);
|
||||
}
|
||||
|
||||
private static void _KickTimer(FrameworkElement target)
|
||||
{
|
||||
_latch?.Stop();
|
||||
|
||||
var ms = Math.Max(0, ToolTipService.GetInitialShowDelay(target));
|
||||
if (ms == 0)
|
||||
{
|
||||
_PopUp(target, _cursor);
|
||||
return;
|
||||
}
|
||||
|
||||
var mark = ++_gen;
|
||||
_latch = new DispatcherTimer(
|
||||
TimeSpan.FromMilliseconds(ms),
|
||||
DispatcherPriority.Normal,
|
||||
(_, _) =>
|
||||
{
|
||||
_latch?.Stop();
|
||||
if (mark == _gen && _target is not null)
|
||||
_PopUp(_target, _cursor);
|
||||
},
|
||||
target.Dispatcher);
|
||||
}
|
||||
|
||||
private static void _PopUp(FrameworkElement target, Point pt)
|
||||
{
|
||||
if (!ReferenceEquals(target, _target)) return;
|
||||
|
||||
if (_flyout is null)
|
||||
_BuildUi();
|
||||
|
||||
_flyout!.PlacementTarget = target;
|
||||
_PlaceNear(target, pt);
|
||||
|
||||
_shell!.DataContext = (target.ToolTip as ToolTip)?.DataContext ?? target.DataContext;
|
||||
_shell.FlowDirection = target.FlowDirection;
|
||||
|
||||
_RenderInside(target);
|
||||
|
||||
_gen++;
|
||||
_shell.BeginStoryboard(_openStory!);
|
||||
_flyout.IsOpen = true;
|
||||
}
|
||||
|
||||
private static void _BuildUi()
|
||||
{
|
||||
_scaler = new ScaleTransform(ScaleClosed, ScaleClosed);
|
||||
_shell = new Border
|
||||
{
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
MaxWidth = 700,
|
||||
SnapsToDevicePixels = true,
|
||||
UseLayoutRounding = true,
|
||||
RenderTransform = _scaler,
|
||||
RenderTransformOrigin = new Point(0, 0),
|
||||
Effect = _Shadow
|
||||
};
|
||||
_shell.SetResourceReference(Border.BackgroundProperty, "ColorBrushWhite");
|
||||
_shell.SetResourceReference(Border.BorderBrushProperty, "ColorBrushGray5");
|
||||
|
||||
var wrap = new Grid
|
||||
{
|
||||
Margin = new Thickness(ShadowBlur + 1),
|
||||
SnapsToDevicePixels = true,
|
||||
UseLayoutRounding = true
|
||||
};
|
||||
wrap.Children.Add(_shell);
|
||||
|
||||
_flyout = new Popup
|
||||
{
|
||||
AllowsTransparency = true,
|
||||
IsHitTestVisible = false,
|
||||
StaysOpen = true,
|
||||
PopupAnimation = PopupAnimation.None,
|
||||
Placement = PlacementMode.Relative,
|
||||
Child = wrap
|
||||
};
|
||||
|
||||
// 预创建 HWND 并挂载鼠标穿透钩子,Popup 不接收任何鼠标消息,全部透传到下层窗口
|
||||
const int WM_NCHITTEST = 0x0084;
|
||||
const int HTTRANSPARENT = -1;
|
||||
_transparentHook = (_, msg, _, _, ref handled) =>
|
||||
{
|
||||
if (msg == WM_NCHITTEST)
|
||||
{
|
||||
handled = true;
|
||||
return HTTRANSPARENT;
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
};
|
||||
_flyout.IsOpen = true;
|
||||
_AttachTransparentHook();
|
||||
_flyout.IsOpen = false;
|
||||
_flyout.Opened += (_, _) => _AttachTransparentHook();
|
||||
}
|
||||
|
||||
private static void _AttachTransparentHook()
|
||||
{
|
||||
if (_transparentHook is null || _flyout?.Child is not UIElement child) return;
|
||||
var src = (HwndSource?)PresentationSource.FromVisual(child);
|
||||
src?.AddHook(_transparentHook);
|
||||
}
|
||||
|
||||
private static void _RenderInside(FrameworkElement owner)
|
||||
{
|
||||
_shell!.Child = null;
|
||||
|
||||
var raw = owner.ToolTip;
|
||||
var tip = raw as ToolTip;
|
||||
var content = tip?.Content ?? raw;
|
||||
|
||||
if (content is null || content is string { Length: 0 })
|
||||
return;
|
||||
|
||||
var hasTpl = tip?.ContentTemplate is not null || tip?.ContentTemplateSelector is not null;
|
||||
var tipW = tip is { Width: > 0 } && !double.IsNaN(tip.Width) ? tip.Width : MaxContentWidth;
|
||||
|
||||
if (content is string text && !hasTpl)
|
||||
{
|
||||
var tb = new TextBlock
|
||||
{
|
||||
Text = text,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = _InnerPad,
|
||||
FontSize = TipFontSize,
|
||||
LineHeight = TipLineHeight,
|
||||
LineStackingStrategy = LineStackingStrategy.BlockLineHeight,
|
||||
MaxWidth = tipW
|
||||
};
|
||||
tb.SetResourceReference(TextBlock.ForegroundProperty, "ColorBrush1");
|
||||
_shell.Child = tb;
|
||||
}
|
||||
else
|
||||
{
|
||||
_shell.Child = new ContentPresenter
|
||||
{
|
||||
Content = content,
|
||||
ContentTemplate = tip?.ContentTemplate,
|
||||
ContentTemplateSelector = tip?.ContentTemplateSelector,
|
||||
ContentStringFormat = tip?.ContentStringFormat,
|
||||
Margin = _InnerPad,
|
||||
MaxWidth = tipW
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static void _PlaceNear(FrameworkElement target, Point pt)
|
||||
{
|
||||
_flyout!.PlacementTarget = target;
|
||||
var mode = ToolTipService.GetPlacement(target);
|
||||
|
||||
if (mode is PlacementMode.Mouse)
|
||||
{
|
||||
_flyout.Placement = PlacementMode.Relative;
|
||||
_flyout.PlacementRectangle = default;
|
||||
_flyout.HorizontalOffset = Math.Round(pt.X + 15 + ToolTipService.GetHorizontalOffset(target));
|
||||
_flyout.VerticalOffset = Math.Round(pt.Y + 25 + ToolTipService.GetVerticalOffset(target));
|
||||
}
|
||||
else if (mode is PlacementMode.MousePoint)
|
||||
{
|
||||
_flyout.Placement = PlacementMode.Relative;
|
||||
_flyout.PlacementRectangle = default;
|
||||
_flyout.HorizontalOffset = Math.Round(pt.X + ToolTipService.GetHorizontalOffset(target));
|
||||
_flyout.VerticalOffset = Math.Round(pt.Y + ToolTipService.GetVerticalOffset(target));
|
||||
}
|
||||
else
|
||||
{
|
||||
_flyout.Placement = mode;
|
||||
_flyout.HorizontalOffset = ToolTipService.GetHorizontalOffset(target);
|
||||
_flyout.VerticalOffset = ToolTipService.GetVerticalOffset(target);
|
||||
_flyout.PlacementRectangle = ToolTipService.GetPlacementRectangle(target);
|
||||
}
|
||||
}
|
||||
|
||||
private static void _WindDown()
|
||||
{
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
|
||||
_latch?.Stop();
|
||||
_latch = null;
|
||||
_target = null;
|
||||
|
||||
if (_flyout is not { IsOpen: true } || _shell is null)
|
||||
{
|
||||
_Hush();
|
||||
return;
|
||||
}
|
||||
|
||||
var mark = ++_gen;
|
||||
var sb = _closeStory!.Clone();
|
||||
sb.Completed += (_, _) =>
|
||||
{
|
||||
if (mark == _gen) _Hush();
|
||||
sb.Remove(_shell);
|
||||
};
|
||||
_shell.BeginStoryboard(sb);
|
||||
}
|
||||
|
||||
private static void _Hush()
|
||||
{
|
||||
_latch?.Stop();
|
||||
_latch = null;
|
||||
_closing = false;
|
||||
_target = null;
|
||||
_gen++;
|
||||
|
||||
if (_flyout is not null)
|
||||
_flyout.IsOpen = false;
|
||||
|
||||
if (_shell is not null)
|
||||
{
|
||||
_shell.BeginAnimation(UIElement.OpacityProperty, null);
|
||||
_shell.Child = null;
|
||||
}
|
||||
|
||||
if (_scaler is not null)
|
||||
{
|
||||
_scaler.BeginAnimation(ScaleTransform.ScaleXProperty, null);
|
||||
_scaler.BeginAnimation(ScaleTransform.ScaleYProperty, null);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ComboBox Hook
|
||||
|
||||
private static void OnComboInit(object s, RoutedEventArgs e) => _Stitch(s as ComboBox);
|
||||
private static void OnComboInit(object s, MouseButtonEventArgs e) => _Stitch(s as ComboBox);
|
||||
|
||||
private static void _Stitch(ComboBox? box)
|
||||
{
|
||||
if (box is null || (bool)box.GetValue(_KeyCombo)) return;
|
||||
box.SetValue(_KeyCombo, true);
|
||||
box.DropDownOpened += (_, _) =>
|
||||
{
|
||||
if (_target is not null) _WindDown();
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/* This file is generated by DeepSeek */
|
||||
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace PCL.Core.UI.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// 瀑布流布局模式
|
||||
/// </summary>
|
||||
public enum WaterfallLayoutMode
|
||||
{
|
||||
/// <summary>
|
||||
/// 固定列数模式,使用 ColumnCount 属性指定列数
|
||||
/// </summary>
|
||||
FixedColumns,
|
||||
|
||||
/// <summary>
|
||||
/// 自适应模式,根据可用宽度和 MaxItemWidth 自动计算列数
|
||||
/// </summary>
|
||||
AutoFit
|
||||
}
|
||||
|
||||
public class WaterfallPanel : Panel
|
||||
{
|
||||
// 私有字段,存储 Measure 阶段计算出的实际列数(用于 Arrange 阶段)
|
||||
private int _computedColumnCount;
|
||||
|
||||
#region 依赖属性
|
||||
|
||||
/// <summary>
|
||||
/// 布局模式(固定列数 / 自适应)
|
||||
/// </summary>
|
||||
public WaterfallLayoutMode LayoutMode
|
||||
{
|
||||
get { return (WaterfallLayoutMode)GetValue(LayoutModeProperty); }
|
||||
set { SetValue(LayoutModeProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty LayoutModeProperty =
|
||||
DependencyProperty.Register(nameof(LayoutMode), typeof(WaterfallLayoutMode), typeof(WaterfallPanel),
|
||||
new FrameworkPropertyMetadata(WaterfallLayoutMode.FixedColumns, FrameworkPropertyMetadataOptions.AffectsMeasure));
|
||||
|
||||
/// <summary>
|
||||
/// 固定列数(仅在 LayoutMode = FixedColumns 时生效)
|
||||
/// </summary>
|
||||
public int ColumnCount
|
||||
{
|
||||
get { return (int)GetValue(ColumnCountProperty); }
|
||||
set { SetValue(ColumnCountProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty ColumnCountProperty =
|
||||
DependencyProperty.Register(nameof(ColumnCount), typeof(int), typeof(WaterfallPanel),
|
||||
new FrameworkPropertyMetadata(2, FrameworkPropertyMetadataOptions.AffectsMeasure));
|
||||
|
||||
/// <summary>
|
||||
/// 列间距
|
||||
/// </summary>
|
||||
public double ColumnSpacing
|
||||
{
|
||||
get { return (double)GetValue(ColumnSpacingProperty); }
|
||||
set { SetValue(ColumnSpacingProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty ColumnSpacingProperty =
|
||||
DependencyProperty.Register(nameof(ColumnSpacing), typeof(double), typeof(WaterfallPanel),
|
||||
new FrameworkPropertyMetadata(5.0, FrameworkPropertyMetadataOptions.AffectsMeasure));
|
||||
|
||||
/// <summary>
|
||||
/// 行间距
|
||||
/// </summary>
|
||||
public double RowSpacing
|
||||
{
|
||||
get { return (double)GetValue(RowSpacingProperty); }
|
||||
set { SetValue(RowSpacingProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty RowSpacingProperty =
|
||||
DependencyProperty.Register(nameof(RowSpacing), typeof(double), typeof(WaterfallPanel),
|
||||
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsMeasure));
|
||||
|
||||
/// <summary>
|
||||
/// 项的最大宽度(仅在 LayoutMode = AutoFit 时生效)
|
||||
/// </summary>
|
||||
public double MaxItemWidth
|
||||
{
|
||||
get { return (double)GetValue(MaxItemWidthProperty); }
|
||||
set { SetValue(MaxItemWidthProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty MaxItemWidthProperty =
|
||||
DependencyProperty.Register(nameof(MaxItemWidth), typeof(double), typeof(WaterfallPanel),
|
||||
new FrameworkPropertyMetadata(100.0, FrameworkPropertyMetadataOptions.AffectsMeasure));
|
||||
|
||||
#endregion
|
||||
|
||||
protected override Size MeasureOverride(Size availableSize)
|
||||
{
|
||||
// 1. 确定列数
|
||||
int columnCount;
|
||||
if (LayoutMode == WaterfallLayoutMode.FixedColumns)
|
||||
{
|
||||
columnCount = ColumnCount;
|
||||
}
|
||||
else // AutoFit
|
||||
{
|
||||
if (double.IsInfinity(availableSize.Width) || double.IsNaN(availableSize.Width))
|
||||
{
|
||||
// 宽度无限时,回退到固定列数模式(使用 ColumnCount 作为后备)
|
||||
columnCount = ColumnCount;
|
||||
}
|
||||
else
|
||||
{
|
||||
double effectiveMaxWidth = MaxItemWidth > 0 ? MaxItemWidth : 100; // 防御性处理
|
||||
double slotWidth = effectiveMaxWidth + ColumnSpacing;
|
||||
columnCount = Math.Max(1, (int)Math.Floor((availableSize.Width + ColumnSpacing) / slotWidth));
|
||||
}
|
||||
}
|
||||
_computedColumnCount = columnCount;
|
||||
|
||||
// 2. 计算每列的实际宽度
|
||||
double colWidth = (availableSize.Width - (columnCount - 1) * ColumnSpacing) / columnCount;
|
||||
colWidth = Math.Max(0, colWidth); // 避免负宽度
|
||||
|
||||
// 3. 记录每列的当前高度(用于测量时决定元素放入哪一列)
|
||||
double[] colHeights = new double[columnCount];
|
||||
|
||||
// 4. 测量所有子元素
|
||||
foreach (UIElement child in InternalChildren)
|
||||
{
|
||||
// 约束子元素宽度为列宽,高度不限
|
||||
child.Measure(new Size(colWidth, double.PositiveInfinity));
|
||||
Size desiredSize = child.DesiredSize;
|
||||
|
||||
// 找到当前高度最小的列
|
||||
int minColIndex = _GetMinHeightColumnIndex(colHeights);
|
||||
double y = colHeights[minColIndex]; // 当前列已占高度,即新元素的 Y 坐标
|
||||
|
||||
// 更新该列高度:加上元素高度和行间距(为下一个元素预留)
|
||||
colHeights[minColIndex] += desiredSize.Height + RowSpacing;
|
||||
}
|
||||
|
||||
// 5. 计算面板所需总高度(取最高列的实际内容高度,需减去最后一个多余的行间距)
|
||||
double totalHeight = 0;
|
||||
for (int i = 0; i < colHeights.Length; i++)
|
||||
{
|
||||
if (colHeights[i] > 0)
|
||||
totalHeight = Math.Max(totalHeight, colHeights[i] - RowSpacing);
|
||||
else
|
||||
totalHeight = Math.Max(totalHeight, colHeights[i]);
|
||||
}
|
||||
|
||||
return new Size(availableSize.Width, totalHeight);
|
||||
}
|
||||
|
||||
protected override Size ArrangeOverride(Size finalSize)
|
||||
{
|
||||
if (_computedColumnCount <= 0) return finalSize;
|
||||
|
||||
// 使用 Measure 阶段确定的列数,但根据 finalSize 重新计算列宽(保证填满可用宽度)
|
||||
double colWidth = (finalSize.Width - (_computedColumnCount - 1) * ColumnSpacing) / _computedColumnCount;
|
||||
colWidth = Math.Max(0, colWidth);
|
||||
|
||||
double[] colHeights = new double[_computedColumnCount];
|
||||
|
||||
foreach (UIElement child in InternalChildren)
|
||||
{
|
||||
int colIndex = _GetMinHeightColumnIndex(colHeights);
|
||||
double x = colIndex * (colWidth + ColumnSpacing);
|
||||
double y = colHeights[colIndex];
|
||||
|
||||
child.Arrange(new Rect(new Point(x, y), new Size(colWidth, child.DesiredSize.Height)));
|
||||
|
||||
// 更新该列高度:加上元素高度和行间距(为下一个元素定位)
|
||||
colHeights[colIndex] += child.DesiredSize.Height + RowSpacing;
|
||||
}
|
||||
|
||||
return finalSize;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前高度最小的列索引
|
||||
/// </summary>
|
||||
private int _GetMinHeightColumnIndex(double[] colHeights)
|
||||
{
|
||||
int minIndex = 0;
|
||||
double minHeight = colHeights[0];
|
||||
for (int i = 1; i < colHeights.Length; i++)
|
||||
{
|
||||
if (colHeights[i] < minHeight)
|
||||
{
|
||||
minHeight = colHeights[i];
|
||||
minIndex = i;
|
||||
}
|
||||
}
|
||||
return minIndex;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user