diff --git a/lib/TweetLib.Api/Data/Notification/IDesktopNotificationScreenProvider.cs b/lib/TweetLib.Api/Data/Notification/IDesktopNotificationScreenProvider.cs new file mode 100644 index 00000000..3c516d4f --- /dev/null +++ b/lib/TweetLib.Api/Data/Notification/IDesktopNotificationScreenProvider.cs @@ -0,0 +1,28 @@ +namespace TweetLib.Api.Data.Notification { + /// <summary> + /// Allows extensions to decide which screen to use for notifications. + /// + /// Every registered provider becomes available in the Options dialog and has to be explicitly selected by the user. Only one provider + /// can be active at any given time. + /// </summary> + public interface IDesktopNotificationScreenProvider { + /// <summary> + /// A unique identifier of this provider. Only needs to be unique within the scope of this plugin. + /// </summary> + Resource Id { get; } + + /// <summary> + /// Text displayed in the user interface. + /// </summary> + string DisplayName { get; } + + /// <summary> + /// Returns a screen that will be used to display the next desktop notification. + /// + /// If the return value is <c>null</c> or a screen that is not present in <see cref="IScreenLayout.AllScreens" />, desktop + /// notifications will be temporarily paused and this method will be called again after an unspecified amount of time (but + /// not sooner than 1 second since the last call). + /// </summary> + IScreen? PickScreen(IScreenLayout layout); + } +} diff --git a/lib/TweetLib.Api/Data/Notification/IScreen.cs b/lib/TweetLib.Api/Data/Notification/IScreen.cs new file mode 100644 index 00000000..d58d738c --- /dev/null +++ b/lib/TweetLib.Api/Data/Notification/IScreen.cs @@ -0,0 +1,7 @@ +namespace TweetLib.Api.Data.Notification { + public interface IScreen { + ScreenBounds Bounds { get; } + string Name { get; } + bool IsPrimary { get; } + } +} diff --git a/lib/TweetLib.Api/Data/Notification/IScreenLayout.cs b/lib/TweetLib.Api/Data/Notification/IScreenLayout.cs new file mode 100644 index 00000000..b81cfbb3 --- /dev/null +++ b/lib/TweetLib.Api/Data/Notification/IScreenLayout.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace TweetLib.Api.Data.Notification { + public interface IScreenLayout { + IScreen PrimaryScreen { get; } + IScreen TweetDuckScreen { get; } + List<IScreen> AllScreens { get; } + } +} diff --git a/lib/TweetLib.Api/Data/Notification/ScreenBounds.cs b/lib/TweetLib.Api/Data/Notification/ScreenBounds.cs new file mode 100644 index 00000000..533b0a25 --- /dev/null +++ b/lib/TweetLib.Api/Data/Notification/ScreenBounds.cs @@ -0,0 +1,18 @@ +namespace TweetLib.Api.Data.Notification { + public readonly struct ScreenBounds { + public int X1 { get; } + public int Y1 { get; } + public int Width { get; } + public int Height { get; } + + public int X2 => X1 + Width; + public int Y2 => Y1 + Height; + + public ScreenBounds(int x1, int y1, int width, int height) { + X1 = x1; + Y1 = y1; + Width = width; + Height = height; + } + } +} diff --git a/lib/TweetLib.Api/Service/INotificationService.cs b/lib/TweetLib.Api/Service/INotificationService.cs new file mode 100644 index 00000000..90916916 --- /dev/null +++ b/lib/TweetLib.Api/Service/INotificationService.cs @@ -0,0 +1,7 @@ +using TweetLib.Api.Data.Notification; + +namespace TweetLib.Api.Service { + public interface INotificationService : ITweetDuckService { + void RegisterDesktopNotificationScreenProvider(IDesktopNotificationScreenProvider provider); + } +} diff --git a/lib/TweetLib.Core/Systems/Configuration/ConfigManager.cs b/lib/TweetLib.Core/Systems/Configuration/ConfigManager.cs index 9dc885db..2044364b 100644 --- a/lib/TweetLib.Core/Systems/Configuration/ConfigManager.cs +++ b/lib/TweetLib.Core/Systems/Configuration/ConfigManager.cs @@ -10,7 +10,7 @@ namespace TweetLib.Core.Systems.Configuration { public abstract class ConfigManager { - protected static TypeConverterRegistry ConverterRegistry { get; } = new (); + public static TypeConverterRegistry ConverterRegistry { get; } = new (); static ConfigManager() { ConverterRegistry.Register(typeof(WindowState), new BasicTypeConverter<WindowState> { diff --git a/windows/TweetDuck/Application/ApiServices.cs b/windows/TweetDuck/Application/ApiServices.cs index dc4fd7cf..44496f63 100644 --- a/windows/TweetDuck/Application/ApiServices.cs +++ b/windows/TweetDuck/Application/ApiServices.cs @@ -1,11 +1,16 @@ using System; +using TweetDuck.Application.Service; using TweetLib.Api; using TweetLib.Api.Data; +using TweetLib.Api.Service; using TweetLib.Core; namespace TweetDuck.Application { static class ApiServices { + public static NotificationService Notifications { get; } = new NotificationService(); + public static void Register() { + App.Api.RegisterService<INotificationService>(Notifications); } internal static NamespacedResource Namespace(Resource path) { diff --git a/windows/TweetDuck/Application/Service/NotificationService.cs b/windows/TweetDuck/Application/Service/NotificationService.cs new file mode 100644 index 00000000..4c9a864a --- /dev/null +++ b/windows/TweetDuck/Application/Service/NotificationService.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using TweetLib.Api.Data; +using TweetLib.Api.Data.Notification; +using TweetLib.Api.Service; + +namespace TweetDuck.Application.Service { + sealed class NotificationService : INotificationService { + private readonly List<NamespacedProvider> desktopNotificationScreenProviders = new (); + + public void RegisterDesktopNotificationScreenProvider(IDesktopNotificationScreenProvider provider) { + desktopNotificationScreenProviders.Add(new NamespacedProvider(ApiServices.Namespace(provider.Id), provider)); + } + + public List<NamespacedProvider> GetDesktopNotificationScreenProviders() { + return desktopNotificationScreenProviders; + } + + public sealed class NamespacedProvider : IDesktopNotificationScreenProvider { + public NamespacedResource NamespacedId { get; } + + private readonly IDesktopNotificationScreenProvider provider; + + public NamespacedProvider(NamespacedResource id, IDesktopNotificationScreenProvider provider) { + this.NamespacedId = id; + this.provider = provider; + } + + public Resource Id => provider.Id; + public string DisplayName => provider.DisplayName; + public IScreen? PickScreen(IScreenLayout layout) => provider.PickScreen(layout); + } + } +} diff --git a/windows/TweetDuck/Browser/Notification/FormNotificationBase.cs b/windows/TweetDuck/Browser/Notification/FormNotificationBase.cs index 59c04d63..df4e33a1 100644 --- a/windows/TweetDuck/Browser/Notification/FormNotificationBase.cs +++ b/windows/TweetDuck/Browser/Notification/FormNotificationBase.cs @@ -22,15 +22,7 @@ abstract partial class FormNotificationBase : Form { protected virtual Point PrimaryLocation { get { - Screen screen; - - if (Config.NotificationDisplay > 0 && Config.NotificationDisplay <= Screen.AllScreens.Length) { - screen = Screen.AllScreens[Config.NotificationDisplay - 1]; - } - else { - screen = Screen.FromControl(owner); - } - + Screen screen = Config.NotificationDisplay.PickScreen(owner); int edgeDist = Config.NotificationEdgeDistance; switch (Config.NotificationPosition) { diff --git a/windows/TweetDuck/Browser/Notification/NotificationScreen.cs b/windows/TweetDuck/Browser/Notification/NotificationScreen.cs new file mode 100644 index 00000000..28804d7a --- /dev/null +++ b/windows/TweetDuck/Browser/Notification/NotificationScreen.cs @@ -0,0 +1,173 @@ +using System.Collections.Generic; +using System.Linq; +using System.Windows.Forms; +using TweetDuck.Application; +using TweetDuck.Application.Service; +using TweetLib.Api.Data; +using TweetLib.Api.Data.Notification; +using TweetLib.Utils.Serialization.Converters; +using TweetLib.Utils.Static; + +namespace TweetDuck.Browser.Notification { + abstract class NotificationScreen { + public static List<NotificationScreen> All { + get { + var list = new List<NotificationScreen> { + SameAsTweetDuck.Instance + }; + + for (int index = 1; index <= Screen.AllScreens.Length; index++) { + list.Add(new Static(index)); + } + + foreach (var provider in ApiServices.Notifications.GetDesktopNotificationScreenProviders()) { + list.Add(new Provided(provider.NamespacedId)); + } + + return list; + } + } + + public abstract string DisplayName { get; } + + private NotificationScreen() {} + + public abstract Screen PickScreen(FormBrowser mainWindow); + + protected abstract string Serialize(); + + public sealed class SameAsTweetDuck : NotificationScreen { + public static SameAsTweetDuck Instance { get; } = new SameAsTweetDuck(); + + public override string DisplayName => "(Same as TweetDuck)"; + + private SameAsTweetDuck() {} + + public override Screen PickScreen(FormBrowser mainWindow) { + return Screen.FromControl(mainWindow); + } + + protected override string Serialize() { + return "0"; + } + + public override bool Equals(object? obj) { + return obj is SameAsTweetDuck; + } + + public override int GetHashCode() { + return 1828695039; + } + } + + private sealed class Static : NotificationScreen { + public override string DisplayName { + get { + Screen? screen = Screen; + if (screen == null) { + return $"Unknown ({screenIndex})"; + } + + return screen.DeviceName.TrimStart('\\', '.') + $" ({screen.Bounds.Width}x{screen.Bounds.Height})"; + } + } + + private Screen? Screen => screenIndex >= 1 && screenIndex <= Screen.AllScreens.Length ? Screen.AllScreens[screenIndex - 1] : null; + + private readonly int screenIndex; + + public Static(int screenIndex) { + this.screenIndex = screenIndex; + } + + public override Screen PickScreen(FormBrowser mainWindow) { + return Screen ?? SameAsTweetDuck.Instance.PickScreen(mainWindow); + } + + protected override string Serialize() { + return screenIndex.ToString(); + } + + public override bool Equals(object? obj) { + return obj is Static other && screenIndex == other.screenIndex; + } + + public override int GetHashCode() { + return 31 * screenIndex; + } + } + + private sealed class Provided : NotificationScreen { + public override string DisplayName => Provider?.DisplayName ?? $"Unknown ({resource})"; + + private readonly NamespacedResource resource; + + private NotificationService.NamespacedProvider? provider; + private NotificationService.NamespacedProvider? Provider => provider ??= ApiServices.Notifications.GetDesktopNotificationScreenProviders().Find(p => p.NamespacedId == resource); + + public Provided(NamespacedResource resource) { + this.resource = resource; + } + + public override Screen PickScreen(FormBrowser mainWindow) { + IScreen? pick = Provider?.PickScreen(new WindowsFormsScreenLayout(mainWindow)); + return pick is WindowsFormsScreen screen ? screen.Screen : SameAsTweetDuck.Instance.PickScreen(mainWindow); // TODO + } + + protected override string Serialize() { + return resource.Namespace + ":" + resource.Path; + } + + public override bool Equals(object? obj) { + return obj is Provided other && resource == other.resource; + } + + public override int GetHashCode() { + return resource.GetHashCode(); + } + + private sealed class WindowsFormsScreenLayout : IScreenLayout { + public IScreen PrimaryScreen => new WindowsFormsScreen(Screen.PrimaryScreen); + public IScreen TweetDuckScreen => new WindowsFormsScreen(Screen.FromControl(mainWindow)); + public List<IScreen> AllScreens => Screen.AllScreens.Select(static screen => new WindowsFormsScreen(screen)).ToList<IScreen>(); + + private readonly FormBrowser mainWindow; + + public WindowsFormsScreenLayout(FormBrowser mainWindow) { + this.mainWindow = mainWindow; + } + } + + private sealed class WindowsFormsScreen : IScreen { + public Screen Screen { get; } + public ScreenBounds Bounds { get; } + public string Name => Screen.DeviceName; + public bool IsPrimary => Screen.Primary; + + public WindowsFormsScreen(Screen screen) { + this.Screen = screen; + this.Bounds = new ScreenBounds(screen.Bounds.X, screen.Bounds.Y, screen.Bounds.Width, screen.Bounds.Height); + } + } + } + + public static readonly BasicTypeConverter<NotificationScreen> Converter = new() { + ConvertToString = static value => value.Serialize(), + ConvertToObject = static value => { + if (value == "0") { + return SameAsTweetDuck.Instance; + } + else if (int.TryParse(value, out int index)) { + return new Static(index); + } + + var resource = StringUtils.SplitInTwo(value, ':'); + if (resource != null) { + return new Provided(new NamespacedResource(new Resource(resource.Value.before), new Resource(resource.Value.after))); + } + + return SameAsTweetDuck.Instance; + } + }; + } +} diff --git a/windows/TweetDuck/Configuration/UserConfig.cs b/windows/TweetDuck/Configuration/UserConfig.cs index b9007a5a..bb428cea 100644 --- a/windows/TweetDuck/Configuration/UserConfig.cs +++ b/windows/TweetDuck/Configuration/UserConfig.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.Drawing; using TweetDuck.Browser; +using TweetDuck.Browser.Notification; using TweetDuck.Controls; using TweetLib.Core; using TweetLib.Core.Application; @@ -66,7 +67,7 @@ sealed class UserConfig : BaseConfig<UserConfig>, IAppUserConfiguration { public DesktopNotification.Position NotificationPosition { get; set; } = DesktopNotification.Position.TopRight; public Point CustomNotificationPosition { get; set; } = ControlExtensions.InvisibleLocation; - public int NotificationDisplay { get; set; } = 0; + public NotificationScreen NotificationDisplay { get; set; } = NotificationScreen.SameAsTweetDuck.Instance; public int NotificationEdgeDistance { get; set; } = 8; public int NotificationWindowOpacity { get; set; } = 100; diff --git a/windows/TweetDuck/Dialogs/Settings/TabSettingsNotifications.cs b/windows/TweetDuck/Dialogs/Settings/TabSettingsNotifications.cs index 273c8d85..d644ef6f 100644 --- a/windows/TweetDuck/Dialogs/Settings/TabSettingsNotifications.cs +++ b/windows/TweetDuck/Dialogs/Settings/TabSettingsNotifications.cs @@ -86,13 +86,21 @@ public TabSettingsNotifications(FormNotificationExample notification) { } comboBoxDisplay.Enabled = trackBarEdgeDistance.Enabled = !radioLocCustom.Checked; - comboBoxDisplay.Items.Add("(Same as TweetDuck)"); - foreach (Screen screen in Screen.AllScreens) { - comboBoxDisplay.Items.Add($"{screen.DeviceName.TrimStart('\\', '.')} ({screen.Bounds.Width}x{screen.Bounds.Height})"); + bool foundScreen = false; + + foreach (var screen in NotificationScreen.All) { + comboBoxDisplay.Items.Add(new NotificationScreenItem(screen)); + if (screen.Equals(Config.NotificationDisplay)) { + comboBoxDisplay.SelectedIndex = comboBoxDisplay.Items.Count - 1; + foundScreen = true; + } } - comboBoxDisplay.SelectedIndex = Math.Min(comboBoxDisplay.Items.Count - 1, Config.NotificationDisplay); + if (!foundScreen) { + comboBoxDisplay.Items.Add(new NotificationScreenItem(Config.NotificationDisplay)); + comboBoxDisplay.SelectedIndex = comboBoxDisplay.Items.Count - 1; + } trackBarEdgeDistance.SetValueSafe(Config.NotificationEdgeDistance); labelEdgeDistanceValue.Text = trackBarEdgeDistance.Value + " px"; @@ -279,7 +287,7 @@ private void radioLocCustom_Click(object? sender, EventArgs e) { } private void comboBoxDisplay_SelectedValueChanged(object? sender, EventArgs e) { - Config.NotificationDisplay = comboBoxDisplay.SelectedIndex; + Config.NotificationDisplay = ((NotificationScreenItem) comboBoxDisplay.SelectedItem).Screen; notification.ShowExampleNotification(false); } @@ -289,6 +297,21 @@ private void trackBarEdgeDistance_ValueChanged(object? sender, EventArgs e) { notification.ShowExampleNotification(false); } + private class NotificationScreenItem { + public NotificationScreen Screen { get; } + + private readonly string displayName; + + public NotificationScreenItem(NotificationScreen screen) { + this.Screen = screen; + this.displayName = screen.DisplayName; + } + + public override string ToString() { + return displayName; + } + } + #endregion #region Size diff --git a/windows/TweetDuck/Program.cs b/windows/TweetDuck/Program.cs index a4daa0ee..a193cedf 100644 --- a/windows/TweetDuck/Program.cs +++ b/windows/TweetDuck/Program.cs @@ -6,6 +6,7 @@ using TweetDuck.Application; using TweetDuck.Browser; using TweetDuck.Browser.Base; +using TweetDuck.Browser.Notification; using TweetDuck.Configuration; using TweetDuck.Dialogs; using TweetDuck.Management; @@ -91,6 +92,7 @@ private sealed class Setup : IAppSetup { public string? ResourceRewriteRules => Arguments.GetValue(Arguments.ArgFreeze); public ConfigManager CreateConfigManager(string storagePath) { + ConfigManager.ConverterRegistry.Register(typeof(NotificationScreen), NotificationScreen.Converter); return new ConfigManager<UserConfig, SystemConfig>(storagePath, Config); }