feat(app): 初始化本地通知桌面应用
搭建 .NET 8 WPF 应用骨架,加入系统托盘、单实例启动与主控制面板。 实现本地 HTTP /notify 消息接入、频道严格匹配、免打扰、熔断限流与历史持久化。 补充弹窗样式配置、队列/推挤/替换展示、溢出处理、应用图标和项目文档。 Initial-Commit: true
This commit is contained in:
312
PopupWindow.xaml.cs
Normal file
312
PopupWindow.xaml.cs
Normal file
@@ -0,0 +1,312 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user