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

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

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