feat(app): 初始化本地通知桌面应用
搭建 .NET 8 WPF 应用骨架,加入系统托盘、单实例启动与主控制面板。 实现本地 HTTP /notify 消息接入、频道严格匹配、免打扰、熔断限流与历史持久化。 补充弹窗样式配置、队列/推挤/替换展示、溢出处理、应用图标和项目文档。 Initial-Commit: true
This commit is contained in:
278
PopupCoordinator.cs
Normal file
278
PopupCoordinator.cs
Normal file
@@ -0,0 +1,278 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace OmniNotify;
|
||||
|
||||
public sealed class PopupCoordinator
|
||||
{
|
||||
private readonly Dictionary<string, Queue<(Channel Channel, IncomingMessage Message)>> _queues = [];
|
||||
private readonly Dictionary<string, List<PopupWindow>> _visible = [];
|
||||
|
||||
public void Show(Channel channel, IncomingMessage message)
|
||||
{
|
||||
var key = Key(channel.Profile);
|
||||
if (channel.Profile.OverflowMode == OverflowMode.Split)
|
||||
{
|
||||
var parts = SplitBody(message.Body, message.Title, channel.Profile).ToList();
|
||||
if (parts.Count > 1)
|
||||
{
|
||||
var splitChannel = CloneForSplit(channel);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
Enqueue(splitChannel, new IncomingMessage { Channel = message.Channel, Title = message.Title, Body = part }, key, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Enqueue(channel, message, key, false);
|
||||
}
|
||||
|
||||
public void ShowSystem(string title, string body)
|
||||
{
|
||||
var channel = new Channel
|
||||
{
|
||||
Name = "system",
|
||||
Profile = NotificationProfile.CreateDefault()
|
||||
};
|
||||
channel.Profile.BackgroundColor = "#7F1D1D";
|
||||
channel.Profile.BorderColor = "#FCA5A5";
|
||||
Show(channel, new IncomingMessage { Channel = "system", Title = title, Body = body });
|
||||
}
|
||||
|
||||
private void Enqueue(Channel channel, IncomingMessage message, string key, bool forceQueue)
|
||||
{
|
||||
if (!_visible.TryGetValue(key, out var windows))
|
||||
{
|
||||
windows = [];
|
||||
_visible[key] = windows;
|
||||
}
|
||||
|
||||
if (forceQueue)
|
||||
{
|
||||
if (windows.Count == 0)
|
||||
{
|
||||
ShowNow(channel, message, key, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_queues.ContainsKey(key))
|
||||
{
|
||||
_queues[key] = new Queue<(Channel, IncomingMessage)>();
|
||||
}
|
||||
|
||||
_queues[key].Enqueue((channel, message));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (channel.Profile.StackMode)
|
||||
{
|
||||
case StackMode.Replace:
|
||||
foreach (var popup in windows.ToList())
|
||||
{
|
||||
popup.CloseImmediately();
|
||||
}
|
||||
windows.Clear();
|
||||
ShowNow(channel, message, key, 0);
|
||||
break;
|
||||
case StackMode.Push:
|
||||
ShowNow(channel, message, key, windows.Count);
|
||||
Relayout(key);
|
||||
break;
|
||||
default:
|
||||
if (windows.Count == 0)
|
||||
{
|
||||
ShowNow(channel, message, key, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!_queues.ContainsKey(key))
|
||||
{
|
||||
_queues[key] = new Queue<(Channel, IncomingMessage)>();
|
||||
}
|
||||
_queues[key].Enqueue((channel, message));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowNow(Channel channel, IncomingMessage message, string key, int offsetIndex)
|
||||
{
|
||||
var popup = new PopupWindow(channel.Profile, message, offsetIndex);
|
||||
_visible[key].Add(popup);
|
||||
popup.Closed += (_, _) =>
|
||||
{
|
||||
if (_visible.TryGetValue(key, out var windows))
|
||||
{
|
||||
windows.Remove(popup);
|
||||
}
|
||||
|
||||
if (channel.Profile.StackMode == StackMode.Queue && _queues.TryGetValue(key, out var queue) && queue.Count > 0)
|
||||
{
|
||||
var next = queue.Dequeue();
|
||||
ShowNow(next.Channel, next.Message, key, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
Relayout(key);
|
||||
}
|
||||
};
|
||||
popup.Show();
|
||||
}
|
||||
|
||||
private void Relayout(string key)
|
||||
{
|
||||
if (!_visible.TryGetValue(key, out var windows))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < windows.Count; i++)
|
||||
{
|
||||
windows[i].MoveToSlot(i);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Key(NotificationProfile profile) => $"{profile.ScreenIndex}:{profile.Anchor}";
|
||||
|
||||
private static IEnumerable<string> SplitBody(string body, string title, NotificationProfile profile)
|
||||
{
|
||||
var contentWidth = PopupWindow.GetBodyContentWidth(profile);
|
||||
var titleHeight = MeasureTextHeight(
|
||||
title,
|
||||
profile,
|
||||
contentWidth,
|
||||
profile.TitleFontSize,
|
||||
null,
|
||||
FontWeights.SemiBold);
|
||||
var bodyViewportHeight = PopupWindow.GetBodyViewportHeight(profile, titleHeight);
|
||||
var textElements = TextElements(body.Replace("\r\n", "\n")).ToList();
|
||||
|
||||
for (var start = 0; start < textElements.Count;)
|
||||
{
|
||||
var remaining = textElements.Count - start;
|
||||
var low = 1;
|
||||
var high = remaining;
|
||||
var best = 1;
|
||||
|
||||
while (low <= high)
|
||||
{
|
||||
var middle = low + (high - low) / 2;
|
||||
var candidate = string.Concat(textElements.Skip(start).Take(middle));
|
||||
if (FitsBodyViewport(candidate, profile, contentWidth, bodyViewportHeight))
|
||||
{
|
||||
best = middle;
|
||||
low = middle + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
|
||||
var page = string.Concat(textElements.Skip(start).Take(best)).Trim('\r', '\n');
|
||||
if (!string.IsNullOrEmpty(page))
|
||||
{
|
||||
yield return page;
|
||||
}
|
||||
|
||||
start += best;
|
||||
while (start < textElements.Count && IsLineBreak(textElements[start]))
|
||||
{
|
||||
start++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool FitsBodyViewport(string text, NotificationProfile profile, double width, double bodyViewportHeight)
|
||||
{
|
||||
var measuredHeight = MeasureTextHeight(
|
||||
text,
|
||||
profile,
|
||||
width,
|
||||
profile.BodyFontSize,
|
||||
PopupWindow.GetBodyLineHeight(profile),
|
||||
FontWeights.Normal);
|
||||
return measuredHeight <= Math.Floor(bodyViewportHeight);
|
||||
}
|
||||
|
||||
private static double MeasureTextHeight(
|
||||
string text,
|
||||
NotificationProfile profile,
|
||||
double width,
|
||||
double fontSize,
|
||||
double? lineHeight,
|
||||
FontWeight fontWeight)
|
||||
{
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Text = text,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
FontFamily = new System.Windows.Media.FontFamily(profile.FontFamily),
|
||||
FontSize = fontSize,
|
||||
FontWeight = fontWeight
|
||||
};
|
||||
|
||||
if (lineHeight is not null)
|
||||
{
|
||||
textBlock.LineStackingStrategy = LineStackingStrategy.BlockLineHeight;
|
||||
textBlock.LineHeight = lineHeight.Value;
|
||||
}
|
||||
|
||||
textBlock.Measure(new System.Windows.Size(width, double.PositiveInfinity));
|
||||
return Math.Ceiling(textBlock.DesiredSize.Height + 2);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> TextElements(string text)
|
||||
{
|
||||
var enumerator = StringInfo.GetTextElementEnumerator(text);
|
||||
while (enumerator.MoveNext())
|
||||
{
|
||||
yield return enumerator.GetTextElement();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLineBreak(string textElement)
|
||||
{
|
||||
return textElement is "\r" or "\n" or "\r\n";
|
||||
}
|
||||
|
||||
private static Channel CloneForSplit(Channel channel)
|
||||
{
|
||||
var profile = channel.Profile;
|
||||
return new Channel
|
||||
{
|
||||
Id = channel.Id,
|
||||
Name = channel.Name,
|
||||
Profile = new NotificationProfile
|
||||
{
|
||||
ScreenIndex = profile.ScreenIndex,
|
||||
Anchor = profile.Anchor,
|
||||
MarginX = profile.MarginX,
|
||||
MarginY = profile.MarginY,
|
||||
Width = profile.Width,
|
||||
MaxHeight = profile.MaxHeight,
|
||||
Padding = profile.Padding,
|
||||
FontFamily = profile.FontFamily,
|
||||
TitleFontSize = profile.TitleFontSize,
|
||||
BodyFontSize = profile.BodyFontSize,
|
||||
TextColor = profile.TextColor,
|
||||
BackgroundColor = profile.BackgroundColor,
|
||||
BackgroundOpacity = profile.BackgroundOpacity,
|
||||
BorderColor = profile.BorderColor,
|
||||
BorderOpacity = profile.BorderOpacity,
|
||||
OverallOpacity = profile.OverallOpacity,
|
||||
LifetimeSeconds = Math.Max(0.5, profile.SplitIntervalSeconds),
|
||||
EnterAnimation = profile.EnterAnimation,
|
||||
ExitAnimation = profile.ExitAnimation,
|
||||
StackMode = StackMode.Queue,
|
||||
OverflowMode = OverflowMode.Split,
|
||||
SplitIntervalSeconds = profile.SplitIntervalSeconds,
|
||||
VerticalScrollHoldSeconds = profile.VerticalScrollHoldSeconds,
|
||||
VerticalScrollSpeed = profile.VerticalScrollSpeed
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user