diff --git a/src/Soloplan.WhatsON.GUI/NotificationsModel.cs b/src/Soloplan.WhatsON.GUI/NotificationsModel.cs index 8b66569..2f40a22 100644 --- a/src/Soloplan.WhatsON.GUI/NotificationsModel.cs +++ b/src/Soloplan.WhatsON.GUI/NotificationsModel.cs @@ -8,6 +8,7 @@ namespace Soloplan.WhatsON.GUI { using System.Collections.ObjectModel; + using System.ComponentModel; using System.Linq; using Soloplan.WhatsON.Composition; using Soloplan.WhatsON.GUI.Common.ConnectorTreeView; @@ -32,6 +33,7 @@ private void SchedulerStatusQueried(object sender, Connector connector) modelToUpdate.Update(connector); modelToUpdate.CurrentStatus.PropertyChanged -= this.OnPropertyChanged; modelToUpdate.CurrentStatus.PropertyChanged += this.OnPropertyChanged; + this.OnPropertyChanged(modelToUpdate.CurrentStatus, new PropertyChangedEventArgs(nameof(ConnectorViewModel.CurrentStatus))); } private ConnectorViewModel GetModelToUpdate(Connector connector) diff --git a/src/Soloplan.WhatsON.GUI/Properties/Resources.Designer.cs b/src/Soloplan.WhatsON.GUI/Properties/Resources.Designer.cs index acd6193..eb3ed3a 100644 --- a/src/Soloplan.WhatsON.GUI/Properties/Resources.Designer.cs +++ b/src/Soloplan.WhatsON.GUI/Properties/Resources.Designer.cs @@ -1,7 +1,6 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -126,6 +125,42 @@ public static string NotInstalled { } } + /// + /// Looks up a localized string similar to WhatsON - At least one build is failing. + /// + public static string TrayHandler_UpdateTrayIcon_BuildFailing { + get { + return ResourceManager.GetString("TrayHandler_UpdateTrayIcon_BuildFailing", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to WhatsON - All builds normal. + /// + public static string TrayHandler_UpdateTrayIcon_Normal { + get { + return ResourceManager.GetString("TrayHandler_UpdateTrayIcon_Normal", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to WhatsON - Checking build status. + /// + public static string TrayHandler_UpdateTrayIcon_Pending { + get { + return ResourceManager.GetString("TrayHandler_UpdateTrayIcon_Pending", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to WhatsON - At least one build is unstable. + /// + public static string TrayHandler_UpdateTrayIcon_Unstable { + get { + return ResourceManager.GetString("TrayHandler_UpdateTrayIcon_Unstable", resourceCulture); + } + } + /// /// Looks up a localized string similar to Value is required.. /// diff --git a/src/Soloplan.WhatsON.GUI/Properties/Resources.resx b/src/Soloplan.WhatsON.GUI/Properties/Resources.resx index 56b3d50..a135329 100644 --- a/src/Soloplan.WhatsON.GUI/Properties/Resources.resx +++ b/src/Soloplan.WhatsON.GUI/Properties/Resources.resx @@ -145,4 +145,16 @@ ..\Resources\whatsONx16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + WhatsON - At least one build is failing + + + WhatsON - At least one build is unstable + + + WhatsON - Checking build status + + + WhatsON - All builds normal + \ No newline at end of file diff --git a/src/Soloplan.WhatsON.GUI/Tests/TrayHandlerTests.cs b/src/Soloplan.WhatsON.GUI/Tests/TrayHandlerTests.cs index b4963d6..15e1466 100644 --- a/src/Soloplan.WhatsON.GUI/Tests/TrayHandlerTests.cs +++ b/src/Soloplan.WhatsON.GUI/Tests/TrayHandlerTests.cs @@ -20,6 +20,42 @@ public class TrayHandlerTests private const ObservationState Failure = ObservationState.Failure; private const ObservationState Running = ObservationState.Running; + /// + /// Tests the method. + /// + /// The states. + /// The configured connector count. + /// A . + [TestCase(new ObservationState[0], 0, ExpectedResult = ObservationState.Success)] + [TestCase(new ObservationState[0], 1, ExpectedResult = ObservationState.Unknown)] + [TestCase(new[] { ObservationState.Success }, 1, ExpectedResult = ObservationState.Success)] + [TestCase(new[] { ObservationState.Success }, 2, ExpectedResult = ObservationState.Unknown)] + [TestCase(new[] { ObservationState.Success, ObservationState.Running }, 2, ExpectedResult = ObservationState.Success)] + [TestCase(new[] { ObservationState.Unknown, ObservationState.Success }, 2, ExpectedResult = ObservationState.Unknown)] + [TestCase(new[] { ObservationState.Success, ObservationState.Unstable }, 3, ExpectedResult = ObservationState.Unstable)] + [TestCase(new[] { ObservationState.Unstable, ObservationState.Failure }, 3, ExpectedResult = ObservationState.Failure)] + [TestCase(new[] { ObservationState.Failure, ObservationState.Unstable, ObservationState.Success }, 3, ExpectedResult = ObservationState.Failure)] + public ObservationState GetOverallStateTest(ObservationState[] states, int configuredConnectorCount) + { + return TrayHandler.GetOverallState(states, configuredConnectorCount); + } + + /// + /// Tests the method. + /// + /// The current state. + /// The snapshot states. + /// A . + [TestCase(ObservationState.Success, new[] { ObservationState.Failure }, ExpectedResult = ObservationState.Success)] + [TestCase(ObservationState.Failure, new[] { ObservationState.Success }, ExpectedResult = ObservationState.Failure)] + [TestCase(ObservationState.Running, new[] { ObservationState.Unstable, ObservationState.Success }, ExpectedResult = ObservationState.Unstable)] + [TestCase(ObservationState.Unknown, new[] { ObservationState.Running, ObservationState.Failure, ObservationState.Success }, ExpectedResult = ObservationState.Failure)] + [TestCase(ObservationState.Unknown, new[] { ObservationState.Unknown, ObservationState.Running }, ExpectedResult = ObservationState.Unknown)] + public ObservationState GetEffectiveStateTest(ObservationState currentState, ObservationState[] snapshotStates) + { + return TrayHandler.GetEffectiveState(currentState, snapshotStates); + } + // usually the status queue contains 5 elements but the newest one is the same current, so it is enough to give 4 states. The newest is on the left [TestCase(false, Success, Success, Success, Success, Success, ExpectedResult = true, Description = "Simple state update")] [TestCase(false, Success, Failure, Failure, Failure, Failure, ExpectedResult = true, Description = "Simple state change")] @@ -55,4 +91,4 @@ public bool CheckNotificationShowTest(bool onlyIfChanged, ObservationState curre return trayHandler.CheckNotificationShow(statusViewModel, currentState, notificationConfiguration); } } -} \ No newline at end of file +} diff --git a/src/Soloplan.WhatsON.GUI/TrayHandler.cs b/src/Soloplan.WhatsON.GUI/TrayHandler.cs index 187de27..99f13b8 100644 --- a/src/Soloplan.WhatsON.GUI/TrayHandler.cs +++ b/src/Soloplan.WhatsON.GUI/TrayHandler.cs @@ -7,13 +7,16 @@ namespace Soloplan.WhatsON.GUI { using System; using System.Drawing; + using System.Drawing.Drawing2D; using System.IO; using System.Linq; + using System.Runtime.InteropServices; using System.Windows.Forms; using Soloplan.WhatsON.Composition; using Soloplan.WhatsON.Configuration; using Soloplan.WhatsON.GUI.Common.ConnectorTreeView; using Soloplan.WhatsON.GUI.Common.VisualConfig; + using Soloplan.WhatsON.GUI.Properties; using Soloplan.WhatsON.Model; using Application = System.Windows.Application; @@ -42,6 +45,26 @@ public class TrayHandler : IDisposable /// private NotifyIcon icon; + /// + /// The icon displayed when build-status is pending. + /// + private Icon loadingIcon; + + /// + /// The icon displayed when build-status is normal. + /// + private Icon normalIcon; + + /// + /// The icon displayed when build-status is unstable. + /// + private Icon unstableIcon; + + /// + /// The icon displayed when build-status is broken. + /// + private Icon failureIcon; + private MainWindow mainWindow; private NotificationsModel model; @@ -53,8 +76,14 @@ public class TrayHandler : IDisposable public TrayHandler(ObservationScheduler scheduler, ApplicationConfiguration configuration) { + this.loadingIcon = new Icon(Properties.Resources.Whatson, new Size(16, 16)); + this.normalIcon = CreateStatusIcon(this.loadingIcon, Color.LimeGreen); + this.unstableIcon = CreateStatusIcon(this.loadingIcon, Color.Gold); + this.failureIcon = CreateStatusIcon(this.loadingIcon, Color.Red); + this.icon = new System.Windows.Forms.NotifyIcon(); - this.icon.Icon = new Icon(Properties.Resources.Whatson, new Size(16, 16)); + this.icon.Icon = this.loadingIcon; + this.icon.Text = Resources.TrayHandler_UpdateTrayIcon_Pending; this.icon.Visible = true; this.scheduler = scheduler; this.configuration = configuration; @@ -68,6 +97,7 @@ public TrayHandler(ObservationScheduler scheduler, ApplicationConfiguration conf this.model = new NotificationsModel(this.scheduler); this.model.PropertyChanged += this.CurrentStatusPropertyChanged; + this.UpdateTrayIcon(); if (File.Exists(Path.Combine(SerializationHelper.Instance.ConfigFolder, MainWindow.VisualSettingsFile))) { @@ -110,6 +140,14 @@ public void Dispose() this.contextMenu = null; this.icon?.Dispose(); this.icon = null; + this.loadingIcon?.Dispose(); + this.loadingIcon = null; + this.normalIcon?.Dispose(); + this.normalIcon = null; + this.unstableIcon?.Dispose(); + this.unstableIcon = null; + this.failureIcon?.Dispose(); + this.failureIcon = null; } /// @@ -170,6 +208,48 @@ public void ShowOrHideWindow() } } + /// + /// Gets the overall build state displayed by the tray icon. + /// + /// Current connector states. + /// Number of configured connectors expected to report a state. + /// Failure if any build is failing, unstable if any build is unstable, unknown while statuses are pending, otherwise success. + internal static ObservationState GetOverallState(System.Collections.Generic.IEnumerable states, int configuredConnectorCount) + { + var currentStates = states.ToList(); + if (currentStates.Contains(ObservationState.Failure)) + { + return ObservationState.Failure; + } + + if (currentStates.Contains(ObservationState.Unstable)) + { + return ObservationState.Unstable; + } + + return currentStates.Count < configuredConnectorCount || currentStates.Contains(ObservationState.Unknown) ? ObservationState.Unknown : ObservationState.Success; + } + + /// + /// Gets the state used for the tray icon, falling back to the newest completed build when necessary. + /// + /// The current connector state. + /// History states ordered from newest to oldest. + /// The current state, or the newest completed history state while the current state is unknown or running. + internal static ObservationState GetEffectiveState(ObservationState currentState, System.Collections.Generic.IEnumerable snapshotStates) + { + if (currentState != ObservationState.Unknown && currentState != ObservationState.Running) + { + return currentState; + } + + var completedState = snapshotStates + .Where(state => state != ObservationState.Unknown && state != ObservationState.Running) + .Select(state => (ObservationState?)state) + .FirstOrDefault(); + return completedState ?? currentState; + } + /// /// Checks if the notification should be shown. /// @@ -209,6 +289,41 @@ internal bool CheckNotificationShow(StatusViewModel currentStatus, ObservationSt return false; } + /// + /// Creates a copy of the application icon with a colored status badge. + /// + private static Icon CreateStatusIcon(Icon baseIcon, Color statusColor) + { + using (var bitmap = new Bitmap(16, 16)) + { + using (var graphics = Graphics.FromImage(bitmap)) + { + graphics.DrawIcon(baseIcon, new Rectangle(0, 0, 16, 16)); + graphics.SmoothingMode = SmoothingMode.AntiAlias; + using (var outline = new SolidBrush(Color.FromArgb(230, 255, 255, 255))) + using (var badge = new SolidBrush(statusColor)) + { + graphics.FillEllipse(outline, 7, 7, 9, 9); + graphics.FillEllipse(badge, 8, 8, 7, 7); + } + } + + var iconHandle = bitmap.GetHicon(); + try + { + return (Icon)Icon.FromHandle(iconHandle).Clone(); + } + finally + { + DestroyIcon(iconHandle); + } + } + } + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DestroyIcon(IntPtr handle); + /// /// Called when user attempts to close . It prevents window being closed and hides it instead. /// @@ -301,6 +416,7 @@ private void MainWindowConfigurationApplied(object sender, ValueEventArgs GetEnabledStateCheck(ObservationState stateToCheck, Notificat /// The instance containing the event data. private void CurrentStatusPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) { - if (sender is StatusViewModel statusViewModel && e.PropertyName == nameof(StatusViewModel.State)) + if (sender is StatusViewModel statusViewModel && (e.PropertyName == nameof(StatusViewModel.State) || e.PropertyName == nameof(ConnectorViewModel.CurrentStatus))) { + this.UpdateTrayIcon(); + + if (e.PropertyName != nameof(StatusViewModel.State)) + { + return; + } + var connectorConfiguration = this.configuration.ConnectorsConfiguration.FirstOrDefault(s => s.Identifier == statusViewModel.Parent.Identifier); var notificationConfiguration = this.configuration.GetNotificationConfiguration(connectorConfiguration); @@ -373,5 +496,32 @@ private void CurrentStatusPropertyChanged(object sender, System.ComponentModel.P } } } + + private void UpdateTrayIcon() + { + var connectorStates = this.model.Connectors + .Select(connector => GetEffectiveState(connector.CurrentStatus.State, connector.ConnectorSnapshots.Select(snapshot => snapshot.State))) + .ToList(); + var overallState = GetOverallState(connectorStates, this.configuration.ConnectorsConfiguration.Count); + switch (overallState) + { + case ObservationState.Failure: + this.icon.Icon = this.failureIcon; + this.icon.Text = Resources.TrayHandler_UpdateTrayIcon_BuildFailing; + break; + case ObservationState.Unstable: + this.icon.Icon = this.unstableIcon; + this.icon.Text = Resources.TrayHandler_UpdateTrayIcon_Unstable; + break; + case ObservationState.Unknown: + this.icon.Icon = this.loadingIcon; + this.icon.Text = Resources.TrayHandler_UpdateTrayIcon_Pending; + break; + default: + this.icon.Icon = this.normalIcon; + this.icon.Text = Resources.TrayHandler_UpdateTrayIcon_Normal; + break; + } + } } -} \ No newline at end of file +}