功能完善

This commit is contained in:
ShrlAlgo
2025-08-20 12:10:13 +08:00
parent d0cfc64450
commit fcd306b0f7
270 changed files with 11503 additions and 7193 deletions

View File

@@ -0,0 +1,225 @@
namespace NeumUI.Controls;
public class Border : System.Windows.Controls.Border
{
protected override Geometry? GetLayoutClip(Size layoutSlotSize)
{
if (!ClipToBounds)
{
return base.GetLayoutClip(layoutSlotSize);
}
var borderThickness = BorderThickness;
var cornerRadius = CornerRadius;
var renderSize = RenderSize;
if (renderSize.Width <= 0 || renderSize.Height <= 0)
{
return null;
}
var rect = new Rect(0, 0, renderSize.Width, renderSize.Height);
var radii = new Radii(cornerRadius, borderThickness, true);
var layoutGeometry = new StreamGeometry();
using var ctx = layoutGeometry.Open();
GenerateGeometry(ctx, rect, radii);
layoutGeometry.Freeze();
return layoutGeometry;
}
internal static void GenerateGeometry(StreamGeometryContext ctx, Rect rect, Radii radii)
{
//
// compute the coordinates of the key points
//
Point topLeft = new(radii.LeftTop, 0);
Point topRight = new(rect.Width - radii.RightTop, 0);
Point rightTop = new(rect.Width, radii.TopRight);
Point rightBottom = new(rect.Width, rect.Height - radii.BottomRight);
Point bottomRight = new(rect.Width - radii.RightBottom, rect.Height);
Point bottomLeft = new(radii.LeftBottom, rect.Height);
Point leftBottom = new(0, rect.Height - radii.BottomLeft);
Point leftTop = new(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)
{
var v = radii.LeftTop / (radii.LeftTop + radii.RightTop) * rect.Width;
topLeft.X = v;
topRight.X = v;
}
// right edge
if (rightTop.Y > rightBottom.Y)
{
var v = radii.TopRight / (radii.TopRight + radii.BottomRight) * rect.Height;
rightTop.Y = v;
rightBottom.Y = v;
}
// bottom edge
if (bottomRight.X < bottomLeft.X)
{
var v = radii.LeftBottom / (radii.LeftBottom + radii.RightBottom) * rect.Width;
bottomRight.X = v;
bottomLeft.X = v;
}
// left edge
if (leftBottom.Y < leftTop.Y)
{
var v = radii.TopLeft / (radii.TopLeft + radii.BottomLeft) * rect.Height;
leftBottom.Y = v;
leftTop.Y = v;
}
//
// add on offsets
//
Vector offset = new(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
var radiusX = rect.TopRight.X - topRight.X;
var radiusY = rightTop.Y - rect.TopRight.Y;
if (!MathHelper.IsZero(radiusX) || !MathHelper.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 (!MathHelper.IsZero(radiusX) || !MathHelper.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 (!MathHelper.IsZero(radiusX) || !MathHelper.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 (!MathHelper.IsZero(radiusX) || !MathHelper.IsZero(radiusY))
{
ctx.ArcTo(topLeft, new Size(radiusX, radiusY), 0, false, SweepDirection.Clockwise, true, false);
}
}
internal struct Radii
{
internal Radii(CornerRadius radii, Thickness borders, bool outer)
{
var left = 0.5 * borders.Left;
var top = 0.5 * borders.Top;
var right = 0.5 * borders.Right;
var bottom = 0.5 * borders.Bottom;
if (outer)
{
if (MathHelper.IsZero(radii.TopLeft))
{
LeftTop = TopLeft = 0.0;
}
else
{
LeftTop = radii.TopLeft + left;
TopLeft = radii.TopLeft + top;
}
if (MathHelper.IsZero(radii.TopRight))
{
TopRight = RightTop = 0.0;
}
else
{
TopRight = radii.TopRight + top;
RightTop = radii.TopRight + right;
}
if (MathHelper.IsZero(radii.BottomRight))
{
RightBottom = BottomRight = 0.0;
}
else
{
RightBottom = radii.BottomRight + right;
BottomRight = radii.BottomRight + bottom;
}
if (MathHelper.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;
}
}
file static class MathHelper
{
internal const double DBL_EPSILON = 2.2204460492503131e-016;
public static bool IsZero(double value) => Math.Abs(value) < 10.0 * DBL_EPSILON;
}

View File

@@ -0,0 +1,195 @@
using System.Diagnostics;
using System.IO.Packaging;
using System.Windows.Threading;
namespace NeumUI.Controls;
/// <summary>
/// 提供显示和关闭启动画面的方法。
/// </summary>
public static class Splash
{
private static STAThread<SplashWindow>? Current { get; set; }
static Splash()
{
if (!UriParser.IsKnownScheme("pack"))
{
// 确保 PackUriHelper 的 UriSchemePack 已经注册
_ = PackUriHelper.UriSchemePack;
}
}
/// <summary>
/// 异步显示启动画面。
/// </summary>
/// <param name="imageUriString">启动画面图像的URI字符串。</param>
/// <param name="opacity">启动画面的不透明度默认为1.0(完全不透明)。</param>
/// <param name="completed">可选的完成回调,当启动画面关闭后调用。</param>
public static void ShowAsync(string imageUriString, double opacity = 1d, Action? completed = null)
{
var imageUri = new Uri(imageUriString);
Current = new STAThread<SplashWindow>(sta =>
{
if (string.IsNullOrWhiteSpace(sta.Value.Name))
{
sta.Value.Name = "Splash Thread";
}
sta.Result = new SplashWindow(imageUri)
{
Opacity = opacity,
};
sta.Result.Show();
});
Current.Start();
if (completed != null)
{
Task.Run(() =>
{
try
{
Current.Value.Join();
completed?.Invoke();
}
catch (Exception ex)
{
Debug.WriteLine(ex);
}
});
}
}
/// <summary>
/// 异步关闭启动画面。
/// </summary>
/// <param name="owner">拥有启动画面的窗口默认为null。</param>
/// <param name="forced">是否强制关闭启动画面默认为false。</param>
public static void CloseAsync(Window? owner = null, bool forced = false)
{
try
{
var current = Current?.Result;
if (current == null)
{
return;
}
current.Closing += (_, _) =>
{
owner?.Dispatcher.Invoke(() =>
{
nint hwnd = new WindowInteropHelper(owner).Handle;
//User32.SetForegroundWindow(hwnd);
//User32.BringWindowToTop(hwnd);
});
};
if (forced)
{
current.Shutdown();
}
else
{
if (!current.AutoEnd)
{
current.StartEnd();
}
}
}
catch (Exception ex)
{
Debug.WriteLine(ex);
}
}
/// <summary>
/// 在指定窗口加载完成后关闭启动画面。
/// </summary>
/// <param name="owner">拥有启动画面的窗口。</param>
/// <param name="minimumMilliseconds">启动画面显示的最小毫秒数如果未达到该时间则延迟关闭默认为null。</param>
/// <param name="forced">是否强制关闭启动画面默认为false。</param>
/// <exception cref="ArgumentNullException">当<paramref name="owner"/>为null时抛出。</exception>
public static void CloseOnLoaded(Window? owner = null, int? minimumMilliseconds = null, bool forced = false)
{
if (owner == null)
{
throw new ArgumentNullException(nameof(owner));
}
owner.Loaded += OnLoaded;
async void OnLoaded(object? sender, RoutedEventArgs e)
{
owner.Loaded -= OnLoaded;
if (minimumMilliseconds != null)
{
var current = Current?.Result;
if (current != null)
{
var survivalMilliseconds = (DateTime.Now - current.TimeOfCtor).TotalMilliseconds;
if (survivalMilliseconds < minimumMilliseconds)
{
await Task.Delay((int)(minimumMilliseconds.Value - survivalMilliseconds));
}
}
}
CloseAsync(owner, forced);
}
}
private class STAThread<T> : STADispatcherObject, IDisposable where T : class
{
public Thread Value { get; set; }
public T Result { get; set; } = null!;
public STAThread(Action<STAThread<T>> start)
{
Value = new Thread(() =>
{
Dispatcher = Dispatcher.CurrentDispatcher;
start?.Invoke(this);
Dispatcher.Run();
})
{
IsBackground = true,
Name = $"STAThread<{typeof(T)}>",
};
Value.SetApartmentState(ApartmentState.STA);
}
public void Start()
{
Value.Start();
}
public void Forget()
{
Dispose();
}
public void Dispose()
{
if (typeof(IDisposable).IsAssignableFrom(typeof(T)))
{
try
{
((IDisposable?)Result)?.Dispose();
}
catch
{
// ignored
}
}
Dispatcher?.InvokeShutdown();
}
}
private class STADispatcherObject(Dispatcher dispatcher = null!)
{
public Dispatcher Dispatcher { get; set; } = dispatcher;
}
}

View File

@@ -0,0 +1,25 @@
namespace NeumUI.Controls;
/// <summary>
/// SplashConfig 类提供了一个配置启动画面的抽象基类。通过设置此类中的属性,可以自定义启动画面的外观,如图像的高度和边框圆角半径。
/// </summary>
public abstract record SplashConfig
{
/// <summary>
/// 获取或设置启动画面中图像的高度。
/// </summary>
/// <remarks>
/// 该属性定义了显示在启动窗口中的图像高度,单位为像素。默认值为 300 像素。
/// 可以通过修改此属性来调整启动画面中图像的大小。
/// </remarks>
public static double ImageHeight { get; set; } = 300d;
/// <summary>
/// 获取或设置控件的圆角半径。此属性用于定义控件边角的弧度大小,通过指定一个值来同时设置四个角的半径,或者分别指定每个角的半径。
/// </summary>
/// <remarks>
/// 该属性在 NeumUI.Controls 命名空间下的多个类中被使用,如 SplashConfig 和 Border 类。通过调整 CornerRadius 的值,可以改变相关控件(例如窗口)的外观样式,使其具有平滑的圆角边缘。
/// 默认情况下CornerRadius 被设置为 24d这意味着如果未明确更改其值则所有使用了该属性的控件将会显示拥有相同半径的圆角。
/// </remarks>
public static CornerRadius CornerRadius { get; set; } = new(24d);
}

View File

@@ -0,0 +1,110 @@
<Window
AllowsTransparency="True"
Background="Transparent"
InputMethod.IsInputMethodEnabled="False"
RenderOptions.BitmapScalingMode="Fant"
ResizeMode="NoResize"
ShowInTaskbar="False"
SizeToContent="WidthAndHeight"
Style="{x:Null}"
Title="Splash"
WindowStartupLocation="CenterScreen"
WindowStyle="None"
d:Opacity="1"
mc:Ignorable="d"
x:Class="NeumUI.Controls.SplashWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:NeumUI.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Window.Resources>
<ResourceDictionary>
<Storyboard Completed="Start_Completed" x:Key="Start">
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="viewbox" Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[0].(ScaleTransform.ScaleX)">
<EasingDoubleKeyFrame KeyTime="0" Value="0.8" />
<EasingDoubleKeyFrame KeyTime="0:0:0.7" Value="0.8" />
<EasingDoubleKeyFrame KeyTime="0:0:1.2" Value="0.9">
<EasingDoubleKeyFrame.EasingFunction>
<PowerEase EasingMode="EaseOut" Power="3" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
<EasingDoubleKeyFrame KeyTime="0:0:3" Value="0.9" />
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="viewbox" Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[0].(ScaleTransform.ScaleY)">
<EasingDoubleKeyFrame KeyTime="0" Value="0.8" />
<EasingDoubleKeyFrame KeyTime="0:0:0.7" Value="0.8" />
<EasingDoubleKeyFrame KeyTime="0:0:1.2" Value="0.9">
<EasingDoubleKeyFrame.EasingFunction>
<PowerEase EasingMode="EaseOut" Power="3" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
<EasingDoubleKeyFrame KeyTime="0:0:3" Value="0.9" />
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="viewbox" Storyboard.TargetProperty="(UIElement.Opacity)">
<EasingDoubleKeyFrame KeyTime="0" Value="0" />
<EasingDoubleKeyFrame KeyTime="0:0:0.7" Value="0" />
<EasingDoubleKeyFrame KeyTime="0:0:1.2" Value="1">
<EasingDoubleKeyFrame.EasingFunction>
<PowerEase EasingMode="EaseOut" Power="3" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
<EasingDoubleKeyFrame KeyTime="0:0:3" Value="1" />
</DoubleAnimationUsingKeyFrames>
</Storyboard>
<Storyboard Completed="End_Completed" x:Key="End">
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="viewbox" Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[0].(ScaleTransform.ScaleX)">
<EasingDoubleKeyFrame KeyTime="0" Value="0.9" />
<EasingDoubleKeyFrame KeyTime="0:0:0.5" Value="1">
<EasingDoubleKeyFrame.EasingFunction>
<PowerEase EasingMode="EaseOut" Power="3" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="viewbox" Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[0].(ScaleTransform.ScaleY)">
<EasingDoubleKeyFrame KeyTime="0" Value="0.9" />
<EasingDoubleKeyFrame KeyTime="0:0:0.5" Value="1">
<EasingDoubleKeyFrame.EasingFunction>
<PowerEase EasingMode="EaseOut" Power="3" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="viewbox" Storyboard.TargetProperty="(UIElement.Opacity)">
<EasingDoubleKeyFrame KeyTime="0" Value="1" />
<EasingDoubleKeyFrame KeyTime="0:0:0.5" Value="0">
<EasingDoubleKeyFrame.EasingFunction>
<PowerEase EasingMode="EaseOut" Power="3" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</ResourceDictionary>
</Window.Resources>
<Window.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard Storyboard="{StaticResource Start}" />
</EventTrigger>
</Window.Triggers>
<Grid>
<Viewbox
Height="{x:Static local:SplashConfig.ImageHeight}"
HorizontalAlignment="Center"
Name="viewbox"
RenderTransformOrigin="0.5,0.5"
VerticalAlignment="Center">
<UIElement.RenderTransform>
<TransformGroup>
<ScaleTransform />
<SkewTransform />
<RotateTransform />
<TranslateTransform />
</TransformGroup>
</UIElement.RenderTransform>
<Grid HorizontalAlignment="Center" VerticalAlignment="Center">
<local:Border ClipToBounds="True" CornerRadius="{x:Static local:SplashConfig.CornerRadius}">
<Image Source="{Binding ImageUri}" />
</local:Border>
</Grid>
</Viewbox>
</Grid>
</Window>

View File

@@ -0,0 +1,147 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Media.Animation;
namespace NeumUI.Controls;
/// <summary>
/// SplashWindow 类表示一个启动窗口,用于在应用程序初始化时显示。它继承自 Window 并实现了 INotifyPropertyChanged 接口以支持属性更改通知。
/// 该类主要用于显示带有图片和提示信息的启动界面,并且可以设置自动结束或手动触发结束动画来关闭窗口。
/// </summary>
public sealed partial class SplashWindow : Window, INotifyPropertyChanged
{
/// <summary>
/// 属性更改事件,当属性值发生变化时触发此事件。
/// 实现了INotifyPropertyChanged接口的类可以通过引发此事件来通知绑定客户端其属性值已经更改。
/// </summary>
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// 获取启动窗口显示的图像资源URI。此属性用于指定在SplashWindow中展示的图片的位置。
/// </summary>
public Uri ImageUri { get; }
/// <summary>
/// 用于存储提示信息的字符串。此字段与Hint属性关联当其值发生变化时会触发属性更改通知。
/// </summary>
private string hint = null!;
/// <summary>
/// 获取或设置提示信息的字符串。此属性允许用户自定义显示在SplashWindow上的提示文本。当该属性值被修改时会自动通知所有已注册的属性更改监听者。
/// </summary>
public string Hint
{
get => hint;
set => SetProperty(ref hint, value);
}
/// <summary>
///
/// </summary>
public bool AutoEnd { get; set; } = false;
/// <summary>
///
/// </summary>
public DateTime TimeOfCtor = DateTime.Now;
/// <summary>
///
/// </summary>
/// <param name="imageUri"></param>
public SplashWindow(Uri imageUri)
{
DataContext = this;
ImageUri = imageUri;
InitializeComponent();
MouseLeftButtonDown += (sender, _) =>
{
if (sender is DependencyObject depObject)
{
GetWindow(depObject)?.DragMove();
}
};
}
/// <summary>
/// 触发属性更改事件,通知监听者指定属性的值已更改。
/// </summary>
/// <param name="e">包含属性更改信息的事件参数。</param>
private void OnPropertyChanged(PropertyChangedEventArgs e)
{
PropertyChanged?.Invoke(this, e);
}
/// <summary>
/// 触发属性更改事件,通知监听者指定属性的值已更改。
/// </summary>
/// <param name="propertyName">发生更改的属性名称默认通过CallerMemberName特性自动获取。</param>
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
OnPropertyChanged(new PropertyChangedEventArgs(propertyName));
}
/// <summary>
/// 设置属性值,并在属性值发生变化时触发属性更改通知。
/// </summary>
/// <typeparam name="T">属性的类型。</typeparam>
/// <param name="field">要设置的字段。</param>
/// <param name="newValue">新值。</param>
/// <param name="propertyName">属性名默认通过CallerMemberName获取。</param>
/// <returns>如果属性值发生改变则返回true否则返回false。</returns>
private bool SetProperty<T>(ref T field, T newValue, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, newValue))
{
return false;
}
field = newValue;
OnPropertyChanged(propertyName);
return true;
}
private void Start_Completed(object? sender, EventArgs e)
{
if (AutoEnd)
{
StartEnd();
}
}
/// <summary>
/// 在Storyboard动画完成时调用此方法用于执行窗口关闭操作。
/// </summary>
/// <param name="sender">触发事件的对象。</param>
/// <param name="e">事件参数。</param>
private void End_Completed(object? sender, EventArgs e)
{
Shutdown();
}
/// <summary>
/// 开始执行结束动画。此方法通过调用Dispatcher来查找名为"End"的Storyboard资源并开始播放从而实现窗口关闭或过渡效果。
/// </summary>
public void StartEnd()
{
Dispatcher.Invoke(() =>
{
var storyboard = (Storyboard)FindResource("End");
storyboard.Begin();
});
}
/// <summary>
/// 关闭当前窗口并停止与之关联的Dispatcher以确保所有相关的UI线程活动被正确终止。
/// </summary>
public void Shutdown()
{
Dispatcher.Invoke(() =>
{
Close();
Dispatcher?.InvokeShutdown();
});
}
}