搭建 .NET 8 WPF 应用骨架,加入系统托盘、单实例启动与主控制面板。 实现本地 HTTP /notify 消息接入、频道严格匹配、免打扰、熔断限流与历史持久化。 补充弹窗样式配置、队列/推挤/替换展示、溢出处理、应用图标和项目文档。 Initial-Commit: true
313 lines
12 KiB
C#
313 lines
12 KiB
C#
using System.Runtime.InteropServices;
|
|
using System.Windows;
|
|
using System.Windows.Forms;
|
|
using System.Windows.Interop;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Animation;
|
|
using System.Windows.Threading;
|
|
|
|
namespace OmniNotify;
|
|
|
|
public partial class PopupWindow : Window
|
|
{
|
|
private const double ShellBorderThickness = 1;
|
|
private const double BodyTopMargin = 8;
|
|
private const int GwlExStyle = -20;
|
|
private const int WsExTransparent = 0x20;
|
|
private const int WsExNoActivate = 0x08000000;
|
|
private const int WsExToolWindow = 0x00000080;
|
|
private readonly NotificationProfile _profile;
|
|
private readonly DispatcherTimer _timer = new();
|
|
private double _bodyViewportHeight;
|
|
private int _slot;
|
|
|
|
public PopupWindow(NotificationProfile profile, IncomingMessage message, int slot)
|
|
{
|
|
InitializeComponent();
|
|
_profile = profile;
|
|
_slot = slot;
|
|
Width = profile.Width;
|
|
MaxHeight = profile.MaxHeight;
|
|
Opacity = profile.OverallOpacity;
|
|
TitleText.Text = message.Title;
|
|
BodyText.Text = message.Body;
|
|
ApplyProfile();
|
|
_timer.Interval = TimeSpan.FromSeconds(Math.Max(0.5, GetInitialLifetimeSeconds(profile)));
|
|
Loaded += (_, _) =>
|
|
{
|
|
ApplyClickThrough();
|
|
LayoutBodyText();
|
|
StartOverflowBehavior();
|
|
MoveToSlot(_slot, false);
|
|
AnimateIn();
|
|
};
|
|
_timer.Tick += (_, _) =>
|
|
{
|
|
_timer.Stop();
|
|
AnimateOut();
|
|
};
|
|
}
|
|
|
|
public void MoveToSlot(int slot, bool animated = true)
|
|
{
|
|
_slot = slot;
|
|
var (left, top) = CalculatePosition(slot);
|
|
if (!animated)
|
|
{
|
|
Left = left;
|
|
Top = top;
|
|
return;
|
|
}
|
|
|
|
BeginAnimation(LeftProperty, new DoubleAnimation(left, TimeSpan.FromMilliseconds(180)) { EasingFunction = new CubicEase() });
|
|
BeginAnimation(TopProperty, new DoubleAnimation(top, TimeSpan.FromMilliseconds(180)) { EasingFunction = new CubicEase() });
|
|
}
|
|
|
|
public void CloseImmediately()
|
|
{
|
|
_timer.Stop();
|
|
Close();
|
|
}
|
|
|
|
private void ApplyProfile()
|
|
{
|
|
Shell.BorderThickness = new Thickness(ShellBorderThickness);
|
|
Shell.Padding = new Thickness(_profile.Padding);
|
|
Shell.Background = Brush(_profile.BackgroundColor, _profile.BackgroundOpacity);
|
|
Shell.BorderBrush = Brush(_profile.BorderColor, _profile.BorderOpacity);
|
|
Shell.ClipToBounds = true;
|
|
ContentPanel.ClipToBounds = true;
|
|
ContentPanel.MaxHeight = Math.Max(80, GetPanelMaxHeight(_profile));
|
|
TitleText.Foreground = Brush(_profile.TextColor, 1);
|
|
BodyText.Foreground = Brush(_profile.TextColor, 0.9);
|
|
TitleText.FontFamily = new System.Windows.Media.FontFamily(_profile.FontFamily);
|
|
BodyText.FontFamily = new System.Windows.Media.FontFamily(_profile.FontFamily);
|
|
TitleText.FontSize = _profile.TitleFontSize;
|
|
BodyText.FontSize = _profile.BodyFontSize;
|
|
BodyText.LineStackingStrategy = LineStackingStrategy.BlockLineHeight;
|
|
BodyText.LineHeight = GetBodyLineHeight(_profile);
|
|
_bodyViewportHeight = GetBodyViewportHeight(_profile);
|
|
BodyText.RenderTransform = null;
|
|
BodyCanvas.RenderTransform = null;
|
|
|
|
switch (_profile.OverflowMode)
|
|
{
|
|
case OverflowMode.VerticalScroll:
|
|
BodyViewport.Height = _bodyViewportHeight;
|
|
BodyViewport.MaxHeight = _bodyViewportHeight;
|
|
BodyText.TextWrapping = TextWrapping.Wrap;
|
|
BodyText.TextTrimming = TextTrimming.None;
|
|
break;
|
|
case OverflowMode.Split:
|
|
BodyViewport.Height = _bodyViewportHeight;
|
|
BodyViewport.MaxHeight = _bodyViewportHeight;
|
|
BodyText.TextWrapping = TextWrapping.Wrap;
|
|
BodyText.TextTrimming = TextTrimming.None;
|
|
break;
|
|
default:
|
|
BodyViewport.Height = Math.Max(_profile.BodyFontSize * 1.6, _profile.BodyFontSize + 4);
|
|
BodyViewport.MaxHeight = BodyViewport.Height;
|
|
BodyText.TextWrapping = TextWrapping.NoWrap;
|
|
BodyText.TextTrimming = TextTrimming.CharacterEllipsis;
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void StartOverflowBehavior()
|
|
{
|
|
if (_profile.OverflowMode != OverflowMode.VerticalScroll)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var overflow = BodyText.DesiredSize.Height - BodyViewport.ActualHeight;
|
|
var hold = TimeSpan.FromSeconds(Math.Max(0, _profile.VerticalScrollHoldSeconds));
|
|
if (overflow <= 1)
|
|
{
|
|
_timer.Interval = ClampTimerInterval(hold + hold);
|
|
return;
|
|
}
|
|
|
|
var transform = new TranslateTransform();
|
|
BodyCanvas.RenderTransform = transform;
|
|
var speed = Math.Max(1, _profile.VerticalScrollSpeed);
|
|
var duration = TimeSpan.FromSeconds(overflow / speed);
|
|
_timer.Interval = ClampTimerInterval(hold + duration + hold);
|
|
|
|
var animation = new DoubleAnimation
|
|
{
|
|
From = 0,
|
|
To = -overflow,
|
|
BeginTime = hold,
|
|
Duration = new Duration(duration),
|
|
FillBehavior = FillBehavior.HoldEnd
|
|
};
|
|
transform.BeginAnimation(TranslateTransform.YProperty, animation);
|
|
}
|
|
|
|
private void LayoutBodyText()
|
|
{
|
|
var viewportWidth = Math.Max(1, BodyViewport.ActualWidth);
|
|
if (viewportWidth <= 1)
|
|
{
|
|
viewportWidth = GetBodyContentWidth(_profile);
|
|
}
|
|
|
|
_bodyViewportHeight = GetBodyViewportHeight(_profile, TitleText.ActualHeight);
|
|
BodyText.Width = viewportWidth;
|
|
BodyCanvas.Width = viewportWidth;
|
|
BodyText.Measure(new System.Windows.Size(viewportWidth, double.PositiveInfinity));
|
|
|
|
var textHeight = Math.Ceiling(BodyText.DesiredSize.Height + 2);
|
|
if (_profile.OverflowMode == OverflowMode.Split)
|
|
{
|
|
BodyViewport.Height = Math.Min(_bodyViewportHeight, textHeight);
|
|
BodyViewport.MaxHeight = BodyViewport.Height;
|
|
BodyCanvas.Height = textHeight;
|
|
}
|
|
else if (_profile.OverflowMode == OverflowMode.VerticalScroll)
|
|
{
|
|
BodyViewport.Height = _bodyViewportHeight;
|
|
BodyViewport.MaxHeight = _bodyViewportHeight;
|
|
BodyCanvas.Height = textHeight;
|
|
}
|
|
else
|
|
{
|
|
BodyCanvas.Height = Math.Max(BodyViewport.ActualHeight, textHeight);
|
|
}
|
|
|
|
BodyText.Arrange(new Rect(0, 0, viewportWidth, textHeight));
|
|
}
|
|
|
|
private void AnimateIn()
|
|
{
|
|
switch (_profile.EnterAnimation)
|
|
{
|
|
case PopupAnimation.Zoom:
|
|
RenderTransformOrigin = new System.Windows.Point(0.5, 0.5);
|
|
RenderTransform = new ScaleTransform(0.92, 0.92);
|
|
RenderTransform.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1, TimeSpan.FromMilliseconds(180)));
|
|
RenderTransform.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1, TimeSpan.FromMilliseconds(180)));
|
|
break;
|
|
case PopupAnimation.Slide:
|
|
Left += 18;
|
|
BeginAnimation(LeftProperty, new DoubleAnimation(Left - 18, TimeSpan.FromMilliseconds(180)) { EasingFunction = new CubicEase() });
|
|
break;
|
|
}
|
|
|
|
BeginAnimation(OpacityProperty, new DoubleAnimation(0, _profile.OverallOpacity, TimeSpan.FromMilliseconds(160)));
|
|
_timer.Start();
|
|
}
|
|
|
|
private void AnimateOut()
|
|
{
|
|
var animation = new DoubleAnimation(0, TimeSpan.FromMilliseconds(160));
|
|
animation.Completed += (_, _) => Close();
|
|
BeginAnimation(OpacityProperty, animation);
|
|
}
|
|
|
|
private (double Left, double Top) CalculatePosition(int slot)
|
|
{
|
|
var screens = Screen.AllScreens;
|
|
var screen = GetWorkingAreaInDips(screens[Math.Clamp(_profile.ScreenIndex, 0, screens.Length - 1)]);
|
|
var height = ActualHeight > 1 ? ActualHeight : _profile.MaxHeight;
|
|
var gap = 12;
|
|
var step = height + gap;
|
|
var x = _profile.Anchor switch
|
|
{
|
|
ScreenAnchor.TopLeft or ScreenAnchor.MiddleLeft or ScreenAnchor.BottomLeft => screen.Left + _profile.MarginX,
|
|
ScreenAnchor.TopCenter or ScreenAnchor.Center or ScreenAnchor.BottomCenter => screen.Left + (screen.Width - Width) / 2,
|
|
_ => screen.Right - Width - _profile.MarginX
|
|
};
|
|
var y = _profile.Anchor switch
|
|
{
|
|
ScreenAnchor.TopLeft or ScreenAnchor.TopCenter or ScreenAnchor.TopRight => screen.Top + _profile.MarginY + slot * step,
|
|
ScreenAnchor.MiddleLeft or ScreenAnchor.Center or ScreenAnchor.MiddleRight => screen.Top + (screen.Height - height) / 2 + slot * step,
|
|
_ => screen.Bottom - height - _profile.MarginY - slot * step
|
|
};
|
|
|
|
return (
|
|
Math.Clamp(x, screen.Left, Math.Max(screen.Left, screen.Right - Width)),
|
|
Math.Clamp(y, screen.Top, Math.Max(screen.Top, screen.Bottom - height))
|
|
);
|
|
}
|
|
|
|
private Rect GetWorkingAreaInDips(Screen screen)
|
|
{
|
|
var source = PresentationSource.FromVisual(this);
|
|
var transform = source?.CompositionTarget?.TransformFromDevice ?? Matrix.Identity;
|
|
var area = screen.WorkingArea;
|
|
var topLeft = transform.Transform(new System.Windows.Point(area.Left, area.Top));
|
|
var bottomRight = transform.Transform(new System.Windows.Point(area.Right, area.Bottom));
|
|
return new Rect(topLeft, bottomRight);
|
|
}
|
|
|
|
private static System.Windows.Media.Brush Brush(string color, double opacity)
|
|
{
|
|
try
|
|
{
|
|
var brush = (SolidColorBrush)new BrushConverter().ConvertFromString(color)!;
|
|
brush.Opacity = Math.Clamp(opacity, 0, 1);
|
|
return brush;
|
|
}
|
|
catch
|
|
{
|
|
return new SolidColorBrush(Colors.White) { Opacity = Math.Clamp(opacity, 0, 1) };
|
|
}
|
|
}
|
|
|
|
public static double GetBodyViewportHeight(NotificationProfile profile)
|
|
{
|
|
return GetBodyViewportHeight(profile, profile.TitleFontSize * 1.6);
|
|
}
|
|
|
|
public static double GetBodyViewportHeight(NotificationProfile profile, double titleHeight)
|
|
{
|
|
return Math.Max(
|
|
profile.BodyFontSize * 1.8,
|
|
profile.MaxHeight - (ShellBorderThickness * 2) - (profile.Padding * 2) - titleHeight - BodyTopMargin);
|
|
}
|
|
|
|
public static double GetBodyLineHeight(NotificationProfile profile)
|
|
{
|
|
return Math.Ceiling(profile.BodyFontSize * 1.35);
|
|
}
|
|
|
|
public static double GetBodyContentWidth(NotificationProfile profile)
|
|
{
|
|
return Math.Max(1, profile.Width - (ShellBorderThickness * 2) - (profile.Padding * 2));
|
|
}
|
|
|
|
private static double GetPanelMaxHeight(NotificationProfile profile)
|
|
{
|
|
return profile.MaxHeight - (ShellBorderThickness * 2) - (profile.Padding * 2);
|
|
}
|
|
|
|
private static double GetInitialLifetimeSeconds(NotificationProfile profile)
|
|
{
|
|
return profile.OverflowMode == OverflowMode.VerticalScroll
|
|
? profile.VerticalScrollHoldSeconds * 2
|
|
: profile.LifetimeSeconds;
|
|
}
|
|
|
|
private static TimeSpan ClampTimerInterval(TimeSpan interval)
|
|
{
|
|
return interval < TimeSpan.FromMilliseconds(500)
|
|
? TimeSpan.FromMilliseconds(500)
|
|
: interval;
|
|
}
|
|
|
|
private void ApplyClickThrough()
|
|
{
|
|
var handle = new WindowInteropHelper(this).Handle;
|
|
var style = GetWindowLong(handle, GwlExStyle);
|
|
SetWindowLong(handle, GwlExStyle, style | WsExTransparent | WsExNoActivate | WsExToolWindow);
|
|
}
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern int GetWindowLong(IntPtr hwnd, int index);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern int SetWindowLong(IntPtr hwnd, int index, int value);
|
|
}
|