搭建 .NET 8 WPF 应用骨架,加入系统托盘、单实例启动与主控制面板。 实现本地 HTTP /notify 消息接入、频道严格匹配、免打扰、熔断限流与历史持久化。 补充弹窗样式配置、队列/推挤/替换展示、溢出处理、应用图标和项目文档。 Initial-Commit: true
279 lines
8.9 KiB
C#
279 lines
8.9 KiB
C#
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
|
|
}
|
|
};
|
|
}
|
|
}
|