Files
omni-notify/PopupWindow.xaml.cs
home-PC c353845fad feat(app): 初始化本地通知桌面应用
搭建 .NET 8 WPF 应用骨架,加入系统托盘、单实例启动与主控制面板。
实现本地 HTTP /notify 消息接入、频道严格匹配、免打扰、熔断限流与历史持久化。
补充弹窗样式配置、队列/推挤/替换展示、溢出处理、应用图标和项目文档。

Initial-Commit: true
2026-05-19 01:32:41 +08:00

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);
}