feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
CI / Go Backend (push) Canceled after 0s

初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块

Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
xyou
2026-08-08 14:07:56 +08:00
parent 9500c4c80a
commit f70b061d1a
1972 changed files with 159760 additions and 6 deletions
@@ -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;
}
}