Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Soloplan.WhatsON.GUI/NotificationsModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand Down
37 changes: 36 additions & 1 deletion src/Soloplan.WhatsON.GUI/Properties/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions src/Soloplan.WhatsON.GUI/Properties/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,16 @@
<data name="whatsONx16" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\whatsONx16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="TrayHandler_UpdateTrayIcon_BuildFailing" xml:space="preserve">
<value>WhatsON - At least one build is failing</value>
</data>
<data name="TrayHandler_UpdateTrayIcon_Unstable" xml:space="preserve">
<value>WhatsON - At least one build is unstable</value>
</data>
<data name="TrayHandler_UpdateTrayIcon_Pending" xml:space="preserve">
<value>WhatsON - Checking build status</value>
</data>
<data name="TrayHandler_UpdateTrayIcon_Normal" xml:space="preserve">
<value>WhatsON - All builds normal</value>
</data>
</root>
38 changes: 37 additions & 1 deletion src/Soloplan.WhatsON.GUI/Tests/TrayHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,42 @@ public class TrayHandlerTests
private const ObservationState Failure = ObservationState.Failure;
private const ObservationState Running = ObservationState.Running;

/// <summary>
/// Tests the <see cref="TrayHandler.GetOverallState"/> method.
/// </summary>
/// <param name="states">The states.</param>
/// <param name="configuredConnectorCount">The configured connector count.</param>
/// <returns>A <see cref="ObservationState"/>.</returns>
[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);
}

/// <summary>
/// Tests the <see cref="TrayHandler.GetEffectiveState"/> method.
/// </summary>
/// <param name="currentState">The current state.</param>
/// <param name="snapshotStates">The snapshot states.</param>
/// <returns>A <see cref="ObservationState"/>.</returns>
[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")]
Expand Down Expand Up @@ -55,4 +91,4 @@ public bool CheckNotificationShowTest(bool onlyIfChanged, ObservationState curre
return trayHandler.CheckNotificationShow(statusViewModel, currentState, notificationConfiguration);
}
}
}
}
156 changes: 153 additions & 3 deletions src/Soloplan.WhatsON.GUI/TrayHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -42,6 +45,26 @@ public class TrayHandler : IDisposable
/// </summary>
private NotifyIcon icon;

/// <summary>
/// The icon displayed when build-status is pending.
/// </summary>
private Icon loadingIcon;

/// <summary>
/// The icon displayed when build-status is normal.
/// </summary>
private Icon normalIcon;

/// <summary>
/// The icon displayed when build-status is unstable.
/// </summary>
private Icon unstableIcon;

/// <summary>
/// The icon displayed when build-status is broken.
/// </summary>
private Icon failureIcon;

private MainWindow mainWindow;

private NotificationsModel model;
Expand All @@ -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;
Expand All @@ -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)))
{
Expand Down Expand Up @@ -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;
}

/// <summary>
Expand Down Expand Up @@ -170,6 +208,48 @@ public void ShowOrHideWindow()
}
}

/// <summary>
/// Gets the overall build state displayed by the tray icon.
/// </summary>
/// <param name="states">Current connector states.</param>
/// <param name="configuredConnectorCount">Number of configured connectors expected to report a state.</param>
/// <returns>Failure if any build is failing, unstable if any build is unstable, unknown while statuses are pending, otherwise success.</returns>
internal static ObservationState GetOverallState(System.Collections.Generic.IEnumerable<ObservationState> 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;
}

/// <summary>
/// Gets the state used for the tray icon, falling back to the newest completed build when necessary.
/// </summary>
/// <param name="currentState">The current connector state.</param>
/// <param name="snapshotStates">History states ordered from newest to oldest.</param>
/// <returns>The current state, or the newest completed history state while the current state is unknown or running.</returns>
internal static ObservationState GetEffectiveState(ObservationState currentState, System.Collections.Generic.IEnumerable<ObservationState> 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;
}

/// <summary>
/// Checks if the notification should be shown.
/// </summary>
Expand Down Expand Up @@ -209,6 +289,41 @@ internal bool CheckNotificationShow(StatusViewModel currentStatus, ObservationSt
return false;
}

/// <summary>
/// Creates a copy of the application icon with a colored status badge.
/// </summary>
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);

/// <summary>
/// Called when user attempts to close <see cref="MainWindow"/>. It prevents window being closed and hides it instead.
/// </summary>
Expand Down Expand Up @@ -301,6 +416,7 @@ private void MainWindowConfigurationApplied(object sender, ValueEventArgs<Applic
this.model.PropertyChanged -= this.CurrentStatusPropertyChanged;
this.model = new NotificationsModel(this.scheduler);
this.model.PropertyChanged += this.CurrentStatusPropertyChanged;
this.UpdateTrayIcon();

this.scheduler.Start();
}
Expand Down Expand Up @@ -345,8 +461,15 @@ private Func<bool> GetEnabledStateCheck(ObservationState stateToCheck, Notificat
/// <param name="e">The <see cref="System.ComponentModel.PropertyChangedEventArgs"/> instance containing the event data.</param>
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);

Expand All @@ -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;
}
}
}
}
}