using System.Globalization; using System.Windows; using System.Windows.Controls; using System.Windows.Media; namespace OmniNotify; public sealed class PopupCoordinator { private readonly Dictionary> _queues = []; private readonly Dictionary> _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 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 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 } }; } }