initial commit
Pulsar .NET 9.0 Windows Release / build (push) Waiting to run
Mirror to Codeberg and Gitea / mirror (push) Waiting to run

This commit is contained in:
i2p
2026-08-27 10:57:58 -06:00
commit 773d05f8f1
1038 changed files with 109261 additions and 0 deletions
Binary file not shown.
+201
View File
@@ -0,0 +1,201 @@
using System;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms;
namespace Pulsar.Server.Controls
{
/// <summary>
/// A custom control that visualizes audio levels with smooth animations in dark mode.
/// </summary>
public class AudioVisualizer : Control
{
private const int BAR_SPACING = 2;
private int _barCount = 40;
private const float DECAY_RATE = 0.92f;
private const float SMOOTHING = 0.3f;
private float[] _barHeights;
private float[] _targetHeights;
private float _currentLevel = 0f;
private float _targetLevel = 0f;
private Timer _animationTimer;
private Random _random = new Random();
// Dark mode colors
private readonly Color _backgroundColor = Color.FromArgb(28, 28, 28);
private readonly Color _barColorLow = Color.FromArgb(0, 150, 255);
private readonly Color _barColorMid = Color.FromArgb(0, 200, 100);
private readonly Color _barColorHigh = Color.FromArgb(255, 100, 0);
public AudioVisualizer()
{
SetStyle(ControlStyles.AllPaintingInWmPaint |
ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer |
ControlStyles.ResizeRedraw, true);
BackColor = _backgroundColor;
_animationTimer = new Timer();
_animationTimer.Interval = 33; // ~30 FPS
_animationTimer.Tick += AnimationTimer_Tick;
_animationTimer.Start();
CalculateBarCount();
InitializeBars();
}
/// <summary>
/// Calculates the optimal number of bars to fill the control width.
/// </summary>
private void CalculateBarCount()
{
if (Width > 0)
{
// Calculate how many bars can fit (minimum 3 pixels per bar + spacing)
_barCount = Math.Max(20, Width / 5);
}
else
{
_barCount = 40; // Default
}
}
protected override void OnResize(EventArgs e)
{
base.OnResize(e);
CalculateBarCount();
InitializeBars();
}
private void InitializeBars()
{
_barHeights = new float[_barCount];
_targetHeights = new float[_barCount];
for (int i = 0; i < _barCount; i++)
{
_barHeights[i] = 0f;
_targetHeights[i] = 0f;
}
}
/// <summary>
/// Updates the audio level for visualization.
/// </summary>
/// <param name="level">Audio level between 0.0 and 1.0</param>
public void UpdateLevel(float level)
{
_targetLevel = Math.Max(0f, Math.Min(1f, level));
}
/// <summary>
/// Resets the visualizer to zero.
/// </summary>
public void Reset()
{
_targetLevel = 0f;
_currentLevel = 0f;
if (_barHeights != null)
{
for (int i = 0; i < _barCount; i++)
{
_barHeights[i] = 0f;
_targetHeights[i] = 0f;
}
}
Invalidate();
}
private void AnimationTimer_Tick(object sender, EventArgs e)
{
if (_barHeights == null || _targetHeights == null)
return;
_currentLevel += (_targetLevel - _currentLevel) * SMOOTHING;
for (int i = 0; i < _barCount; i++)
{
float baseHeight = _currentLevel;
if (_currentLevel > 0.01f)
{
float variation = (float)_random.NextDouble() * 0.3f - 0.15f;
_targetHeights[i] = Math.Max(0f, Math.Min(1f, baseHeight + variation));
}
else
{
_targetHeights[i] = 0f;
}
if (_barHeights[i] < _targetHeights[i])
{
_barHeights[i] += (_targetHeights[i] - _barHeights[i]) * 0.4f;
}
else
{
_barHeights[i] *= DECAY_RATE;
}
}
_targetLevel *= 0.85f;
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
if (_barHeights == null || _targetHeights == null)
return;
Graphics g = e.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
int barWidth = (Width - (_barCount - 1) * BAR_SPACING) / _barCount;
if (barWidth < 1) barWidth = 1;
for (int i = 0; i < _barCount; i++)
{
int x = i * (barWidth + BAR_SPACING);
int barHeight = (int)(_barHeights[i] * Height);
int y = Height - barHeight;
if (barHeight > 0)
{
Color barColor;
if (_barHeights[i] < 0.5f)
{
barColor = _barColorLow;
}
else if (_barHeights[i] < 0.8f)
{
barColor = _barColorMid;
}
else
{
barColor = _barColorHigh;
}
using (SolidBrush brush = new SolidBrush(barColor))
{
g.FillRectangle(brush, x, y, barWidth, barHeight);
}
}
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_animationTimer?.Stop();
_animationTimer?.Dispose();
}
base.Dispose(disposing);
}
}
}
@@ -0,0 +1,96 @@
using Pulsar.Server.Controls.Wpf;
using Pulsar.Server.Networking;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Forms;
using System.Windows.Forms.Integration;
#nullable enable
namespace Pulsar.Server.Controls
{
public sealed class ClientsListElementHost : ElementHost
{
private readonly ClientsListView _clientsListView;
public ClientsListElementHost()
{
_clientsListView = new ClientsListView();
Child = _clientsListView;
Dock = DockStyle.Fill;
_clientsListView.SelectionChanged += ClientsListViewOnSelectionChanged;
_clientsListView.ItemDoubleClicked += ClientsListViewOnItemDoubleClicked;
_clientsListView.FavoriteToggled += ClientsListViewOnFavoriteToggled;
}
public event EventHandler? SelectionChanged;
public event EventHandler<Client>? ItemDoubleClicked;
public event EventHandler<Client>? FavoriteToggled;
public IReadOnlyList<Client> SelectedClients => _clientsListView.SelectedEntries.Select(e => e.Client).ToList();
public int SelectedCount => SelectedClients.Count;
public ClientListEntry AddOrUpdate(Client client, Action<ClientListEntry> updater)
{
return _clientsListView.AddOrUpdate(client, updater);
}
public void Remove(Client client) => _clientsListView.Remove(client);
public void ClearClients() => _clientsListView.Clear();
public void ApplyFilter(Func<ClientListEntry, bool>? predicate)
{
_clientsListView.ApplyFilter(predicate == null ? null : new Predicate<ClientListEntry>(predicate));
}
public void SetGroupByCountry(bool enabled) => _clientsListView.SetGroupByCountry(enabled);
public void RefreshSort() => _clientsListView.RefreshSort();
public void SetSelectedClients(IEnumerable<Client> clients)
{
_clientsListView.SetSelectedClients(clients);
}
public void RefreshItem(Client client)
{
var entry = _clientsListView.GetEntryByClient(client);
if (entry != null)
{
_clientsListView.RefreshItem(entry);
}
}
public void ApplyTheme(bool isDarkMode) => _clientsListView.ApplyTheme(isDarkMode);
public void SetToolTip(Client client, string text)
{
var entry = _clientsListView.GetEntryByClient(client);
if (entry != null)
{
entry.ToolTip = text;
}
}
public ClientListEntry? GetEntry(Client client) => _clientsListView.GetEntryByClient(client);
private void ClientsListViewOnSelectionChanged(object? sender, IReadOnlyList<ClientListEntry> e)
{
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
private void ClientsListViewOnItemDoubleClicked(object? sender, ClientListEntry e)
{
ItemDoubleClicked?.Invoke(this, e.Client);
}
private void ClientsListViewOnFavoriteToggled(object? sender, ClientListEntry e)
{
FavoriteToggled?.Invoke(this, e.Client);
}
}
}
@@ -0,0 +1,369 @@
using Pulsar.Server.Models;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms;
// thanks to Mavamaarten~ for coding this
namespace Pulsar.Server.Controls
{
public class TabPageEventArgs : EventArgs
{
public TabPage TabPage { get; }
public TabPageEventArgs(TabPage tabPage)
{
TabPage = tabPage;
}
}
internal class DotNetBarTabControl : TabControl
{
private bool _darkMode = Settings.DarkMode;
private Dictionary<int, Rectangle> _closeButtonRects = new Dictionary<int, Rectangle>();
private bool _showCloseButtons = true;
public event EventHandler<TabPageEventArgs> TabClosed;
/// <summary>
/// Gets or sets whether close buttons are shown on tabs.
/// </summary>
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public bool ShowCloseButtons
{
get { return _showCloseButtons; }
set
{
if (_showCloseButtons != value)
{
_showCloseButtons = value;
Invalidate();
}
}
}
public DotNetBarTabControl()
{
SetStyle(
ControlStyles.AllPaintingInWmPaint | ControlStyles.ResizeRedraw | ControlStyles.UserPaint |
ControlStyles.DoubleBuffer, true);
SizeMode = TabSizeMode.Fixed;
SelectedIndex = 0;
ShowCloseButtons = false;
MouseClick += DotNetBarTabControl_MouseClick;
}
private void DotNetBarTabControl_MouseClick(object sender, MouseEventArgs e)
{
if (!_showCloseButtons)
return;
foreach (var kvp in _closeButtonRects)
{
if (kvp.Value.Contains(e.Location))
{
int tabIndex = kvp.Key;
if (tabIndex >= 0 && tabIndex < TabCount)
{
TabPage tabPage = TabPages[tabIndex];
TabPages.Remove(tabPage);
TabClosed?.Invoke(this, new TabPageEventArgs(tabPage));
Invalidate();
}
break;
}
}
}
private void DrawTabText(Graphics g, Rectangle rect, string text, Font baseFont, Brush textBrush, bool isSelected)
{
int maxTextWidth = rect.Width - 20;
if (_showCloseButtons)
maxTextWidth -= 20;
SizeF textSize = g.MeasureString(text, baseFont);
Font fontToUse = baseFont;
float scaleFactor = 1.0f;
if (textSize.Width > maxTextWidth)
{
scaleFactor = maxTextWidth / textSize.Width;
float newSize = Math.Max(baseFont.Size * scaleFactor, 7.0f);
fontToUse = new Font(baseFont.FontFamily, newSize, isSelected ? FontStyle.Bold : FontStyle.Regular);
}
else if (isSelected && baseFont.Style != FontStyle.Bold)
{
fontToUse = new Font(baseFont.FontFamily, baseFont.Size, FontStyle.Bold);
}
g.DrawString(text, fontToUse, textBrush, rect, new StringFormat
{
LineAlignment = StringAlignment.Center,
Alignment = StringAlignment.Center,
Trimming = StringTrimming.EllipsisCharacter
});
if (fontToUse != baseFont)
{
fontToUse.Dispose();
}
}
private void DrawCloseButton(Graphics g, Rectangle tabRect, int tabIndex, bool isSelected)
{
if (!_showCloseButtons)
return;
int buttonSize = 16;
int buttonX = tabRect.Right - buttonSize - 5;
int buttonY = tabRect.Top + (tabRect.Height - buttonSize) / 2;
Rectangle closeRect = new Rectangle(buttonX, buttonY, buttonSize, buttonSize);
_closeButtonRects[tabIndex] = closeRect;
Color bgColor = _darkMode
? (isSelected ? Color.FromArgb(90, 90, 90) : Color.FromArgb(60, 60, 60))
: (isSelected ? Color.FromArgb(240, 240, 250) : Color.FromArgb(220, 220, 240));
using (SolidBrush bgBrush = new SolidBrush(bgColor))
{
g.FillEllipse(bgBrush, closeRect);
}
Color xColor = _darkMode
? Color.FromArgb(200, 200, 200)
: Color.FromArgb(100, 100, 100);
using (Pen xPen = new Pen(xColor, 1.5f))
{
g.DrawLine(xPen,
closeRect.Left + 4, closeRect.Top + 4,
closeRect.Right - 4, closeRect.Bottom - 4);
g.DrawLine(xPen,
closeRect.Left + 4, closeRect.Bottom - 4,
closeRect.Right - 4, closeRect.Top + 4);
}
}
protected override void OnPaint(PaintEventArgs e)
{
_closeButtonRects.Clear();
Bitmap b = new Bitmap(Width, Height);
Graphics g = Graphics.FromImage(b);
g.SmoothingMode = SmoothingMode.AntiAlias;
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;
if (_darkMode)
{
DrawDarkMode(g);
}
else
{
DrawLightMode(g);
}
e.Graphics.DrawImage(b, new Point(0, 0));
g.Dispose();
b.Dispose();
}
private void DrawDarkMode(Graphics g)
{
if (!DesignMode && TabCount > 0 && SelectedIndex >= 0)
SelectedTab.BackColor = Color.FromArgb(43, 43, 43);
g.Clear(Color.FromArgb(43, 43, 43));
g.FillRectangle(new SolidBrush(Color.FromArgb(43, 43, 43)),
new Rectangle(0, 0, ItemSize.Height + 4, Height));
g.DrawLine(new Pen(Color.FromArgb(80, 80, 80)), new Point(ItemSize.Height + 3, 0),
new Point(ItemSize.Height + 3, 999));
g.DrawLine(new Pen(Color.FromArgb(80, 80, 80)), new Point(0, Size.Height - 1),
new Point(Width + 3, Size.Height - 1));
for (int i = 0; i <= TabCount - 1; i++)
{
if (i == SelectedIndex)
{
Rectangle x2 = new Rectangle(new Point(GetTabRect(i).Location.X - 2, GetTabRect(i).Location.Y - 2),
new Size(GetTabRect(i).Width + 3, GetTabRect(i).Height - 1));
g.FillRectangle(new SolidBrush(Color.FromArgb(70, 70, 70)), x2);
g.DrawRectangle(new Pen(Color.FromArgb(43, 43, 43)), x2);
g.SmoothingMode = SmoothingMode.HighQuality;
if (ImageList != null)
{
try
{
g.DrawImage(ImageList.Images[TabPages[i].ImageIndex],
new Point(x2.Location.X + 8, x2.Location.Y + 6));
DrawTabText(g, x2, TabPages[i].Text, Font, Brushes.White, true);
}
catch (Exception)
{
DrawTabText(g, x2, TabPages[i].Text, Font, Brushes.White, true);
}
}
else
{
DrawTabText(g, x2, TabPages[i].Text, Font, Brushes.White, true);
}
DrawCloseButton(g, x2, i, true);
}
else
{
Rectangle x2 = new Rectangle(new Point(GetTabRect(i).Location.X - 2, GetTabRect(i).Location.Y - 2),
new Size(GetTabRect(i).Width + 3, GetTabRect(i).Height - 1));
g.FillRectangle(new SolidBrush(Color.FromArgb(43, 43, 43)), x2);
g.DrawLine(new Pen(Color.FromArgb(80, 80, 80)), new Point(x2.Right, x2.Top),
new Point(x2.Right, x2.Bottom));
if (ImageList != null)
{
try
{
g.DrawImage(ImageList.Images[TabPages[i].ImageIndex],
new Point(x2.Location.X + 8, x2.Location.Y + 6));
DrawTabText(g, x2, TabPages[i].Text, Font, Brushes.LightGray, false);
}
catch (Exception)
{
DrawTabText(g, x2, TabPages[i].Text, Font, Brushes.LightGray, false);
}
}
else
{
DrawTabText(g, x2, TabPages[i].Text, Font, Brushes.LightGray, false);
}
DrawCloseButton(g, x2, i, false);
}
}
}
private void DrawLightMode(Graphics g)
{
if (!DesignMode && TabCount > 0 && SelectedIndex >= 0)
SelectedTab.BackColor = SystemColors.Control;
g.Clear(SystemColors.Control);
g.FillRectangle(new SolidBrush(Color.FromArgb(246, 248, 252)),
new Rectangle(0, 0, ItemSize.Height + 4, Height));
g.DrawLine(new Pen(Color.FromArgb(170, 187, 204)), new Point(ItemSize.Height + 3, 0),
new Point(ItemSize.Height + 3, 999));
g.DrawLine(new Pen(Color.FromArgb(170, 187, 204)), new Point(0, Size.Height - 1),
new Point(Width + 3, Size.Height - 1));
for (int i = 0; i <= TabCount - 1; i++)
{
if (i == SelectedIndex)
{
Rectangle x2 = new Rectangle(new Point(GetTabRect(i).Location.X - 2, GetTabRect(i).Location.Y - 2),
new Size(GetTabRect(i).Width + 3, GetTabRect(i).Height - 1));
ColorBlend myBlend = new ColorBlend();
myBlend.Colors = new Color[] { Color.FromArgb(232, 232, 240), Color.FromArgb(232, 232, 240), Color.FromArgb(232, 232, 240) };
myBlend.Positions = new float[] { 0f, 0.5f, 1f };
LinearGradientBrush lgBrush = new LinearGradientBrush(x2, Color.Black, Color.Black, 90f);
lgBrush.InterpolationColors = myBlend;
g.FillRectangle(lgBrush, x2);
g.DrawRectangle(new Pen(Color.FromArgb(170, 187, 204)), x2);
g.SmoothingMode = SmoothingMode.HighQuality;
Point[] p =
{
new Point(ItemSize.Height - 3, GetTabRect(i).Location.Y + 20),
new Point(ItemSize.Height + 4, GetTabRect(i).Location.Y + 14),
new Point(ItemSize.Height + 4, GetTabRect(i).Location.Y + 27)
};
g.FillPolygon(SystemBrushes.Control, p);
g.DrawPolygon(new Pen(Color.FromArgb(170, 187, 204)), p);
if (ImageList != null)
{
try
{
g.DrawImage(ImageList.Images[TabPages[i].ImageIndex],
new Point(x2.Location.X + 8, x2.Location.Y + 6));
DrawTabText(g, x2, TabPages[i].Text, Font, Brushes.Black, true);
}
catch (Exception)
{
DrawTabText(g, x2, TabPages[i].Text, Font, Brushes.Black, true);
}
}
else
{
DrawTabText(g, x2, TabPages[i].Text, Font, Brushes.Black, true);
}
g.DrawLine(new Pen(Color.FromArgb(200, 200, 250)), new Point(x2.Location.X - 1, x2.Location.Y - 1),
new Point(x2.Location.X, x2.Location.Y));
g.DrawLine(new Pen(Color.FromArgb(200, 200, 250)), new Point(x2.Location.X - 1, x2.Bottom - 1),
new Point(x2.Location.X, x2.Bottom));
DrawCloseButton(g, x2, i, true);
}
else
{
Rectangle x2 = new Rectangle(new Point(GetTabRect(i).Location.X - 2, GetTabRect(i).Location.Y - 2),
new Size(GetTabRect(i).Width + 3, GetTabRect(i).Height - 1));
g.FillRectangle(new SolidBrush(Color.FromArgb(246, 248, 252)), x2);
g.DrawLine(new Pen(Color.FromArgb(170, 187, 204)), new Point(x2.Right, x2.Top),
new Point(x2.Right, x2.Bottom));
if (ImageList != null)
{
try
{
g.DrawImage(ImageList.Images[TabPages[i].ImageIndex],
new Point(x2.Location.X + 8, x2.Location.Y + 6));
DrawTabText(g, x2, TabPages[i].Text, Font, Brushes.DimGray, false);
}
catch (Exception)
{
DrawTabText(g, x2, TabPages[i].Text, Font, Brushes.DimGray, false);
}
}
else
{
DrawTabText(g, x2, TabPages[i].Text, Font, Brushes.DimGray, false);
}
DrawCloseButton(g, x2, i, false);
}
}
}
}
}
@@ -0,0 +1,194 @@
using Pulsar.Server.Utilities;
using System;
using System.Diagnostics;
using System.Drawing;
using System.Windows.Forms;
using System.ComponentModel;
namespace Pulsar.Server.Controls
{
public interface IHVNCRapidPictureBox
{
bool Running { get; set; }
Image GetImageSafe { get; set; }
void Start();
void Stop();
void UpdateImage(Bitmap bmp, bool cloneBitmap = false);
}
/// <summary>
/// Custom PictureBox Control designed for rapidly-changing images.
/// </summary>
public class HVNCRapidPictureBox : PictureBox, IRapidPictureBox
{
/// <summary>
/// True if the PictureBox is currently streaming images, else False.
/// </summary>
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public bool Running { get; set; }
/// <summary>
/// Returns the width of the original screen.
/// </summary>
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public int ScreenWidth { get; private set; }
/// <summary>
/// Returns the height of the original screen.
/// </summary>
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public int ScreenHeight { get; private set; }
/// <summary>
/// Provides thread-safe access to the Image of this Picturebox.
/// </summary>
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public Image GetImageSafe
{
get
{
return Image;
}
set
{
lock (_imageLock)
{
Image = value;
}
}
}
/// <summary>
/// The lock object for the Picturebox's image.
/// </summary>
private readonly object _imageLock = new object();
/// <summary>
/// The Stopwatch for internal FPS measuring.
/// </summary>
private Stopwatch _sWatch;
/// <summary>
/// The internal class for FPS measuring.
/// </summary>
private FrameCounter _frameCounter;
/// <summary>
/// Subscribes an Eventhandler to the FrameUpdated event.
/// </summary>
/// <param name="e">The Eventhandler to set.</param>
public void SetFrameUpdatedEvent(FrameUpdatedEventHandler e)
{
_frameCounter.FrameUpdated += e;
}
/// <summary>
/// Unsubscribes an Eventhandler from the FrameUpdated event.
/// </summary>
/// <param name="e">The Eventhandler to remove.</param>
public void UnsetFrameUpdatedEvent(FrameUpdatedEventHandler e)
{
_frameCounter.FrameUpdated -= e;
}
/// <summary>
/// Starts the internal FPS measuring.
/// </summary>
public void Start()
{
_frameCounter = new FrameCounter();
_sWatch = Stopwatch.StartNew();
Running = true;
}
/// <summary>
/// Stops the internal FPS measuring.
/// </summary>
public void Stop()
{
_sWatch?.Stop();
Running = false;
}
/// <summary>
/// Updates the Image of this Picturebox.
/// </summary>
/// <param name="bmp">The new bitmap to use.</param>
/// <param name="cloneBitmap">If True the bitmap will be cloned, else it uses the original bitmap.</param>
public void UpdateImage(Bitmap bmp, bool cloneBitmap)
{
try
{
CountFps();
if ((ScreenWidth != bmp.Width) && (ScreenHeight != bmp.Height))
UpdateScreenSize(bmp.Width, bmp.Height);
lock (_imageLock)
{
// get old image to dispose it correctly
var oldImage = GetImageSafe;
SuspendLayout();
GetImageSafe = cloneBitmap ? (Bitmap)bmp.Clone() : bmp;
ResumeLayout();
oldImage?.Dispose();
}
}
catch (InvalidOperationException)
{
}
catch (Exception)
{
}
}
/// <summary>
/// Constructor, sets Picturebox double-buffered and initializes the Framecounter.
/// </summary>
public HVNCRapidPictureBox()
{
this.SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true);
}
protected override CreateParams CreateParams
{
get
{
CreateParams cp = base.CreateParams;
cp.ExStyle |= 0x02000000; // Turn on WS_EX_COMPOSITED
return cp;
}
}
protected override void OnPaint(PaintEventArgs pe)
{
lock (_imageLock)
{
if (GetImageSafe != null)
{
pe.Graphics.DrawImage(GetImageSafe, Location);
}
}
}
private void UpdateScreenSize(int newWidth, int newHeight)
{
ScreenWidth = newWidth;
ScreenHeight = newHeight;
}
private void CountFps()
{
var deltaTime = (float)_sWatch.Elapsed.TotalSeconds;
_sWatch = Stopwatch.StartNew();
_frameCounter.Update(deltaTime);
}
}
}
@@ -0,0 +1,47 @@
using System.Windows.Forms;
using System.Windows.Forms.Integration;
using Pulsar.Server.Controls.Wpf;
using Pulsar.Server.Statistics;
#nullable enable
namespace Pulsar.Server.Controls
{
public sealed class HeatMapElementHost : ElementHost
{
private readonly HeatMapView _heatMapView;
private ClientGeoSnapshot? _lastSnapshot;
public HeatMapElementHost()
{
_heatMapView = new HeatMapView();
Child = _heatMapView;
Dock = DockStyle.Fill;
}
public void ShowLoading()
{
_heatMapView.ShowLoading();
}
public void ShowError(string message)
{
_heatMapView.ShowError(message);
}
public void UpdateSnapshot(ClientGeoSnapshot snapshot)
{
_lastSnapshot = snapshot;
_heatMapView.UpdateSnapshot(snapshot);
}
public void ApplyTheme(bool isDarkMode)
{
_heatMapView.ApplyTheme(isDarkMode);
if (_lastSnapshot != null && !_lastSnapshot.HasError)
{
_heatMapView.UpdateSnapshot(_lastSnapshot);
}
}
}
}
@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
namespace Pulsar.Server.Controls.HexEditor
{
public class ByteCollection
{
private List<byte> _bytes;
#region Properties
public int Length
{
get { return _bytes.Count; }
}
#endregion
#region Constructor
public ByteCollection()
{
_bytes = new List<byte>();
}
public ByteCollection(byte[] bytes)
{
_bytes = new List<byte>(bytes);
}
#endregion
#region Methods
public void Add(byte item)
{
_bytes.Add(item);
}
public void Insert(int index, byte item)
{
_bytes.Insert(index, item);
}
public void Remove(byte item)
{
_bytes.Remove(item);
}
public void RemoveAt(int index)
{
_bytes.RemoveAt(index);
}
public void RemoveRange(int startIndex, int count)
{
_bytes.RemoveRange(startIndex, count);
}
public byte GetAt(int index)
{
return _bytes[index];
}
public void SetAt(int index, byte item)
{
_bytes[index] = item;
}
public char GetCharAt(int index)
{
return Convert.ToChar(_bytes[index]);
}
public byte[] ToArray()
{
return _bytes.ToArray();
}
#endregion
}
}
+232
View File
@@ -0,0 +1,232 @@
using System;
using System.Drawing;
using System.Runtime.InteropServices;
namespace Pulsar.Server.Controls.HexEditor
{
public class Caret
{
#region Field
/// <summary>
/// Contains the start index
/// where the caret started
/// </summary>
int _startIndex;
/// <summary>
/// Contains the end index
/// where the caret is
/// currently located
/// </summary>
int _endIndex;
/// <summary>
/// Tells if the given caret
/// is active in the controller
/// (control is in focus)
/// </summary>
bool _isCaretActive;
/// <summary>
/// Tells if the caret is
/// currently hidden or
/// not (out of view)
/// </summary>
bool _isCaretHidden;
/// <summary>
/// Holds the actual position
/// of the caret
/// </summary>
Point _location;
private HexEditor _editor;
#endregion
#region Properties
public int SelectionStart
{
get
{
if (_endIndex < _startIndex)
return _endIndex;
return _startIndex;
}
}
public int SelectionLength
{
get
{
if (_endIndex < _startIndex)
return _startIndex - _endIndex;
return _endIndex - _startIndex;
}
}
public bool Focused
{
get { return _isCaretActive; }
}
public int CurrentIndex
{
get { return _endIndex; }
}
public Point Location
{
get { return _location; }
}
#endregion
#region EventHandlers
public event EventHandler SelectionStartChanged;
public event EventHandler SelectionLengthChanged;
#endregion
#region Constructor
public Caret(HexEditor editor)
{
_editor = editor;
_isCaretActive = false;
_startIndex = 0;
_endIndex = 0;
_isCaretHidden = true;
_location = new Point(0, 0);
}
#endregion
#region Methods
#region Caret
private bool Create(IntPtr hWHandler)
{
if (!_isCaretActive)
{
_isCaretActive = true;
return CreateCaret(hWHandler, IntPtr.Zero, 0, (int)_editor.CharSize.Height - 2);
}
return false;
}
private bool Show(IntPtr hWnd)
{
if (_isCaretActive)
{
_isCaretHidden = false;
return ShowCaret(hWnd);
}
return false;
}
public bool Hide(IntPtr hWnd)
{
if (_isCaretActive && !_isCaretHidden)
{
_isCaretHidden = true;
return HideCaret(hWnd);
}
return false;
}
public bool Destroy()
{
if (_isCaretActive)
{
_isCaretActive = false;
DeSelect();
DestroyCaret();
}
return false;
}
#endregion
public void SetStartIndex(int index)
{
_startIndex = index;
_endIndex = _startIndex;
if (SelectionStartChanged != null)
SelectionStartChanged(this, EventArgs.Empty);
if (SelectionLengthChanged != null)
SelectionLengthChanged(this, EventArgs.Empty);
}
public void SetEndIndex(int index)
{
_endIndex = index;
if (SelectionStartChanged != null)
SelectionStartChanged(this, EventArgs.Empty);
if (SelectionLengthChanged != null)
SelectionLengthChanged(this, EventArgs.Empty);
}
public void SetCaretLocation(Point start)
{
Create(_editor.Handle);
_location = start;
SetCaretPos(_location.X, _location.Y);
Show(_editor.Handle);
}
public bool IsSelected(int byteIndex)
{
return (SelectionStart <= byteIndex && byteIndex < (SelectionStart + SelectionLength));
}
private void DeSelect()
{
if (_endIndex < _startIndex)
_startIndex = _endIndex;
else
_endIndex = _startIndex;
if (SelectionStartChanged != null)
SelectionStartChanged(this, EventArgs.Empty);
if (SelectionLengthChanged != null)
SelectionLengthChanged(this, EventArgs.Empty);
}
#endregion
#region Caret import
[DllImport("user32.dll", SetLastError = true)]
static extern bool CreateCaret(IntPtr hWnd, IntPtr hBitmap, int nWidth, int nHeight);
[DllImport("user32.dll", SetLastError = true)]
static extern bool DestroyCaret();
[DllImport("user32.dll", SetLastError = true)]
static extern bool SetCaretPos(int x, int y);
[DllImport("user32.dll", SetLastError = true)]
static extern bool ShowCaret(IntPtr hWnd);
[DllImport("user32.dll", SetLastError = true)]
static extern bool HideCaret(IntPtr hWnd);
#endregion
}
}
@@ -0,0 +1,177 @@
using System;
using System.Drawing;
using System.Windows.Forms;
namespace Pulsar.Server.Controls.HexEditor
{
public class EditView : IKeyMouseEventHandler
{
#region Fields
/// <summary>
/// Contains the handler for the hex
/// view.
/// </summary>
private HexViewHandler _hexView;
/// <summary>
/// Contains the handler for the
/// string view
/// </summary>
private StringViewHandler _stringView;
private HexEditor _editor;
#endregion
#region Contructor
public EditView(HexEditor editor)
{
_editor = editor;
_hexView = new HexViewHandler(editor);
_stringView = new StringViewHandler(editor);
}
#endregion
#region KeyMouseEvent
#region Key
public void OnKeyPress(KeyPressEventArgs e)
{
if (InHexView(_editor.CaretPosX))
{
_hexView.OnKeyPress(e);
}
else
{
_stringView.OnKeyPress(e);
}
}
public void OnKeyDown(KeyEventArgs e)
{
if (InHexView(_editor.CaretPosX))
{
_hexView.OnKeyDown(e);
}
else
{
_stringView.OnKeyDown(e);
}
}
public void OnKeyUp(KeyEventArgs e)
{ /* ... */ }
#endregion
#region Mouse
public void OnMouseDown(MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
if (InHexView(e.X))
{
_hexView.OnMouseDown(e.X, e.Y);
}
else
{
_stringView.OnMouseDown(e.X, e.Y);
}
}
}
public void OnMouseDragged(MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
if (InHexView(e.X))
{
_hexView.OnMouseDragged(e.X, e.Y);
}
else
{
_stringView.OnMouseDragged(e.X, e.Y);
}
}
}
public void OnMouseUp(MouseEventArgs e)
{ /* ... */ }
public void OnMouseDoubleClick(MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
if (InHexView(e.X))
{
_hexView.OnMouseDoubleClick();
}
else
{
_stringView.OnMouseDoubleClick();
}
}
}
#endregion
#region Focus
public void OnGotFocus(EventArgs e)
{
if (InHexView(_editor.CaretPosX))
_hexView.Focus();
else
_stringView.Focus();
}
#endregion
#endregion
#region UpdateActions
public void SetLowerCase()
{
_hexView.SetLowerCase();
}
public void SetUpperCase()
{
_hexView.SetUpperCase();
}
public void Update(int startPositionX, Rectangle area)
{
_hexView.Update(startPositionX, area);
_stringView.Update(_hexView.MaxWidth, area);
}
#endregion
#region PaintActions
public void Paint(Graphics g, int startIndex, int endIndex)
{
for (int i = 0; (i + startIndex) < endIndex; i++)
{
_hexView.Paint(g, i, startIndex);
_stringView.Paint(g, i, startIndex);
}
}
#endregion
#region Misc
private bool InHexView(int x)
{
return (x < (_hexView.MaxWidth + _editor.EntityMargin - 2));
}
#endregion
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,442 @@
using System;
using System.Drawing;
using System.Windows.Forms;
namespace Pulsar.Server.Controls.HexEditor
{
public class HexViewHandler
{
#region Fields
bool _isEditing;
/// <summary>
/// Contains info about how to
/// present the hex values
/// (Upper or Lower case)
/// </summary>
string _hexType = "X2";
/// <summary>
/// Contains the boundary for one single
/// hexa value that is visible
/// </summary>
Rectangle _recHexValue;
/// <summary>
/// Contains the format of the hexadecimal
/// strings that are presented
/// </summary>
StringFormat _stringFormat;
private HexEditor _editor;
#endregion
#region Properties
public int MaxWidth
{
get { return _recHexValue.X + (_recHexValue.Width * _editor.BytesPerLine); }
}
#endregion
#region Constructor
public HexViewHandler(HexEditor editor)
{
_editor = editor;
//Set String format for the hex values
_stringFormat = new StringFormat(StringFormat.GenericTypographic);
_stringFormat.Alignment = StringAlignment.Center;
_stringFormat.LineAlignment = StringAlignment.Center;
}
#endregion
#region Method
#region KeyMouseEvents
#region KeyEvents
public void OnKeyPress(KeyPressEventArgs e)
{
if (IsHex(e.KeyChar))
HandleUserInput(e.KeyChar);
}
public void OnKeyDown(KeyEventArgs e)
{
if (e.KeyCode == Keys.Delete || e.KeyCode == Keys.Back)
{
if (_editor.SelectionLength > 0)
{
//Remove the selected bytes
HandleUserRemove();
int index = _editor.CaretIndex;
Point newLocation = GetCaretLocation(index);
_editor.SetCaretStart(index, newLocation);
}
else if (_editor.CaretIndex < _editor.LastVisibleByte && e.KeyCode == Keys.Delete)
{
//Remove the byte after the caret
_editor.RemoveByteAt(_editor.CaretIndex);
Point newLocation = GetCaretLocation(_editor.CaretIndex);
_editor.SetCaretStart(_editor.CaretIndex, newLocation);
}
else if (_editor.CaretIndex > 0 && e.KeyCode == Keys.Back)
{
//Remove byte before the caret
int index = _editor.CaretIndex - 1;
if (_isEditing)
{
//Remove the byte that is being edited
index = _editor.CaretIndex;
}
_editor.RemoveByteAt(index);
Point newLocation = GetCaretLocation(index);
_editor.SetCaretStart(index, newLocation);
}
_isEditing = false;
}
else if (e.KeyCode == Keys.Up && (_editor.CaretIndex - _editor.BytesPerLine) >= 0)
{
int index = _editor.CaretIndex - _editor.BytesPerLine;
//Check ig caret is att the end of the line
if (index % _editor.BytesPerLine == 0 && _editor.CaretPosX >= _recHexValue.X + _recHexValue.Width * _editor.BytesPerLine)
{
Point position = new Point(_editor.CaretPosX, _editor.CaretPosY - _recHexValue.Height);
//check that this is not the last row (nothing above)
if (index == 0)
{
//Last row do not change index and position
position = new Point(_editor.CaretPosX, _editor.CaretPosY);
index = _editor.BytesPerLine;
}
if (e.Shift)
_editor.SetCaretEnd(index, position);
else
_editor.SetCaretStart(index, position);
_isEditing = false;
}
else
{
HandleArrowKeys(index, e.Shift);
}
}
else if (e.KeyCode == Keys.Down && (_editor.CaretIndex - 1) / _editor.BytesPerLine < _editor.HexTableLength / _editor.BytesPerLine)
{
int index = _editor.CaretIndex + _editor.BytesPerLine;
if (index > _editor.HexTableLength)
{
index = _editor.HexTableLength;
HandleArrowKeys(index, e.Shift);
}
else
{
Point position = new Point(_editor.CaretPosX, _editor.CaretPosY + _recHexValue.Height);
if (e.Shift)
_editor.SetCaretEnd(index, position);
else
_editor.SetCaretStart(index, position);
_isEditing = false;
}
}
else if (e.KeyCode == Keys.Left && (_editor.CaretIndex - 1) >= 0)
{
int index = _editor.CaretIndex - 1;
HandleArrowKeys(index, e.Shift);
}
else if (e.KeyCode == Keys.Right && (_editor.CaretIndex + 1) <= _editor.HexTableLength)
{
int index = _editor.CaretIndex + 1;
HandleArrowKeys(index, e.Shift);
}
}
public void HandleArrowKeys(int index, bool isShiftDown)
{
Point position = GetCaretLocation(index);
if (isShiftDown)
_editor.SetCaretEnd(index, position);
else
_editor.SetCaretStart(index, position);
_isEditing = false;
}
#endregion
#region MouseEvent
public void OnMouseDown(int x, int y)
{
int iX = (x - _recHexValue.X) / _recHexValue.Width;
int iY = (y - _recHexValue.Y) / _recHexValue.Height;
//Check that values are good
iX = iX > _editor.BytesPerLine ? _editor.BytesPerLine : iX;
iX = iX < 0 ? 0 : iX;
iY = iY > _editor.MaxBytesV ? _editor.MaxBytesV : iY;
iY = iY < 0 ? 0 : iY;
//Make sure values are withing the given bounds
if ((_editor.LastVisibleByte - _editor.FirstVisibleByte) / _editor.BytesPerLine <= iY)
{
//Check that column is not greater than max
if ((_editor.LastVisibleByte - _editor.FirstVisibleByte) % _editor.BytesPerLine <= iX)
{
iX = (_editor.LastVisibleByte - _editor.FirstVisibleByte) % _editor.BytesPerLine;
}
iY = (_editor.LastVisibleByte - _editor.FirstVisibleByte) / _editor.BytesPerLine;
}
//Get the smallest possible location (do not want to exceed the max)
int index = Math.Min(_editor.LastVisibleByte, _editor.FirstVisibleByte + iX + (iY * _editor.BytesPerLine));
int xPos = (iX * _recHexValue.Width) + _recHexValue.X;
int yPos = (iY * _recHexValue.Height) + _recHexValue.Y;
_editor.SetCaretStart(index, new Point(xPos, yPos));
_isEditing = false;
}
public void OnMouseDragged(int x, int y)
{
int iX = (x - _recHexValue.X) / _recHexValue.Width;
int iY = (y - _recHexValue.Y) / _recHexValue.Height;
//Check that values are good
iX = iX > _editor.BytesPerLine ? _editor.BytesPerLine : iX;
iX = iX < 0 ? 0 : iX;
iY = iY > _editor.MaxBytesV ? _editor.MaxBytesV : iY;
if (_editor.FirstVisibleByte > 0)
{
iY = iY < 0 ? -1 : iY;
}
else
{
iY = iY < 0 ? 0 : iY;
}
//Make sure values are withing the given bounds
if ((_editor.LastVisibleByte - _editor.FirstVisibleByte) / _editor.BytesPerLine <= iY)
{
//Check that column is not greater than max
if ((_editor.LastVisibleByte - _editor.FirstVisibleByte) % _editor.BytesPerLine <= iX)
{
iX = (_editor.LastVisibleByte - _editor.FirstVisibleByte) % _editor.BytesPerLine;
}
iY = (_editor.LastVisibleByte - _editor.FirstVisibleByte) / _editor.BytesPerLine;
}
//Get the smallest possible location (do not want to exceed the max)
int index = Math.Min(_editor.LastVisibleByte, _editor.FirstVisibleByte + iX + (iY * _editor.BytesPerLine));
int xPos = (iX * _recHexValue.Width) + _recHexValue.X;
int yPos = (iY * _recHexValue.Height) + _recHexValue.Y;
_editor.SetCaretEnd(index, new Point(xPos, yPos));
}
public void OnMouseDoubleClick()
{
if (_editor.CaretIndex < _editor.LastVisibleByte)
{
int index = _editor.CaretIndex + 1;
Point newLocation = GetCaretLocation(index);
_editor.SetCaretEnd(index, newLocation);
}
}
#endregion
#endregion
#region PaintMethod
public void Update(int startPositionX, Rectangle area)
{
_recHexValue = new Rectangle(
startPositionX,
area.Y,
(int)(_editor.CharSize.Width * 3),
(int)(_editor.CharSize.Height) - 2
);
_recHexValue.X += _editor.EntityMargin;
}
public void Paint(Graphics g, int index, int startIndex)
{
Point columnAndRow = GetByteColumnAndRow(index);
if (_editor.IsSelected(index + startIndex))
{
PaintByteAsSelected(g, columnAndRow, (index + startIndex));
}
else
{
PaintByte(g, columnAndRow, (index + startIndex));
}
}
private void PaintByteAsSelected(Graphics g, Point point, int index)
{
SolidBrush backBrush = new SolidBrush(_editor.SelectionBackColor);
SolidBrush textBrush = new SolidBrush(_editor.SelectionForeColor);
RectangleF drawSurface = GetBound(point);
string hexValue = _editor.GetByte(index).ToString(_hexType);
g.FillRectangle(backBrush, drawSurface);
g.DrawString(hexValue, _editor.Font, textBrush, drawSurface, _stringFormat);
}
private void PaintByte(Graphics g, Point point, int index)
{
SolidBrush brush = new SolidBrush(_editor.ForeColor);
RectangleF drawSurface = GetBound(point);
string hexValue = _editor.GetByte(index).ToString(_hexType);
g.DrawString(hexValue, _editor.Font, brush, drawSurface, _stringFormat);
}
#endregion
public void SetLowerCase()
{
_hexType = "x2";
}
public void SetUpperCase()
{
_hexType = "X2";
}
public void Focus()
{
int index = _editor.CaretIndex;
Point location = GetCaretLocation(index);
_editor.SetCaretStart(index, location);
}
#endregion
#region Caret
/// <summary>
/// Get the caret current location
/// in the given bound.
/// </summary>
private Point GetCaretLocation(int index)
{
int xPos = _recHexValue.X + (_recHexValue.Width * (index % _editor.BytesPerLine));
int yPos = _recHexValue.Y + (_recHexValue.Height * ((index - (_editor.FirstVisibleByte + index % _editor.BytesPerLine)) / _editor.BytesPerLine));
Point ret = new Point(xPos, yPos);
return ret;
}
#endregion
#region Misc
private void HandleUserRemove()
{
//Calculate where to position the caret after the removal
int index = _editor.SelectionStart;
Point position = GetCaretLocation(index);
//Remove all of the selected bytes
_editor.RemoveSelectedBytes();
//Set the new position of the caret
_editor.SetCaretStart(index, position);
}
private void HandleUserInput(char key)
{
if (!_editor.CaretFocused)
return;
//Perform overwrite
HandleUserRemove();
if (_isEditing)
{
//Editing has already started, should change the second nibble
_isEditing = false;
//Load old bytes to allow change
byte oldByte = _editor.GetByte(_editor.CaretIndex);
//Append the new nibble
oldByte += Convert.ToByte(key.ToString(), 16);
_editor.SetByte(_editor.CaretIndex, oldByte);
//Relocate the caret
int index = _editor.CaretIndex + 1;
Point newLocation = GetCaretLocation(index);
_editor.SetCaretStart(index, newLocation);
}
else
{
//Begin new edit phase
_isEditing = true;
string hexByte = key.ToString() + "0";
byte newByte = Convert.ToByte(hexByte, 16);
if (_editor.HexTable.Length <= 0)
{
_editor.AppendByte(newByte);
}
else
{
_editor.InsertByte(_editor.CaretIndex, newByte);
}
//Relocate the caret to the middle of the hex value (provide illusion of editing the second value)
int xPos = (_recHexValue.X + (_recHexValue.Width * ((_editor.CaretIndex) % _editor.BytesPerLine)) + (_recHexValue.Width / 2));
int yPos = _recHexValue.Y + (_recHexValue.Height * ((_editor.CaretIndex - (_editor.FirstVisibleByte + _editor.CaretIndex % _editor.BytesPerLine)) / _editor.BytesPerLine));
_editor.SetCaretStart(_editor.CaretIndex, new Point(xPos, yPos));
}
}
private Point GetByteColumnAndRow(int index)
{
int column = index % _editor.BytesPerLine;
int row = index / _editor.BytesPerLine;
Point ret = new Point(column, row);
return ret;
}
private RectangleF GetBound(Point point)
{
RectangleF ret = new RectangleF(
_recHexValue.X + (point.X * _recHexValue.Width),
_recHexValue.Y + (point.Y * _recHexValue.Height),
_recHexValue.Width,
_recHexValue.Height
);
return ret;
}
private bool IsHex(char c)
{
return (c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F') ||
Char.IsDigit(c);
}
#endregion
}
}
@@ -0,0 +1,24 @@
using System;
using System.Windows.Forms;
namespace Pulsar.Server.Controls.HexEditor
{
public interface IKeyMouseEventHandler
{
void OnKeyPress(KeyPressEventArgs e);
void OnKeyDown(KeyEventArgs e);
void OnKeyUp(KeyEventArgs e);
void OnMouseDown(MouseEventArgs e);
void OnMouseDragged(MouseEventArgs e);
void OnMouseUp(MouseEventArgs e);
void OnMouseDoubleClick(MouseEventArgs e);
void OnGotFocus(EventArgs e);
}
}
@@ -0,0 +1,382 @@
using System;
using System.Drawing;
using System.Windows.Forms;
namespace Pulsar.Server.Controls.HexEditor
{
public class StringViewHandler
{
#region Field
/// <summary>
/// Contains the boundary of
/// a single line
/// </summary>
Rectangle _recStringView;
/// <summary>
/// Contains the format of the
/// string to be used in the
/// string view
/// </summary>
StringFormat _stringFormat;
private HexEditor _editor;
#endregion
#region Properties
public int MaxWidth
{
get { return _recStringView.X + _recStringView.Width; }
}
#endregion
#region Constructor
public StringViewHandler(HexEditor editor)
{
_editor = editor;
//Set String format for the values
_stringFormat = new StringFormat(StringFormat.GenericTypographic);
_stringFormat.Alignment = StringAlignment.Center;
_stringFormat.LineAlignment = StringAlignment.Center;
}
#endregion
#region KeyMouseEvents
#region Key
public void OnKeyPress(KeyPressEventArgs e)
{
if (!Char.IsControl(e.KeyChar))
{
HandleUserInput(e.KeyChar);
}
}
public void OnKeyDown(KeyEventArgs e)
{
if (e.KeyCode == Keys.Delete || e.KeyCode == Keys.Back)
{
if (_editor.SelectionLength > 0)
{
//Remove the selected bytes
HandleUserRemove();
int index = _editor.CaretIndex;
Point newLocation = GetCaretLocation(index);
_editor.SetCaretStart(index, newLocation);
}
else if (_editor.CaretIndex < _editor.LastVisibleByte && e.KeyCode == Keys.Delete)
{
//Remove the byte after the caret
_editor.RemoveByteAt(_editor.CaretIndex);
Point newLocation = GetCaretLocation(_editor.CaretIndex);
_editor.SetCaretStart(_editor.CaretIndex, newLocation);
}
else if (_editor.CaretIndex > 0 && e.KeyCode == Keys.Back)
{
//Remove byte before the caret
int index = _editor.CaretIndex - 1;
_editor.RemoveByteAt(index);
Point newLocation = GetCaretLocation(index);
_editor.SetCaretStart(index, newLocation);
}
}
else if (e.KeyCode == Keys.Up && (_editor.CaretIndex - _editor.BytesPerLine) >= 0)
{
int index = _editor.CaretIndex - _editor.BytesPerLine;
//Check ig caret is att the end of the line
if (index % _editor.BytesPerLine == 0 && _editor.CaretPosX >= _recStringView.X + _recStringView.Width)
{
Point position = new Point(_editor.CaretPosX, _editor.CaretPosY - _recStringView.Height);
//check that this is not the last row (nothing above)
if (index == 0)
{
//Last row do not change index and position
position = new Point(_editor.CaretPosX, _editor.CaretPosY);
index = _editor.BytesPerLine;
}
if (e.Shift)
_editor.SetCaretEnd(index, position);
else
_editor.SetCaretStart(index, position);
}
else
{
HandleArrowKeys(index, e.Shift);
}
}
else if (e.KeyCode == Keys.Down && (_editor.CaretIndex - 1) / _editor.BytesPerLine < _editor.HexTableLength / _editor.BytesPerLine)
{
int index = _editor.CaretIndex + _editor.BytesPerLine;
if (index > _editor.HexTableLength)
{
index = _editor.HexTableLength;
HandleArrowKeys(index, e.Shift);
}
else
{
Point position = new Point(_editor.CaretPosX, _editor.CaretPosY + _recStringView.Height);
if (e.Shift)
_editor.SetCaretEnd(index, position);
else
_editor.SetCaretStart(index, position);
}
}
else if (e.KeyCode == Keys.Left && (_editor.CaretIndex - 1) >= 0)
{
int index = _editor.CaretIndex - 1;
HandleArrowKeys(index, e.Shift);
}
else if (e.KeyCode == Keys.Right && (_editor.CaretIndex + 1) <= _editor.LastVisibleByte)
{
int index = _editor.CaretIndex + 1;
HandleArrowKeys(index, e.Shift);
}
}
public void HandleArrowKeys(int index, bool isShiftDown)
{
Point newLocation = GetCaretLocation(index);
if (isShiftDown)
_editor.SetCaretEnd(index, newLocation);
else
_editor.SetCaretStart(index, newLocation);
}
#endregion
#region Mouse
public void OnMouseDown(int x, int y)
{
int iX = (x - _recStringView.X) / (int)_editor.CharSize.Width;
int iY = (y - _recStringView.Y) / _recStringView.Height;
//Check that values are good
iX = iX > _editor.BytesPerLine ? _editor.BytesPerLine : iX;
iX = iX < 0 ? 0 : iX;
iY = iY > _editor.MaxBytesV ? _editor.MaxBytesV : iY;
iY = iY < 0 ? 0 : iY;
//Make sure values are withing the given bounds
if ((_editor.LastVisibleByte - _editor.FirstVisibleByte) / _editor.BytesPerLine <= iY)
{
//Check that column is not greater than max
if ((_editor.LastVisibleByte - _editor.FirstVisibleByte) % _editor.BytesPerLine <= iX)
{
iX = (_editor.LastVisibleByte - _editor.FirstVisibleByte) % _editor.BytesPerLine;
}
iY = (_editor.LastVisibleByte - _editor.FirstVisibleByte) / _editor.BytesPerLine;
}
//Get the smallest possible location (do not want to exceed the max)
int index = Math.Min(_editor.LastVisibleByte, _editor.FirstVisibleByte + iX + iY * _editor.BytesPerLine);
int xPos = (iX * (int)_editor.CharSize.Width) + _recStringView.X;
int yPos = (iY * _recStringView.Height) + _recStringView.Y;
_editor.SetCaretStart(index, new Point(xPos, yPos));
}
public void OnMouseDragged(int x, int y)
{
int iX = (x - _recStringView.X) / (int)_editor.CharSize.Width;
int iY = (y - _recStringView.Y) / _recStringView.Height;
//Check that values are good
iX = iX > _editor.BytesPerLine ? _editor.BytesPerLine : iX;
iX = iX < 0 ? 0 : iX;
iY = iY > _editor.MaxBytesV ? _editor.MaxBytesV : iY;
if (_editor.FirstVisibleByte > 0)
{
iY = iY < 0 ? -1 : iY;
}
else
{
iY = iY < 0 ? 0 : iY;
}
//Make sure values are withing the given bounds
if ((_editor.LastVisibleByte - _editor.FirstVisibleByte) / _editor.BytesPerLine <= iY)
{
//Check that column is not greater than max
if ((_editor.LastVisibleByte - _editor.FirstVisibleByte) % _editor.BytesPerLine <= iX)
{
iX = (_editor.LastVisibleByte - _editor.FirstVisibleByte) % _editor.BytesPerLine;
}
iY = (_editor.LastVisibleByte - _editor.FirstVisibleByte) / _editor.BytesPerLine;
}
//Get the smallest possible location (do not want to exceed the max)
int index = Math.Min(_editor.LastVisibleByte, _editor.FirstVisibleByte + iX + iY * _editor.BytesPerLine);
int xPos = (iX * (int)_editor.CharSize.Width) + _recStringView.X;
int yPos = (iY * _recStringView.Height) + _recStringView.Y;
_editor.SetCaretEnd(index, new Point(xPos, yPos));
}
public void OnMouseDoubleClick()
{
if (_editor.CaretIndex < _editor.LastVisibleByte)
{
int index = _editor.CaretIndex + 1;
Point newLocation = GetCaretLocation(index);
_editor.SetCaretEnd(index, newLocation);
}
}
#endregion
#region Focus
public void Focus()
{
int index = _editor.CaretIndex;
Point location = GetCaretLocation(index);
_editor.SetCaretStart(index, location);
}
#endregion
#endregion
#region Paint
public void Update(int startPositionX, Rectangle area)
{
_recStringView = new Rectangle(
startPositionX,
area.Y,
(int)(_editor.CharSize.Width * _editor.BytesPerLine),
(int)(_editor.CharSize.Height) - 2
);
_recStringView.X += _editor.EntityMargin;
}
public void Paint(Graphics g, int index, int startIndex)
{
Point columnAndRow = GetByteColumnAndRow(index);
if (_editor.IsSelected(index + startIndex))
{
PaintByteAsSelected(g, columnAndRow, (index + startIndex));
}
else
{
PaintByte(g, columnAndRow, (index + startIndex));
}
}
private void PaintByteAsSelected(Graphics g, Point point, int index)
{
SolidBrush backBrush = new SolidBrush(_editor.SelectionBackColor);
SolidBrush textBrush = new SolidBrush(_editor.SelectionForeColor);
RectangleF drawSurface = GetBound(point);
char value = _editor.GetByteAsChar(index);
string strValue = (Char.IsControl(value) ? "." : value.ToString());
g.FillRectangle(backBrush, drawSurface);
g.DrawString(strValue, _editor.Font, textBrush, drawSurface, _stringFormat);
}
private void PaintByte(Graphics g, Point point, int index)
{
SolidBrush brush = new SolidBrush(_editor.ForeColor);
RectangleF drawLocation = GetBound(point);
char value = _editor.GetByteAsChar(index);
string strValue = (Char.IsControl(value) ? "." : value.ToString());
g.DrawString(strValue, _editor.Font, brush, drawLocation, _stringFormat);
}
#endregion
#region Caret
/// <summary>
/// Get the caret current location
/// in the given bound.
/// </summary>
private Point GetCaretLocation(int index)
{
int xPos = _recStringView.X + ((int)_editor.CharSize.Width * (index % _editor.BytesPerLine));
int yPos = _recStringView.Y + ((int)_recStringView.Height * ((index - (_editor.FirstVisibleByte + index % _editor.BytesPerLine)) / _editor.BytesPerLine));
Point ret = new Point(xPos, yPos);
return ret;
}
#endregion
#region Misc
private void HandleUserRemove()
{
//Calculate where to position the caret after the removal
int index = _editor.SelectionStart;
Point position = GetCaretLocation(index);
//Remove all of the selected bytes
_editor.RemoveSelectedBytes();
//Set the new position of the caret
_editor.SetCaretStart(index, position);
}
private void HandleUserInput(char key)
{
if (!_editor.CaretFocused)
return;
HandleUserRemove();
byte newByte = Convert.ToByte(key);
if (_editor.HexTableLength <= 0)
_editor.AppendByte(newByte);
else
_editor.InsertByte(_editor.CaretIndex, newByte);
int index = _editor.CaretIndex + 1;
Point newLocation = GetCaretLocation(index);
_editor.SetCaretStart(index, newLocation);
}
private Point GetByteColumnAndRow(int index)
{
int column = index % _editor.BytesPerLine;
int row = index / _editor.BytesPerLine;
Point ret = new Point(column, row);
return ret;
}
private RectangleF GetBound(Point point)
{
RectangleF ret = new RectangleF(
_recStringView.X + (point.X * (int)_editor.CharSize.Width),
_recStringView.Y + (point.Y * _recStringView.Height),
_editor.CharSize.Width,
_recStringView.Height
);
return ret;
}
#endregion
}
}
+54
View File
@@ -0,0 +1,54 @@
using System;
using System.Drawing;
using System.Windows.Forms;
namespace Pulsar.Server.Controls
{
public static class InputBox
{
public static DialogResult Show(string title, string promptText, ref string value)
{
DialogResult dialogResult = DialogResult.Cancel;
using (var form = new Form())
{
Label label = new Label();
TextBox textBox = new TextBox();
Button buttonOk = new Button();
Button buttonCancel = new Button();
form.Text = title;
label.Text = promptText;
textBox.Text = value;
buttonOk.Text = "OK";
buttonCancel.Text = "Cancel";
buttonOk.DialogResult = DialogResult.OK;
buttonCancel.DialogResult = DialogResult.Cancel;
label.SetBounds(9, 20, 372, 13);
textBox.SetBounds(12, 36, 372, 20);
buttonOk.SetBounds(228, 72, 75, 23);
buttonCancel.SetBounds(309, 72, 75, 23);
label.AutoSize = true;
textBox.Anchor = textBox.Anchor | AnchorStyles.Right;
buttonOk.Anchor = AnchorStyles.Bottom | AnchorStyles.Right;
buttonCancel.Anchor = AnchorStyles.Bottom | AnchorStyles.Right;
form.ClientSize = new Size(396, 107);
form.Controls.AddRange(new Control[] { label, textBox, buttonOk, buttonCancel });
form.ClientSize = new Size(Math.Max(300, label.Right + 10), form.ClientSize.Height);
form.FormBorderStyle = FormBorderStyle.FixedDialog;
form.StartPosition = FormStartPosition.CenterScreen;
form.MinimizeBox = false;
form.MaximizeBox = false;
form.AcceptButton = buttonOk;
form.CancelButton = buttonCancel;
dialogResult = form.ShowDialog();
value = textBox.Text;
}
return dialogResult;
}
}
}
+52
View File
@@ -0,0 +1,52 @@
using Pulsar.Server.Models;
using System.ComponentModel;
using System.Drawing;
using System.Windows.Forms;
namespace Pulsar.Server.Controls
{
public class Line : Control
{
public enum Alignment
{
Horizontal,
Vertical
}
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public Alignment LineAlignment { get; set; }
public Line()
{
this.TabStop = false;
this.BackColor = GetBackgroundColor();
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
e.Graphics.DrawLine(new Pen(new SolidBrush(Color.LightGray)), new Point(5, 5),
LineAlignment == Alignment.Horizontal ? new Point(500, 5) : new Point(5, 500));
}
protected override void OnPaintBackground(PaintEventArgs e)
{
using (var brush = new SolidBrush(GetBackgroundColor()))
{
e.Graphics.FillRectangle(brush, ClientRectangle);
}
}
private Color GetBackgroundColor()
{
if (Settings.DarkMode)
{
return Color.FromArgb(43, 43, 43);
}
else
{
return this.BackColor;
}
}
}
}
+149
View File
@@ -0,0 +1,149 @@
using Pulsar.Common.Helpers;
using Pulsar.Server.Helper;
using Pulsar.Server.Utilities;
using System;
using System.ComponentModel;
using System.Drawing;
using System.Windows.Forms;
namespace Pulsar.Server.Controls
{
internal class AeroListView : ListView
{
private const uint WM_CHANGEUISTATE = 0x127;
private const short UIS_SET = 1;
private const short UISF_HIDEFOCUS = 0x1;
private readonly IntPtr _removeDots = new IntPtr(NativeMethodsHelper.MakeWin32Long(UIS_SET, UISF_HIDEFOCUS));
private const int WM_VSCROLL = 0x115;
private const int SB_BOTTOM = 7;
private const int SB_TOP = 6;
private const int WS_VSCROLL = 0x00200000;
private const int WS_HSCROLL = 0x00100000;
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public ListViewColumnSorter LvwColumnSorter { get; set; }
[DefaultValue(true)]
public bool AllowAutoSort { get; set; } = true;
/// <summary>
/// Initializes a new instance of the <see cref="AeroListView"/> class.
/// </summary>
public AeroListView()
{
SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true);
this.LvwColumnSorter = new ListViewColumnSorter();
this.ListViewItemSorter = LvwColumnSorter;
this.View = View.Details;
this.FullRowSelect = true;
Resize += AeroListView_Resize;
}
/// <summary>
/// Gets the creation parameters.
/// </summary>
protected override CreateParams CreateParams
{
get
{
CreateParams cp = base.CreateParams;
cp.Style |= WS_VSCROLL | WS_HSCROLL; // Always show both scrollbars
return cp;
}
}
private void AeroListView_Resize(object sender, EventArgs e)
{
if (Columns.Count == 0) return;
int totalWidth = 0;
for (int i = 0; i < Columns.Count - 1; i++)
{
totalWidth += Columns[i].Width;
}
int newWidth = ClientSize.Width - totalWidth;
if (newWidth > 0)
{
Columns[Columns.Count - 1].Width = newWidth;
}
}
/// <summary>
/// Refreshes the scrollbars to ensure they are properly displayed.
/// </summary>
public void RefreshScrollBars()
{
// Yes I chatGPT this I have no idea wtf is going on.
// Force scrollbars to update by sending scroll messages
if (IsHandleCreated)
{
// Scroll to bottom and then back to top to refresh vertical scrollbar
NativeMethods.SendMessage(this.Handle, WM_VSCROLL, (IntPtr)SB_BOTTOM, IntPtr.Zero);
NativeMethods.SendMessage(this.Handle, WM_VSCROLL, (IntPtr)SB_TOP, IntPtr.Zero);
// Call UpdateScrollBars to ensure proper sizing
this.BeginUpdate();
this.EndUpdate();
}
}
/// <summary>
/// Raises the <see cref="E:HandleCreated" /> event.
/// </summary>
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
NativeMethods.SetWindowTheme(this.Handle, "explorer", null);
NativeMethods.SendMessage(this.Handle, WM_CHANGEUISTATE, _removeDots, IntPtr.Zero);
// Add this to refresh scrollbars after creation
this.BeginInvoke(new Action(() => RefreshScrollBars()));
}
/// <summary>
/// Raises the <see cref="E:Resize" /> event.
/// </summary>
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
protected override void OnResize(EventArgs e)
{
base.OnResize(e);
RefreshScrollBars();
}
/// <summary>
/// Raises the <see cref="E:ColumnClick" /> event.
/// </summary>
/// <param name="e">The <see cref="ColumnClickEventArgs"/> instance containing the event data.</param>
protected override void OnColumnClick(ColumnClickEventArgs e)
{
base.OnColumnClick(e);
if (!AllowAutoSort || this.LvwColumnSorter == null)
{
return;
}
// Determine if clicked column is already the column that is being sorted.
if (e.Column == this.LvwColumnSorter.SortColumn)
{
// Reverse the current sort direction for this column.
this.LvwColumnSorter.Order = (this.LvwColumnSorter.Order == SortOrder.Ascending)
? SortOrder.Descending
: SortOrder.Ascending;
}
else
{
// Set the column number that is to be sorted; default to ascending.
this.LvwColumnSorter.SortColumn = e.Column;
this.LvwColumnSorter.Order = SortOrder.Ascending;
}
// Perform the sort with these new sort options.
if (!this.VirtualMode)
this.Sort();
}
}
}
+54
View File
@@ -0,0 +1,54 @@
using System.ComponentModel;
using System.Drawing;
using System.Windows.Forms;
namespace Pulsar.Server.Controls
{
public class MenuButton : Button
{
[DefaultValue(null)]
public ContextMenuStrip Menu { get; set; }
[DefaultValue(false)]
public bool ShowMenuUnderCursor { get; set; }
protected override void OnMouseDown(MouseEventArgs mevent)
{
base.OnMouseDown(mevent);
if (Menu != null && mevent.Button == MouseButtons.Left)
{
Point menuLocation;
if (ShowMenuUnderCursor)
{
menuLocation = mevent.Location;
}
else
{
menuLocation = new Point(0, Height - 1);
}
Menu.Show(this, menuLocation);
}
}
protected override void OnPaint(PaintEventArgs pevent)
{
base.OnPaint(pevent);
if (Menu != null)
{
int arrowX = ClientRectangle.Width - Padding.Right - 14;
int arrowY = (ClientRectangle.Height / 2) - 1;
Color color = Color.White;
using (Brush brush = new SolidBrush(color))
{
Point[] arrows = new Point[] { new Point(arrowX, arrowY), new Point(arrowX + 7, arrowY), new Point(arrowX + 3, arrowY + 4) };
pevent.Graphics.FillPolygon(brush, arrows);
}
}
}
}
}
@@ -0,0 +1,23 @@
using System;
using System.Windows.Forms;
namespace Pulsar.Server.Controls
{
public class NoButtonTabControl : TabControl
{
protected override void WndProc(ref Message m)
{
// Message 0x1328 is related to tab header drawing, we suppress it here.
if (m.Msg == 0x1328 && !DesignMode)
{
// Suppress the header (tab) drawing
m.Result = (IntPtr)1;
}
else
{
// Process other messages as usual
base.WndProc(ref m);
}
}
}
}
+301
View File
@@ -0,0 +1,301 @@
using Pulsar.Server.Utilities;
using System;
using System.Diagnostics;
using System.Drawing;
using System.Windows.Forms;
using System.ComponentModel;
using System.Drawing.Drawing2D;
namespace Pulsar.Server.Controls
{
public interface IRapidPictureBox
{
bool Running { get; set; }
Image GetImageSafe { get; set; }
void Start();
void Stop();
void UpdateImage(Bitmap bmp, bool cloneBitmap = false);
}
/// <summary>
/// Custom PictureBox Control designed for rapidly-changing images.
/// </summary>
public class RapidPictureBox : PictureBox, IRapidPictureBox
{
/// <summary>
/// True if the PictureBox is currently streaming images, else False.
/// </summary>
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public bool Running { get; set; }
/// <summary>
/// Returns the width of the original screen.
/// </summary>
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
[Browsable(false)]
public int ScreenWidth { get; private set; }
/// <summary>
/// Returns the height of the original screen.
/// </summary>
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
[Browsable(false)]
public int ScreenHeight { get; private set; }
/// <summary>
/// Provides thread-safe access to the Image of this Picturebox.
/// </summary>
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public Image GetImageSafe
{
get
{
lock (_imageLock)
{
return _frame;
}
}
set
{
lock (_imageLock)
{
var old = _frame;
_frame = value as Bitmap;
old?.Dispose();
}
RequestRepaint();
}
}
/// <summary>
/// The lock object for the Picturebox's image.
/// </summary>
private readonly object _imageLock = new object();
/// <summary>
/// Latest frame to draw. We avoid assigning to PictureBox.Image to prevent cross-thread UI access and allocations.
/// </summary>
private Bitmap _frame;
/// <summary>
/// Small placeholder assigned to base.Image so existing code paths that check Image != null keep working.
/// </summary>
private Bitmap _placeholder;
/// <summary>
/// Prevent flooding the message queue; coalesce multiple UpdateImage calls into one repaint.
/// </summary>
private bool _repaintPending;
/// <summary>
/// The Stopwatch for internal FPS measuring.
/// </summary>
private Stopwatch _sWatch;
/// <summary>
/// The internal class for FPS measuring.
/// </summary>
private FrameCounter _frameCounter;
/// <summary>
/// Subscribes an Eventhandler to the FrameUpdated event.
/// </summary>
/// <param name="e">The Eventhandler to set.</param>
public void SetFrameUpdatedEvent(FrameUpdatedEventHandler e)
{
_frameCounter.FrameUpdated += e;
}
/// <summary>
/// Unsubscribes an Eventhandler from the FrameUpdated event.
/// </summary>
/// <param name="e">The Eventhandler to remove.</param>
public void UnsetFrameUpdatedEvent(FrameUpdatedEventHandler e)
{
_frameCounter.FrameUpdated -= e;
}
/// <summary>
/// Starts the internal FPS measuring.
/// </summary>
public void Start()
{
_frameCounter = new FrameCounter();
_sWatch = Stopwatch.StartNew();
Running = true;
}
/// <summary>
/// Stops the internal FPS measuring.
/// </summary>
public void Stop()
{
_sWatch?.Stop();
Running = false;
}
/// <summary>
/// Updates the Image of this Picturebox.
/// </summary>
/// <param name="bmp">The new bitmap to use.</param>
/// <param name="cloneBitmap">If True the bitmap will be cloned, else it uses the original bitmap.</param>
public void UpdateImage(Bitmap bmp, bool cloneBitmap)
{
try
{
CountFps();
if ((ScreenWidth != bmp.Width) || (ScreenHeight != bmp.Height))
UpdateScreenSize(bmp.Width, bmp.Height);
// Swap the frame without resizing; scaling is handled in OnPaint for speed.
lock (_imageLock)
{
var old = _frame;
_frame = cloneBitmap ? (Bitmap)bmp.Clone() : bmp;
old?.Dispose();
}
RequestRepaint();
}
catch (InvalidOperationException)
{
}
catch (Exception)
{
}
}
/// <summary>
/// Constructor, sets Picturebox double-buffered and initializes the Framecounter.
/// </summary>
public RapidPictureBox()
{
this.SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true);
_placeholder = new Bitmap(1, 1);
_placeholder.SetPixel(0, 0, Color.Transparent);
base.Image = _placeholder;
}
protected override void OnPaint(PaintEventArgs pe)
{
Bitmap localFrame = null;
lock (_imageLock)
{
if (_frame == null)
return;
localFrame = _frame;
}
var g = pe.Graphics;
g.SmoothingMode = SmoothingMode.None;
g.CompositingMode = CompositingMode.SourceCopy;
g.CompositingQuality = CompositingQuality.HighSpeed;
g.PixelOffsetMode = PixelOffsetMode.Half;
g.InterpolationMode = InterpolationMode.NearestNeighbor;
if (localFrame == null)
return;
var cs = this.ClientSize;
if (cs.Width <= 0 || cs.Height <= 0) return;
if (localFrame.Width == cs.Width && localFrame.Height == cs.Height)
{
g.DrawImageUnscaled(localFrame, 0, 0);
}
else
{
g.DrawImage(localFrame, this.ClientRectangle);
}
}
protected override void OnPaintBackground(PaintEventArgs pevent)
{
if (this.BackColor.A == 255)
{
using (var b = new SolidBrush(this.BackColor))
{
pevent.Graphics.FillRectangle(b, this.ClientRectangle);
}
}
else
{
base.OnPaintBackground(pevent);
}
}
private void UpdateScreenSize(int newWidth, int newHeight)
{
ScreenWidth = newWidth;
ScreenHeight = newHeight;
}
private void CountFps()
{
var deltaTime = (float)_sWatch.Elapsed.TotalSeconds;
_sWatch = Stopwatch.StartNew();
_frameCounter.Update(deltaTime);
}
private void RequestRepaint()
{
if (_repaintPending)
return;
_repaintPending = true;
void doInvalidate()
{
if (!IsDisposed)
{
Invalidate();
//Update();
}
_repaintPending = false;
}
if (IsHandleCreated && InvokeRequired)
{
try { BeginInvoke((Action)(doInvalidate)); } catch { _repaintPending = false; }
}
else
{
doInvalidate();
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
lock (_imageLock)
{
_frame?.Dispose();
_frame = null;
}
try
{
if (ReferenceEquals(base.Image, _placeholder))
{
base.Image = null;
}
_placeholder?.Dispose();
}
catch { }
finally
{
_placeholder = null;
}
}
base.Dispose(disposing);
}
}
}
@@ -0,0 +1,13 @@
using System.Windows.Forms;
namespace Pulsar.Server.Controls
{
public class RegistryTreeView : TreeView
{
public RegistryTreeView()
{
//Enable double buffering and ignore WM_ERASEBKGND to reduce flicker
SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true);
}
}
}
@@ -0,0 +1,75 @@
using Pulsar.Common.Models;
using Pulsar.Server.Extensions;
using Pulsar.Server.Registry;
using System.Windows.Forms;
namespace Pulsar.Server.Controls
{
public class RegistryValueLstItem : ListViewItem
{
private string _type { get; set; }
private string _data { get; set; }
public string RegName
{
get { return this.Name; }
set
{
this.Name = value;
this.Text = RegValueHelper.GetName(value);
}
}
public string Type
{
get { return _type; }
set
{
_type = value;
if (this.SubItems.Count < 2)
this.SubItems.Add(_type);
else
this.SubItems[1].Text = _type;
this.ImageIndex = GetRegistryValueImgIndex(_type);
}
}
public string Data
{
get { return _data; }
set
{
_data = value;
if (this.SubItems.Count < 3)
this.SubItems.Add(_data);
else
this.SubItems[2].Text = _data;
}
}
public RegistryValueLstItem(RegValueData value)
{
RegName = value.Name;
Type = value.Kind.RegistryTypeToString();
Data = RegValueHelper.RegistryValueToString(value);
}
private int GetRegistryValueImgIndex(string type)
{
switch (type)
{
case "REG_MULTI_SZ":
case "REG_SZ":
case "REG_EXPAND_SZ":
return 0;
case "REG_BINARY":
case "REG_DWORD":
case "REG_QWORD":
default:
return 1;
}
}
}
}
@@ -0,0 +1,47 @@
using System.Windows.Forms;
using System.Windows.Forms.Integration;
using Pulsar.Server.Controls.Wpf;
using Pulsar.Server.Statistics;
#nullable enable
namespace Pulsar.Server.Controls
{
public sealed class StatsElementHost : ElementHost
{
private readonly StatsView _statsView;
private ClientStatisticsSnapshot? _lastSnapshot;
public StatsElementHost()
{
_statsView = new StatsView();
Child = _statsView;
Dock = DockStyle.Fill;
}
public void ShowLoading()
{
_statsView.ShowLoading();
}
public void ShowError(string message)
{
_statsView.ShowError(message);
}
public void UpdateSnapshot(ClientStatisticsSnapshot snapshot)
{
_lastSnapshot = snapshot;
_statsView.UpdateSnapshot(snapshot);
}
public void ApplyTheme(bool isDarkMode)
{
_statsView.ApplyTheme(isDarkMode);
if (_lastSnapshot != null && !_lastSnapshot.HasError)
{
_statsView.UpdateSnapshot(_lastSnapshot);
}
}
}
}
+36
View File
@@ -0,0 +1,36 @@
namespace Pulsar.Server.Controls
{
partial class WordTextBox
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Component Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
components = new System.ComponentModel.Container();
}
#endregion
}
}
+175
View File
@@ -0,0 +1,175 @@
using Pulsar.Server.Enums;
using System;
using System.ComponentModel;
using System.Globalization;
using System.Windows.Forms;
namespace Pulsar.Server.Controls
{
public partial class WordTextBox : TextBox
{
private bool isHexNumber;
private WordType type;
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public override int MaxLength
{
get
{
return base.MaxLength;
}
set { }
}
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public bool IsHexNumber
{
get { return isHexNumber; }
set
{
if (isHexNumber == value)
return;
if (value)
{
if (Type == WordType.DWORD)
Text = UIntValue.ToString("x");
else
Text = ULongValue.ToString("x");
}
else
{
if (Type == WordType.DWORD)
Text = UIntValue.ToString();
else
Text = ULongValue.ToString();
}
isHexNumber = value;
UpdateMaxLength();
}
}
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public WordType Type
{
get { return type; }
set
{
if (type == value)
return;
type = value;
UpdateMaxLength();
}
}
public uint UIntValue
{
get
{
try
{
if (String.IsNullOrEmpty(Text))
return 0;
else if (IsHexNumber)
return UInt32.Parse(Text, NumberStyles.HexNumber);
else
return UInt32.Parse(Text);
}
catch (Exception)
{
return UInt32.MaxValue;
}
}
}
public ulong ULongValue
{
get
{
try
{
if (String.IsNullOrEmpty(Text))
return 0;
else if (IsHexNumber)
return UInt64.Parse(Text, NumberStyles.HexNumber);
else
return UInt64.Parse(Text);
}
catch (Exception)
{
return UInt64.MaxValue;
}
}
}
public bool IsConversionValid()
{
if (String.IsNullOrEmpty(Text))
return true;
if (!IsHexNumber)
{
return ConvertToHex();
}
return true;
}
public WordTextBox()
{
InitializeComponent();
base.MaxLength = 8;
}
protected override void OnKeyPress(KeyPressEventArgs e)
{
base.OnKeyPress(e);
e.Handled = !IsValidChar(e.KeyChar);
}
private bool IsValidChar(char ch)
{
return (Char.IsControl(ch) ||
Char.IsDigit(ch) ||
(IsHexNumber && Char.IsLetter(ch) && Char.ToLower(ch) <= 'f'));
}
private void UpdateMaxLength()
{
if (Type == WordType.DWORD)
{
if (IsHexNumber)
base.MaxLength = 8;
else
base.MaxLength = 10;
}
else
{
if (IsHexNumber)
base.MaxLength = 16;
else
base.MaxLength = 20;
}
}
private bool ConvertToHex()
{
try
{
if (Type == WordType.DWORD)
UInt32.Parse(Text);
else
UInt64.Parse(Text);
return true;
}
catch (Exception)
{
return false;
}
}
}
}
@@ -0,0 +1,176 @@
using Pulsar.Server.Networking;
using Pulsar.Server.Utilities;
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Media;
#nullable enable
namespace Pulsar.Server.Controls.Wpf
{
public sealed class ClientListEntry : INotifyPropertyChanged
{
private string _ip = string.Empty;
private string _nickname = string.Empty;
private string _tag = string.Empty;
private string _userAtPc = string.Empty;
private string _version = string.Empty;
private string _status = string.Empty;
private string _currentWindow = string.Empty;
private string _userStatus = string.Empty;
private string _countryWithCode = string.Empty;
private string _country = string.Empty;
private string _operatingSystem = string.Empty;
private string _accountType = string.Empty;
private bool _isFavorite;
private string _toolTip = string.Empty;
private int _imageIndex;
private ImageSource? _flagImage;
public ClientListEntry(Client client)
{
Client = client ?? throw new ArgumentNullException(nameof(client));
}
public event PropertyChangedEventHandler? PropertyChanged;
public Client Client { get; }
public string Ip
{
get => _ip;
set => SetField(ref _ip, value);
}
public string Nickname
{
get => _nickname;
set => SetField(ref _nickname, value);
}
public string Tag
{
get => _tag;
set => SetField(ref _tag, value);
}
public string UserAtPc
{
get => _userAtPc;
set => SetField(ref _userAtPc, value);
}
public string Version
{
get => _version;
set => SetField(ref _version, value);
}
public string Status
{
get => _status;
set => SetField(ref _status, value);
}
public string CurrentWindow
{
get => _currentWindow;
set => SetField(ref _currentWindow, value);
}
public string UserStatus
{
get => _userStatus;
set => SetField(ref _userStatus, value);
}
public string CountryWithCode
{
get => _countryWithCode;
set => SetField(ref _countryWithCode, value);
}
public string Country
{
get => _country;
set => SetField(ref _country, value);
}
public string OperatingSystem
{
get => _operatingSystem;
set => SetField(ref _operatingSystem, value);
}
public string AccountType
{
get => _accountType;
set => SetField(ref _accountType, value);
}
public bool IsFavorite
{
get => _isFavorite;
set => SetField(ref _isFavorite, value);
}
public string ToolTip
{
get => _toolTip;
set => SetField(ref _toolTip, value);
}
public int ImageIndex
{
get => _imageIndex;
set => SetField(ref _imageIndex, value);
}
public ImageSource? FlagImage
{
get => _flagImage;
set => SetField(ref _flagImage, value);
}
public Brush StatusBrush => string.Equals(Status, "Connected", StringComparison.OrdinalIgnoreCase)
? Brushes.LimeGreen
: Brushes.White;
public Brush VersionBrush => string.Equals(Version, ServerVersion.Current, StringComparison.OrdinalIgnoreCase)
? Brushes.Green
: Brushes.Red;
public void UpdateStatusBrush()
{
OnPropertyChanged(nameof(StatusBrush));
}
public void UpdateVersionBrush()
{
OnPropertyChanged(nameof(VersionBrush));
}
private void SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (!Equals(field, value))
{
field = value;
OnPropertyChanged(propertyName);
if (propertyName == nameof(Status))
{
UpdateStatusBrush();
}
if (propertyName == nameof(Version))
{
UpdateVersionBrush();
}
}
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}
@@ -0,0 +1,264 @@
<UserControl x:Class="Pulsar.Server.Controls.Wpf.ClientsListView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800" Background="Transparent">
<UserControl.Resources>
<controlsWpf:FavoriteToBrushConverter x:Key="FavoriteToBrushConverter"
xmlns:controlsWpf="clr-namespace:Pulsar.Server.Controls.Wpf" />
<SolidColorBrush x:Key="RowBackgroundBrush" Color="#1E1E1E" />
<SolidColorBrush x:Key="RowAlternateBackgroundBrush" Color="#232323" />
<SolidColorBrush x:Key="RowHoverBrush" Color="#2B2B2B" />
<SolidColorBrush x:Key="RowSelectedBrush" Color="#3A3A3A" />
<SolidColorBrush x:Key="RowSelectedInactiveBrush" Color="#333333" />
<SolidColorBrush x:Key="RowForegroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="RowSelectedForegroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="HeaderBackgroundBrush" Color="#2A2A2A" />
<SolidColorBrush x:Key="HeaderForegroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="GridBackgroundBrush" Color="#141414" />
<SolidColorBrush x:Key="ScrollBarTrackBrush" Color="#1E1E1E" />
<SolidColorBrush x:Key="ScrollBarThumbBrush" Color="#444444" />
<SolidColorBrush x:Key="ScrollBarThumbHoverBrush" Color="#5A5A5A" />
<SolidColorBrush x:Key="ScrollBarThumbPressedBrush" Color="#737373" />
<Style x:Key="ClientsRowStyle" TargetType="DataGridRow">
<Setter Property="FontSize" Value="12" />
<Setter Property="Background" Value="{DynamicResource RowBackgroundBrush}" />
<Setter Property="Foreground" Value="{DynamicResource RowForegroundBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<EventSetter Event="PreviewMouseRightButtonDown" Handler="DataGridRow_OnPreviewMouseRightButtonDown" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource RowHoverBrush}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource RowSelectedBrush}" />
<Setter Property="Foreground" Value="{DynamicResource RowSelectedForegroundBrush}" />
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="True" />
<Condition Property="IsKeyboardFocusWithin" Value="False" />
</MultiTrigger.Conditions>
<Setter Property="Background" Value="{DynamicResource RowSelectedInactiveBrush}" />
</MultiTrigger>
</Style.Triggers>
</Style>
<Style x:Key="StatusTextStyle" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource RowForegroundBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding Status}" Value="Connected">
<Setter Property="Foreground" Value="#32CD32" />
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="SlimScrollBarThumbStyle" TargetType="Thumb">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbBrush}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border Background="{TemplateBinding Background}" CornerRadius="4" />
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbHoverBrush}" />
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbPressedBrush}" />
</Trigger>
</Style.Triggers>
</Style>
<ControlTemplate x:Key="SlimVerticalScrollBarTemplate" TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}" Width="{TemplateBinding Width}">
<Grid.RowDefinitions>
<RowDefinition Height="0" />
<RowDefinition Height="*" />
<RowDefinition Height="0" />
</Grid.RowDefinitions>
<Track x:Name="PART_Track"
Grid.Row="1"
IsDirectionReversed="true"
Orientation="Vertical"
Maximum="{TemplateBinding Maximum}"
Minimum="{TemplateBinding Minimum}"
Value="{TemplateBinding Value}"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.Thumb>
<Thumb Style="{StaticResource SlimScrollBarThumbStyle}" />
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
<ControlTemplate x:Key="SlimHorizontalScrollBarTemplate" TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}" Height="{TemplateBinding Height}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="0" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="0" />
</Grid.ColumnDefinitions>
<Track x:Name="PART_Track"
Grid.Column="1"
Orientation="Horizontal"
Maximum="{TemplateBinding Maximum}"
Minimum="{TemplateBinding Minimum}"
Value="{TemplateBinding Value}"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.Thumb>
<Thumb Style="{StaticResource SlimScrollBarThumbStyle}" />
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
<Style x:Key="SlimScrollBarStyle" TargetType="ScrollBar">
<Setter Property="Background" Value="{DynamicResource ScrollBarTrackBrush}" />
<Setter Property="Width" Value="10" />
<Setter Property="Template" Value="{StaticResource SlimVerticalScrollBarTemplate}" />
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Height" Value="10" />
<Setter Property="Width" Value="Auto" />
<Setter Property="Template" Value="{StaticResource SlimHorizontalScrollBarTemplate}" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="ClientsCellStyle" TargetType="DataGridCell">
<Setter Property="Background" Value="{Binding Background, RelativeSource={RelativeSource AncestorType=DataGridRow}}" />
<Setter Property="Foreground" Value="{DynamicResource RowForegroundBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Foreground" Value="{DynamicResource RowSelectedForegroundBrush}" />
</Trigger>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter Property="Foreground" Value="{DynamicResource RowSelectedForegroundBrush}" />
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="DataGridColumnHeader">
<Setter Property="Foreground" Value="{DynamicResource HeaderForegroundBrush}" />
<Setter Property="Background" Value="{DynamicResource HeaderBackgroundBrush}" />
</Style>
<Style TargetType="DataGrid">
<Setter Property="RowBackground" Value="{DynamicResource RowBackgroundBrush}" />
<Setter Property="AlternatingRowBackground" Value="{DynamicResource RowAlternateBackgroundBrush}" />
<Setter Property="Foreground" Value="{DynamicResource RowForegroundBrush}" />
</Style>
</UserControl.Resources>
<Grid>
<DataGrid x:Name="ClientsGrid"
AutoGenerateColumns="False"
HeadersVisibility="Column"
IsReadOnly="True"
SelectionMode="Extended"
SelectionUnit="FullRow"
EnableRowVirtualization="True"
EnableColumnVirtualization="True"
GridLinesVisibility="None"
ScrollViewer.CanContentScroll="True"
ItemsSource="{Binding ClientsView}"
SelectionChanged="ClientsGrid_OnSelectionChanged"
MouseDoubleClick="ClientsGrid_OnMouseDoubleClick"
BorderThickness="0"
CanUserResizeRows="False"
AlternationCount="2"
Background="{DynamicResource GridBackgroundBrush}"
RowStyle="{StaticResource ClientsRowStyle}"
CellStyle="{StaticResource ClientsCellStyle}">
<DataGrid.Resources>
<Style TargetType="ScrollBar" BasedOn="{StaticResource SlimScrollBarStyle}" />
</DataGrid.Resources>
<DataGrid.Columns>
<DataGridTemplateColumn Header="IP" Width="180">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Image Source="{Binding FlagImage}"
Width="28"
Height="20"
VerticalAlignment="Center"
Stretch="Uniform"
RenderOptions.BitmapScalingMode="Fant">
<Image.Style>
<Style TargetType="Image">
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<Trigger Property="Source" Value="{x:Null}">
<Setter Property="Visibility" Value="Collapsed" />
</Trigger>
</Style.Triggers>
</Style>
</Image.Style>
</Image>
<TextBlock Text="{Binding Ip}" VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Margin" Value="6,0,0,0" />
<Style.Triggers>
<DataTrigger Binding="{Binding FlagImage}" Value="{x:Null}">
<Setter Property="Margin" Value="0" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Binding="{Binding Nickname}" Header="Nickname" Width="120" />
<DataGridTextColumn Binding="{Binding Tag}" Header="Tag" Width="80" />
<DataGridTextColumn Binding="{Binding UserAtPc}" Header="User@PC" Width="150" />
<DataGridTemplateColumn Header="Version" Width="100">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Version}" Foreground="{Binding VersionBrush}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Status" Width="120">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Status}" Style="{StaticResource StatusTextStyle}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Binding="{Binding CurrentWindow}" Header="Current Window" Width="200" />
<DataGridTextColumn Binding="{Binding UserStatus}" Header="User Status" Width="120" />
<DataGridTextColumn Binding="{Binding CountryWithCode}" Header="Country" Width="160" />
<DataGridTextColumn Binding="{Binding OperatingSystem}" Header="OS" Width="180" />
<DataGridTextColumn Binding="{Binding AccountType}" Header="Account Type" Width="120" />
<DataGridTemplateColumn Width="40" Header="★">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<ToggleButton HorizontalAlignment="Center"
VerticalAlignment="Center"
Content="★"
FontSize="14"
Background="Transparent"
BorderThickness="0"
Foreground="{Binding IsFavorite, Converter={StaticResource FavoriteToBrushConverter}}"
Command="{Binding DataContext.ToggleFavoriteCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"
IsChecked="{Binding IsFavorite, Mode=TwoWay}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
</UserControl>
@@ -0,0 +1,591 @@
using Pulsar.Server.Models;
using Pulsar.Server.Networking;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Controls.Primitives;
using System.Windows.Documents;
#nullable enable
namespace Pulsar.Server.Controls.Wpf
{
public partial class ClientsListView : UserControl
{
private readonly ObservableCollection<ClientListEntry> _entries = new();
private readonly Dictionary<Client, ClientListEntry> _entryLookup = new();
private readonly CollectionViewSource _collectionViewSource;
private bool _groupByCountry;
private Predicate<object>? _filter;
private bool _suppressSelectionNotifications;
private bool _isDragSelecting;
private ClientListEntry? _dragAnchorEntry;
private Point _dragStartPoint;
private SelectionAdorner? _selectionAdorner;
public ClientsListView()
{
InitializeComponent();
_collectionViewSource = new CollectionViewSource { Source = _entries };
_collectionViewSource.Filter += OnCollectionFilter;
ClientsView = _collectionViewSource.View;
ClientsView.SortDescriptions.Add(new SortDescription(nameof(ClientListEntry.Country), ListSortDirection.Ascending));
ClientsView.SortDescriptions.Add(new SortDescription(nameof(ClientListEntry.IsFavorite), ListSortDirection.Descending));
ClientsView.SortDescriptions.Add(new SortDescription(nameof(ClientListEntry.Nickname), ListSortDirection.Ascending));
ToggleFavoriteCommand = new RelayCommand<ClientListEntry>(OnToggleFavorite);
DataContext = this;
ApplyTheme(Settings.DarkMode);
ClientsGrid.PreviewMouseLeftButtonDown += ClientsGrid_OnPreviewMouseLeftButtonDown;
ClientsGrid.PreviewMouseMove += ClientsGrid_OnPreviewMouseMove;
ClientsGrid.PreviewMouseLeftButtonUp += ClientsGrid_OnPreviewMouseLeftButtonUp;
ClientsGrid.MouseLeave += ClientsGrid_OnMouseLeave;
}
public ICollectionView ClientsView { get; }
public ICommand ToggleFavoriteCommand { get; }
public event EventHandler<IReadOnlyList<ClientListEntry>>? SelectionChanged;
public event EventHandler<ClientListEntry>? ItemDoubleClicked;
public event EventHandler<ClientListEntry>? FavoriteToggled;
public IReadOnlyList<ClientListEntry> SelectedEntries => ClientsGrid.SelectedItems.Cast<ClientListEntry>().ToList();
public ClientListEntry? GetEntryByClient(Client client)
{
if (client == null)
{
return null;
}
if (Dispatcher.CheckAccess())
{
_entryLookup.TryGetValue(client, out var entry);
return entry;
}
return Dispatcher.Invoke(() =>
{
_entryLookup.TryGetValue(client, out var entry);
return entry;
});
}
public ClientListEntry AddOrUpdate(Client client, Action<ClientListEntry> updater)
{
if (client == null)
{
throw new ArgumentNullException(nameof(client));
}
if (updater == null)
{
throw new ArgumentNullException(nameof(updater));
}
if (Dispatcher.CheckAccess())
{
return AddOrUpdateInternal(client, updater);
}
return Dispatcher.Invoke(() => AddOrUpdateInternal(client, updater));
}
private ClientListEntry AddOrUpdateInternal(Client client, Action<ClientListEntry> updater)
{
if (!_entryLookup.TryGetValue(client, out var entry))
{
entry = new ClientListEntry(client);
_entries.Add(entry);
_entryLookup[client] = entry;
}
updater(entry);
return entry;
}
public void Remove(Client client)
{
if (client == null)
{
return;
}
void RemoveInternal()
{
if (_entryLookup.TryGetValue(client, out var target))
{
_entries.Remove(target);
_entryLookup.Remove(client);
}
}
if (Dispatcher.CheckAccess())
{
RemoveInternal();
}
else
{
Dispatcher.Invoke(RemoveInternal);
}
}
public void Clear()
{
void ClearInternal()
{
_entries.Clear();
_entryLookup.Clear();
}
if (Dispatcher.CheckAccess())
{
ClearInternal();
}
else
{
Dispatcher.Invoke(ClearInternal);
}
}
public void ApplyFilter(Predicate<ClientListEntry>? filter)
{
_filter = filter != null ? new Predicate<object>(o => filter((ClientListEntry)o)) : null;
Dispatcher.Invoke(() => ClientsView.Refresh());
}
public void SetGroupByCountry(bool enabled)
{
_groupByCountry = enabled;
Dispatcher.Invoke(UpdateGrouping);
}
public void SetSelectedClients(IEnumerable<Client> clients)
{
if (clients == null)
{
return;
}
var target = new HashSet<Client>(clients);
Dispatcher.Invoke(() =>
{
_suppressSelectionNotifications = true;
try
{
ClientsGrid.SelectedItems.Clear();
foreach (var entry in _entries)
{
if (target.Contains(entry.Client))
{
ClientsGrid.SelectedItems.Add(entry);
}
}
}
finally
{
_suppressSelectionNotifications = false;
}
});
}
public void RefreshSort()
{
Dispatcher.Invoke(() =>
{
using (ClientsView.DeferRefresh())
{
ClientsView.SortDescriptions.Clear();
ClientsView.SortDescriptions.Add(new SortDescription(nameof(ClientListEntry.Country), ListSortDirection.Ascending));
ClientsView.SortDescriptions.Add(new SortDescription(nameof(ClientListEntry.IsFavorite), ListSortDirection.Descending));
ClientsView.SortDescriptions.Add(new SortDescription(nameof(ClientListEntry.Nickname), ListSortDirection.Ascending));
}
});
}
public void RefreshItem(ClientListEntry entry)
{
Dispatcher.Invoke(() =>
{
entry.UpdateStatusBrush();
ClientsView.Refresh();
});
}
public void ApplyTheme(bool isDarkMode)
{
Dispatcher.Invoke(() =>
{
Resources["RowBackgroundBrush"] = CreateBrush(isDarkMode ? "#1E1E1E" : "#FFFFFF");
Resources["RowAlternateBackgroundBrush"] = CreateBrush(isDarkMode ? "#232323" : "#F7F7F7");
Resources["RowHoverBrush"] = CreateBrush(isDarkMode ? "#2E2E2E" : "#ECECEC");
Resources["RowSelectedBrush"] = CreateBrush(isDarkMode ? "#162B4C" : "#D8E6FF");
Resources["RowSelectedInactiveBrush"] = CreateBrush(isDarkMode ? "#11213C" : "#E5EFFE");
Resources["RowForegroundBrush"] = CreateBrush(isDarkMode ? "#FFFFFF" : "#1A1A1A");
Resources["RowSelectedForegroundBrush"] = CreateBrush(isDarkMode ? "#67B0FF" : "#0F3B8C");
Resources["HeaderBackgroundBrush"] = CreateBrush(isDarkMode ? "#2A2A2A" : "#FFFFFF");
Resources["HeaderForegroundBrush"] = CreateBrush(isDarkMode ? "#FFFFFF" : "#1A1A1A");
Resources["GridBackgroundBrush"] = CreateBrush(isDarkMode ? "#141414" : "#FFFFFF");
Resources["ScrollBarTrackBrush"] = CreateBrush(isDarkMode ? "#1E1E1E" : "#E5E5E5");
Resources["ScrollBarThumbBrush"] = CreateBrush(isDarkMode ? "#444444" : "#B5B5B5");
Resources["ScrollBarThumbHoverBrush"] = CreateBrush(isDarkMode ? "#5A5A5A" : "#9E9E9E");
Resources["ScrollBarThumbPressedBrush"] = CreateBrush(isDarkMode ? "#737373" : "#7C7C7C");
ClientsGrid.Background = (Brush)Resources["GridBackgroundBrush"];
ClientsGrid.RowBackground = (Brush)Resources["RowBackgroundBrush"];
ClientsGrid.AlternatingRowBackground = (Brush)Resources["RowAlternateBackgroundBrush"];
ClientsGrid.Foreground = (Brush)Resources["RowForegroundBrush"];
});
}
private void UpdateGrouping()
{
using (ClientsView.DeferRefresh())
{
ClientsView.GroupDescriptions.Clear();
if (_groupByCountry)
{
ClientsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(ClientListEntry.Country)));
}
}
}
private void OnCollectionFilter(object sender, FilterEventArgs e)
{
if (_filter == null)
{
e.Accepted = true;
return;
}
e.Accepted = _filter(e.Item);
}
private void OnToggleFavorite(ClientListEntry? entry)
{
if (entry == null)
{
return;
}
System.Diagnostics.Debug.WriteLine($"[ClientsListView] Toggling favorite for {entry.Nickname} ({entry.Client?.Value?.UserAtPc ?? "unknown"})");
System.Diagnostics.Debug.WriteLine($"[ClientsListView] Before toggle: IsFavorite={entry.IsFavorite}");
RefreshSort();
FavoriteToggled?.Invoke(this, entry);
System.Diagnostics.Debug.WriteLine($"[ClientsListView] After toggle: IsFavorite={entry.IsFavorite}");
}
private void ClientsGrid_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_suppressSelectionNotifications)
{
return;
}
var selection = SelectedEntries;
SelectionChanged?.Invoke(this, selection);
}
private void ClientsGrid_OnMouseDoubleClick(object sender, MouseButtonEventArgs e)
{
if (ClientsGrid.SelectedItem is ClientListEntry entry)
{
ItemDoubleClicked?.Invoke(this, entry);
}
}
private void ClientsGrid_OnPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.OriginalSource is not DependencyObject source)
{
return;
}
if (FindVisualParent<DataGridColumnHeader>(source) != null || FindVisualParent<ScrollBar>(source) != null)
{
return;
}
if (FindVisualParent<ButtonBase>(source) != null)
{
return;
}
_dragStartPoint = e.GetPosition(ClientsGrid);
ClientsGrid.Focus();
if (Keyboard.Modifiers != ModifierKeys.None)
{
return;
}
var row = FindVisualParent<DataGridRow>(source);
_dragAnchorEntry = row?.Item as ClientListEntry;
BeginDragSelection();
if (_dragAnchorEntry != null)
{
SelectEntries(new[] { _dragAnchorEntry });
}
else
{
ClearSelectionInternal(false);
}
e.Handled = true;
}
private void ClientsGrid_OnPreviewMouseMove(object sender, MouseEventArgs e)
{
if (!_isDragSelecting || e.LeftButton != MouseButtonState.Pressed || Keyboard.Modifiers != ModifierKeys.None)
{
return;
}
var point = e.GetPosition(ClientsGrid);
UpdateDragSelection(point);
e.Handled = true;
}
private void ClientsGrid_OnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (_isDragSelecting)
{
EndDragSelection();
e.Handled = true;
}
}
private void ClientsGrid_OnMouseLeave(object sender, MouseEventArgs e)
{
if (e.LeftButton != MouseButtonState.Pressed)
{
EndDragSelection();
}
}
private void DataGridRow_OnPreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is DataGridRow row)
{
if (!ClientsGrid.SelectedItems.Contains(row.Item))
{
ClientsGrid.SelectedItem = row.Item;
}
ClientsGrid.Focus();
}
}
private static SolidColorBrush CreateBrush(string hex)
{
var color = (Color)ColorConverter.ConvertFromString(hex)!;
var brush = new SolidColorBrush(color);
brush.Freeze();
return brush;
}
public void ClearSelection()
{
ClearSelectionInternal(true);
}
private void EndDragSelection()
{
_isDragSelecting = false;
_dragAnchorEntry = null;
if (ClientsGrid.IsMouseCaptured)
{
ClientsGrid.ReleaseMouseCapture();
}
if (_selectionAdorner != null)
{
var layer = AdornerLayer.GetAdornerLayer(ClientsGrid);
layer?.Remove(_selectionAdorner);
_selectionAdorner = null;
}
}
private DataGridRow? GetRowFromPoint(Point point)
{
var element = ClientsGrid.InputHitTest(point) as DependencyObject;
return FindVisualParent<DataGridRow>(element);
}
private static T? FindVisualParent<T>(DependencyObject? current) where T : DependencyObject
{
while (current != null)
{
if (current is T target)
{
return target;
}
current = VisualTreeHelper.GetParent(current);
}
return null;
}
private void BeginDragSelection()
{
_isDragSelecting = true;
ClientsGrid.Focus();
ClientsGrid.CaptureMouse();
var layer = AdornerLayer.GetAdornerLayer(ClientsGrid);
if (layer != null)
{
_selectionAdorner = new SelectionAdorner(ClientsGrid, _dragStartPoint);
layer.Add(_selectionAdorner);
}
}
private void UpdateDragSelection(Point currentPoint)
{
_selectionAdorner?.Update(currentPoint);
var rect = new Rect(_dragStartPoint, currentPoint);
var selected = new List<ClientListEntry>();
if (_dragAnchorEntry != null)
{
selected.Add(_dragAnchorEntry);
}
var itemCount = ClientsGrid.Items.Count;
for (var i = 0; i < itemCount; i++)
{
if (ClientsGrid.Items[i] is not ClientListEntry entry)
{
continue;
}
if (ReferenceEquals(entry, _dragAnchorEntry))
{
continue;
}
if (ClientsGrid.ItemContainerGenerator.ContainerFromIndex(i) is not DataGridRow row)
{
continue;
}
var bounds = VisualTreeHelper.GetDescendantBounds(row);
var topLeft = row.TransformToAncestor(ClientsGrid).Transform(new Point(bounds.X, bounds.Y));
var rowRect = new Rect(topLeft, bounds.Size);
if (rowRect.IntersectsWith(rect))
{
selected.Add(entry);
}
}
SelectEntries(selected);
}
private void SelectEntries(IReadOnlyList<ClientListEntry> entries)
{
_suppressSelectionNotifications = true;
try
{
ClientsGrid.SelectedItems.Clear();
foreach (var entry in entries)
{
ClientsGrid.SelectedItems.Add(entry);
}
}
finally
{
_suppressSelectionNotifications = false;
}
SelectionChanged?.Invoke(this, entries);
}
private void ClearSelectionInternal(bool raiseEvent)
{
if (ClientsGrid.SelectedItems.Count == 0)
{
if (raiseEvent)
{
SelectionChanged?.Invoke(this, Array.Empty<ClientListEntry>());
}
return;
}
_suppressSelectionNotifications = true;
try
{
ClientsGrid.SelectedItems.Clear();
}
finally
{
_suppressSelectionNotifications = false;
}
if (raiseEvent)
{
SelectionChanged?.Invoke(this, Array.Empty<ClientListEntry>());
}
}
private sealed class SelectionAdorner : Adorner
{
private static readonly Brush FillBrush;
private static readonly Pen BorderPen;
private Point _start;
private Point _end;
static SelectionAdorner()
{
FillBrush = new SolidColorBrush(Color.FromArgb(40, 51, 153, 255));
FillBrush.Freeze();
BorderPen = new Pen(new SolidColorBrush(Color.FromArgb(200, 51, 153, 255)), 1)
{
DashStyle = DashStyles.Dash
};
BorderPen.Brush.Freeze();
BorderPen.Freeze();
}
public SelectionAdorner(UIElement adornedElement, Point start)
: base(adornedElement)
{
IsHitTestVisible = false;
_start = start;
_end = start;
}
public void Update(Point current)
{
_end = current;
InvalidateVisual();
}
protected override void OnRender(DrawingContext drawingContext)
{
var rect = new Rect(_start, _end);
drawingContext.DrawRectangle(FillBrush, BorderPen, rect);
}
}
}
}
@@ -0,0 +1,23 @@
using System;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
#nullable enable
namespace Pulsar.Server.Controls.Wpf
{
internal sealed class FavoriteToBrushConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
bool isFavorite = value is bool flag && flag;
return isFavorite ? Brushes.Gold : Brushes.Gray;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
}
+268
View File
@@ -0,0 +1,268 @@
<UserControl x:Class="Pulsar.Server.Controls.Wpf.HeatMapView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignWidth="900"
d:DesignHeight="620">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<SolidColorBrush x:Key="StatsBackgroundBrush" Color="#FFFFFFFF" />
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFF5F5F5" />
<SolidColorBrush x:Key="CardBorderBrush" Color="#FFE0E0E0" />
<SolidColorBrush x:Key="CardForegroundBrush" Color="#FF1F1F1F" />
<SolidColorBrush x:Key="SectionHeaderBrush" Color="#FF1F1F1F" />
<SolidColorBrush x:Key="MutedTextBrush" Color="#FF5F6368" />
<SolidColorBrush x:Key="AccentBrush" Color="#FF1976D2" />
<SolidColorBrush x:Key="PositiveAccentBrush" Color="#FF2E7D32" />
<SolidColorBrush x:Key="NegativeAccentBrush" Color="#FFC62828" />
<SolidColorBrush x:Key="ChartBackgroundBrush" Color="#FFFFFFFF" />
<SolidColorBrush x:Key="ChartBorderBrush" Color="#FFE0E0E0" />
<SolidColorBrush x:Key="ScrollBarTrackBrush" Color="#FFE5E5E5" />
<SolidColorBrush x:Key="ScrollBarThumbBrush" Color="#FFB5B5B5" />
<SolidColorBrush x:Key="ScrollBarThumbHoverBrush" Color="#FF9E9E9E" />
<SolidColorBrush x:Key="ScrollBarThumbPressedBrush" Color="#FF7C7C7C" />
<Style x:Key="RightAlignedCell" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style x:Key="SlimScrollBarThumbStyle" TargetType="Thumb">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbBrush}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border Background="{TemplateBinding Background}" CornerRadius="4" />
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbHoverBrush}" />
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbPressedBrush}" />
</Trigger>
</Style.Triggers>
</Style>
<ControlTemplate x:Key="SlimVerticalScrollBarTemplate" TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}" Width="{TemplateBinding Width}">
<Grid.RowDefinitions>
<RowDefinition Height="0" />
<RowDefinition Height="*" />
<RowDefinition Height="0" />
</Grid.RowDefinitions>
<Track x:Name="PART_Track"
Grid.Row="1"
IsDirectionReversed="True"
Orientation="Vertical"
Maximum="{TemplateBinding Maximum}"
Minimum="{TemplateBinding Minimum}"
Value="{TemplateBinding Value}"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.Thumb>
<Thumb Style="{StaticResource SlimScrollBarThumbStyle}" />
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
<ControlTemplate x:Key="SlimHorizontalScrollBarTemplate" TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}" Height="{TemplateBinding Height}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="0" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="0" />
</Grid.ColumnDefinitions>
<Track x:Name="PART_Track"
Grid.Column="1"
Orientation="Horizontal"
Maximum="{TemplateBinding Maximum}"
Minimum="{TemplateBinding Minimum}"
Value="{TemplateBinding Value}"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.Thumb>
<Thumb Style="{StaticResource SlimScrollBarThumbStyle}" />
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
<Style x:Key="SlimScrollBarStyle" TargetType="ScrollBar">
<Setter Property="Background" Value="{DynamicResource ScrollBarTrackBrush}" />
<Setter Property="Width" Value="10" />
<Setter Property="Template" Value="{StaticResource SlimVerticalScrollBarTemplate}" />
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Height" Value="10" />
<Setter Property="Width" Value="Auto" />
<Setter Property="Template" Value="{StaticResource SlimHorizontalScrollBarTemplate}" />
</Trigger>
</Style.Triggers>
</Style>
</UserControl.Resources>
<Grid x:Name="LayoutRoot" Background="{StaticResource StatsBackgroundBrush}">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Background="Transparent"
Visibility="{Binding IsContentVisible, Converter={StaticResource BoolToVisibilityConverter}}">
<ScrollViewer.Resources>
<Style TargetType="ScrollBar" BasedOn="{StaticResource SlimScrollBarStyle}" />
</ScrollViewer.Resources>
<StackPanel Margin="24">
<TextBlock Text="Global presence"
FontSize="20"
FontWeight="SemiBold"
Foreground="{StaticResource SectionHeaderBrush}" />
<ItemsControl ItemsSource="{Binding StatCards}" Margin="0,16,0,24">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="2" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Margin="8"
Padding="16"
CornerRadius="12"
Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1">
<StackPanel>
<TextBlock Text="{Binding Title}"
FontSize="14"
FontWeight="SemiBold"
Foreground="{StaticResource CardForegroundBrush}" />
<TextBlock Text="{Binding Value}"
FontSize="28"
FontWeight="Bold"
Margin="0,8,0,4"
Foreground="{StaticResource AccentBrush}" />
<TextBlock Text="{Binding Subtitle}"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Opacity="0.7" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid Margin="0,0,0,24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0"
Margin="0,0,12,0"
Padding="16"
CornerRadius="12"
Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1">
<StackPanel>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,12">
<TextBlock Text="World heat map"
FontSize="16"
FontWeight="SemiBold"
Foreground="{StaticResource SectionHeaderBrush}" />
<TextBlock Text="— Clients by country"
FontSize="13"
Margin="8,2,0,0"
Foreground="{StaticResource MutedTextBrush}"
Opacity="0.7" />
</StackPanel>
<ContentControl x:Name="MapHost"
Height="360"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch" />
<TextBlock Text="Hotter regions indicate more connected clients."
Margin="0,12,0,0"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}" />
</StackPanel>
</Border>
<Border Grid.Column="1"
Padding="16"
CornerRadius="12"
Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1">
<StackPanel>
<TextBlock Text="Top countries"
FontSize="16"
FontWeight="SemiBold"
Foreground="{StaticResource SectionHeaderBrush}"
Margin="0,0,0,12" />
<ListView x:Name="TopCountriesList"
ItemsSource="{Binding TopCountries}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="0"
Background="Transparent"
Foreground="{StaticResource CardForegroundBrush}"
ScrollViewer.VerticalScrollBarVisibility="Auto">
<ListView.Resources>
<Style TargetType="ScrollBar" BasedOn="{StaticResource SlimScrollBarStyle}" />
</ListView.Resources>
<ListView.View>
<GridView AllowsColumnReorder="False">
<GridViewColumn Width="36" Header="#" DisplayMemberBinding="{Binding Rank}" />
<GridViewColumn Width="140" Header="Country" DisplayMemberBinding="{Binding Country}" />
<GridViewColumn Width="60" Header="ISO" DisplayMemberBinding="{Binding Code}" />
<GridViewColumn Width="80" Header="Clients" DisplayMemberBinding="{Binding Count}" />
<GridViewColumn Width="80" Header="Share" DisplayMemberBinding="{Binding SharePercent}" />
</GridView>
</ListView.View>
</ListView>
</StackPanel>
</Border>
</Grid>
<TextBlock Text="{Binding LastUpdated}"
Margin="0,16,0,0"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Opacity="0.7" />
</StackPanel>
</ScrollViewer>
<Border Background="#AA000000"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="Loading heat map..."
Foreground="White"
FontSize="18"
FontWeight="SemiBold"
TextAlignment="Center" />
</StackPanel>
</Border>
<Border Background="#33FF0000"
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibilityConverter}}">
<Border Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="24"
HorizontalAlignment="Center"
VerticalAlignment="Center"
MaxWidth="420">
<StackPanel>
<TextBlock Text="Unable to load heat map"
FontSize="18"
FontWeight="SemiBold"
Foreground="{StaticResource CardForegroundBrush}"
TextAlignment="Center" />
<TextBlock Text="{Binding ErrorMessage}"
Margin="0,12,0,0"
TextWrapping="Wrap"
Foreground="{StaticResource CardForegroundBrush}"
Opacity="0.8"
TextAlignment="Center" />
</StackPanel>
</Border>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,148 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Media;
using LiveChartsCore.Geo;
using LiveChartsCore.SkiaSharpView.WPF;
using Pulsar.Server.Statistics;
#nullable enable
namespace Pulsar.Server.Controls.Wpf
{
public partial class HeatMapView : UserControl
{
private readonly HeatMapViewModel _viewModel;
private readonly GeoMap _geoMap;
public HeatMapView()
{
InitializeComponent();
_viewModel = new HeatMapViewModel();
DataContext = _viewModel;
Dispatcher.UnhandledException += OnDispatcherUnhandledException;
_geoMap = CreateGeoMap();
MapHost.Content = _geoMap;
Bind(_geoMap, GeoMap.SeriesProperty, nameof(HeatMapViewModel.Series));
}
private void OnDispatcherUnhandledException(object? sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
{
if (e.Exception is NullReferenceException &&
e.Exception.StackTrace?.Contains("LiveChartsCore.SkiaSharpView.WPF.Rendering.CompositionTargetTicker.DisposeTicker", StringComparison.Ordinal) == true)
{
e.Handled = true;
}
}
public void ShowLoading()
{
Dispatcher.Invoke(_viewModel.SetLoading);
}
public void ShowError(string message)
{
Dispatcher.Invoke(() => _viewModel.SetError(message));
}
public void UpdateSnapshot(ClientGeoSnapshot snapshot)
{
if (snapshot == null)
{
return;
}
Dispatcher.Invoke(() => _viewModel.UpdateSnapshot(snapshot));
}
public void ApplyTheme(bool isDarkMode)
{
Dispatcher.Invoke(() =>
{
UpdateBrush("StatsBackgroundBrush", isDarkMode ? "#FF1A1A1A" : "#FFFFFFFF");
UpdateBrush("CardBackgroundBrush", isDarkMode ? "#FF222327" : "#FFF5F5F5");
UpdateBrush("CardBorderBrush", isDarkMode ? "#FF2E3136" : "#FFE0E0E0");
UpdateBrush("CardForegroundBrush", isDarkMode ? "#FFE8EAED" : "#FF1F1F1F");
UpdateBrush("MutedTextBrush", isDarkMode ? "#FF9AA0A6" : "#FF5F6368");
UpdateBrush("AccentBrush", isDarkMode ? "#FF64B5F6" : "#FF1976D2");
UpdateBrush("SectionHeaderBrush", isDarkMode ? "#FF64B5F6" : "#FF1976D2");
UpdateBrush("ChartBackgroundBrush", isDarkMode ? "#FF1E1F23" : "#FFFFFFFF");
UpdateBrush("ChartBorderBrush", isDarkMode ? "#FF2F3338" : "#FFE0E0E0");
UpdateBrush("ScrollBarTrackBrush", isDarkMode ? "#FF1E1E1E" : "#FFE5E5E5");
UpdateBrush("ScrollBarThumbBrush", isDarkMode ? "#FF444444" : "#FFB5B5B5");
UpdateBrush("ScrollBarThumbHoverBrush", isDarkMode ? "#FF5A5A5A" : "#FF9E9E9E");
UpdateBrush("ScrollBarThumbPressedBrush", isDarkMode ? "#FF737373" : "#FF7C7C7C");
LayoutRoot.Background = (Brush)Resources["StatsBackgroundBrush"];
ApplyMapTheme();
_viewModel.UpdateTheme(isDarkMode);
});
}
private void UpdateBrush(string resourceKey, string hex)
{
var color = (Color)ColorConverter.ConvertFromString(hex)!;
if (Resources[resourceKey] is SolidColorBrush brush)
{
if (!brush.IsFrozen)
{
brush.Color = color;
}
else
{
var mutable = brush.Clone();
mutable.Color = color;
Resources[resourceKey] = mutable;
}
}
else
{
Resources[resourceKey] = new SolidColorBrush(color);
}
}
private void ApplyMapTheme()
{
if (Resources["ChartBackgroundBrush"] is not SolidColorBrush chartBackground)
{
return;
}
if (Resources["ChartBorderBrush"] is not SolidColorBrush chartBorder)
{
return;
}
_geoMap.Background = chartBackground;
_geoMap.BorderBrush = chartBorder;
_geoMap.BorderThickness = new Thickness(1);
}
private static GeoMap CreateGeoMap()
{
return new GeoMap
{
Height = 360,
Padding = new Thickness(8)
};
}
private static Binding CreateOneWayBinding(string path)
{
return new Binding(path)
{
Mode = BindingMode.OneWay,
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
};
}
private static void Bind(FrameworkElement element, DependencyProperty property, string path)
{
element.SetBinding(property, CreateOneWayBinding(path));
}
}
}
@@ -0,0 +1,231 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using LiveChartsCore.Geo;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Drawing.Geometries;
using Pulsar.Server.Statistics;
#nullable enable
namespace Pulsar.Server.Controls.Wpf
{
public sealed class HeatMapViewModel : INotifyPropertyChanged
{
private readonly ObservableCollection<StatCardViewModel> _statCards = new()
{
new StatCardViewModel("Total Clients"),
new StatCardViewModel("Geolocated"),
new StatCardViewModel("Unknown Location"),
new StatCardViewModel("Unique Countries")
};
private readonly ObservableCollection<CountryHeatItem> _topCountries = new();
private HeatLandSeries[] _series = Array.Empty<HeatLandSeries>();
private bool _isLoading;
private bool _hasError;
private string? _errorMessage;
private string _lastUpdated = string.Empty;
private bool _isDarkMode;
private ClientGeoSnapshot? _lastSnapshot;
public event PropertyChangedEventHandler? PropertyChanged;
public ReadOnlyObservableCollection<StatCardViewModel> StatCards { get; }
public HeatLandSeries[] Series
{
get => _series;
private set => SetField(ref _series, value);
}
public bool IsLoading
{
get => _isLoading;
private set
{
if (SetField(ref _isLoading, value))
{
OnPropertyChanged(nameof(IsContentVisible));
}
}
}
public bool HasError
{
get => _hasError;
private set
{
if (SetField(ref _hasError, value))
{
OnPropertyChanged(nameof(IsContentVisible));
}
}
}
public bool IsContentVisible => !IsLoading && !HasError;
public string? ErrorMessage
{
get => _errorMessage;
private set => SetField(ref _errorMessage, value);
}
public string LastUpdated
{
get => _lastUpdated;
private set => SetField(ref _lastUpdated, value);
}
public ReadOnlyObservableCollection<CountryHeatItem> TopCountries { get; }
public HeatMapViewModel()
{
StatCards = new ReadOnlyObservableCollection<StatCardViewModel>(_statCards);
TopCountries = new ReadOnlyObservableCollection<CountryHeatItem>(_topCountries);
}
public void SetLoading()
{
ErrorMessage = null;
HasError = false;
IsLoading = true;
}
public void SetError(string message)
{
_lastSnapshot = null;
ErrorMessage = message;
HasError = true;
IsLoading = false;
LastUpdated = string.Empty;
ClearData();
}
public void UpdateSnapshot(ClientGeoSnapshot snapshot)
{
_lastSnapshot = snapshot;
ErrorMessage = snapshot.ErrorMessage;
HasError = snapshot.HasError;
IsLoading = false;
if (snapshot.HasError)
{
LastUpdated = string.Empty;
ClearData();
return;
}
LastUpdated = $"Updated {snapshot.GeneratedAtUtc.ToLocalTime():g}";
UpdateCards(snapshot);
UpdateTopCountries(snapshot);
BuildSeries();
}
public void UpdateTheme(bool isDarkMode)
{
_isDarkMode = isDarkMode;
BuildSeries();
}
private void UpdateCards(ClientGeoSnapshot snapshot)
{
_statCards[0].Update(snapshot.TotalClients.ToString("N0"), "Records processed");
_statCards[1].Update(snapshot.MappedClients.ToString("N0"), "Known country");
_statCards[2].Update(snapshot.UnknownClients.ToString("N0"), "No location data");
_statCards[3].Update(snapshot.UniqueCountryCount.ToString("N0"), "Countries represented");
}
private void UpdateTopCountries(ClientGeoSnapshot snapshot)
{
_topCountries.Clear();
var rank = 1;
foreach (var country in snapshot.Countries.Take(15))
{
_topCountries.Add(new CountryHeatItem(rank++, country.Name, country.CountryCode3.ToUpperInvariant(), country.Count, country.Share));
}
}
private void BuildSeries()
{
if (_lastSnapshot == null || _lastSnapshot.HasError)
{
Series = Array.Empty<HeatLandSeries>();
return;
}
var lands = _lastSnapshot.Countries
.Where(c => !string.IsNullOrWhiteSpace(c.CountryCode3))
.Select(c => new HeatLand
{
Name = c.CountryCode3.ToLowerInvariant(),
Value = c.Count
})
.ToArray();
if (lands.Length == 0)
{
Series = Array.Empty<HeatLandSeries>();
return;
}
Series = new[]
{
new HeatLandSeries
{
Lands = lands
}
};
}
private void ClearData()
{
Series = Array.Empty<HeatLandSeries>();
_topCountries.Clear();
}
private bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
public sealed class CountryHeatItem
{
public CountryHeatItem(int rank, string country, string code, int count, double share)
{
Rank = rank;
Country = string.IsNullOrWhiteSpace(country) ? "Unknown" : country;
Code = string.IsNullOrWhiteSpace(code) ? "" : code;
Count = count;
Share = share;
}
public int Rank { get; }
public string Country { get; }
public string Code { get; }
public int Count { get; }
public double Share { get; }
public string SharePercent => Share > 0 ? Share.ToString("P1") : "0%";
}
}
@@ -0,0 +1,175 @@
<UserControl x:Class="Pulsar.Server.Controls.Wpf.ProcessTreeView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:wpf="clr-namespace:Pulsar.Server.Controls.Wpf"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="480">
<UserControl.Resources>
<SolidColorBrush x:Key="RowSeparatorBrush" Color="#2E2E2E" />
<SolidColorBrush x:Key="RowHoverBrush" Color="#2A2A2A" />
<SolidColorBrush x:Key="ScrollbarTrackBrush" Color="#1E1E1E" />
<SolidColorBrush x:Key="ScrollbarThumbBrush" Color="#3C3C3C" />
<SolidColorBrush x:Key="ScrollbarThumbHoverBrush" Color="#525252" />
<HierarchicalDataTemplate DataType="{x:Type wpf:ProcessTreeNode}" ItemsSource="{Binding Children}">
<Border BorderBrush="{StaticResource RowSeparatorBrush}"
BorderThickness="0,0,0,1"
Background="Transparent"
Padding="0">
<Grid Margin="0" VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding Name}"
Foreground="{Binding Foreground}" />
<TextBlock Grid.Column="1"
Text="{Binding PidDisplay}"
Margin="16,0,16,0"
HorizontalAlignment="Left"
Foreground="{Binding Foreground}" />
<TextBlock Grid.Column="2"
Text="{Binding WindowTitle}"
Foreground="{Binding Foreground}" />
</Grid>
</Border>
</HierarchicalDataTemplate>
<Style TargetType="TreeViewItem">
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="Padding" Value="4" />
<Setter Property="FontSize" Value="12" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="#F5F5F5" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsRatProcess}" Value="True">
<Setter Property="Foreground" Value="#9CFF9C" />
<Setter Property="FontWeight" Value="SemiBold" />
</DataTrigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{StaticResource RowHoverBrush}" />
<Setter Property="Foreground" Value="#FFFFFF" />
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource RowHoverBrush}" />
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="ScrollBar">
<Setter Property="Width" Value="12" />
<Setter Property="Background" Value="{StaticResource ScrollbarTrackBrush}" />
<Setter Property="Foreground" Value="{StaticResource ScrollbarThumbBrush}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}">
<Track x:Name="PART_Track"
IsDirectionReversed="true">
<Track.Thumb>
<Thumb x:Name="Thumb"
Background="{Binding RelativeSource={RelativeSource AncestorType=ScrollBar}, Path=Foreground}">
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border Background="{TemplateBinding Background}" CornerRadius="4" />
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Thumb" Property="Background" Value="{StaticResource ScrollbarThumbHoverBrush}" />
</Trigger>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Width" Value="Auto" />
<Setter Property="Height" Value="12" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Grid Background="Transparent">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Grid.Row="0"
Background="#1F1F1F"
BorderBrush="#2E2E2E"
BorderThickness="0,0,0,1"
Padding="10,6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0"
Orientation="Horizontal"
Cursor="Hand"
MouseLeftButtonUp="OnNameHeaderClick">
<TextBlock Text="Name"
Foreground="#F0F0F0"
FontSize="13"
FontWeight="SemiBold" />
<TextBlock Text="{Binding HeaderGlyphName}"
Foreground="#F0F0F0"
Margin="6,0,0,0" />
</StackPanel>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
Margin="16,0"
Cursor="Hand"
MouseLeftButtonUp="OnPidHeaderClick">
<TextBlock Text="PID"
Foreground="#F0F0F0"
FontSize="13"
FontWeight="SemiBold" />
<TextBlock Text="{Binding HeaderGlyphPid}"
Foreground="#F0F0F0"
Margin="6,0,0,0" />
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Horizontal"
Cursor="Hand"
MouseLeftButtonUp="OnTitleHeaderClick">
<TextBlock Text="Window Title"
Foreground="#F0F0F0"
FontSize="13"
FontWeight="SemiBold" />
<TextBlock Text="{Binding HeaderGlyphTitle}"
Foreground="#F0F0F0"
Margin="6,0,0,0" />
</StackPanel>
</Grid>
</Border>
<Border Grid.Row="1" Background="Transparent">
<TreeView x:Name="Tree"
ItemsSource="{Binding RootNodes}"
Background="Transparent"
BorderThickness="0"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
SelectedItemChanged="OnTreeSelected"
PreviewMouseRightButtonDown="OnTreePreviewRightMouse"
PreviewMouseWheel="OnTreePreviewMouseWheel" />
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,484 @@
using Pulsar.Common.Models;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
namespace Pulsar.Server.Controls.Wpf
{
public partial class ProcessTreeView : UserControl
{
private readonly ProcessTreeViewModel _viewModel = new ProcessTreeViewModel();
private ScrollViewer _scrollViewer;
public ProcessTreeView()
{
InitializeComponent();
DataContext = _viewModel;
Loaded += OnLoaded;
}
public event EventHandler<SortRequestedEventArgs> SortRequested;
public event EventHandler SelectedProcessChanged;
public Process SelectedProcess => (Tree.SelectedItem as ProcessTreeNode)?.Model;
public IReadOnlyList<Process> SelectedProcesses
{
get
{
var selected = SelectedProcess;
return selected != null ? new[] { selected } : Array.Empty<Process>();
}
}
// make sure these fields exist in the class
private List<ProcessTreeNode> _allNodes = new List<ProcessTreeNode>();
private int _searchIndex = -1;
// public so the form can call it (or you can call it from FindNext)
public void FlattenNodes()
{
_allNodes.Clear();
void Add(ProcessTreeNode node)
{
_allNodes.Add(node);
foreach (var child in node.Children)
Add(child);
}
foreach (var root in _viewModel.RootNodes)
Add(root);
}
private HashSet<int> _expandedNodeIds = new();
public void SaveExpandedNodes()
{
_expandedNodeIds.Clear();
foreach (var node in FlattenAllNodes())
if (node.IsExpanded)
_expandedNodeIds.Add(node.Model.Id);
}
private IEnumerable<ProcessTreeNode> FlattenAllNodes()
{
var list = new List<ProcessTreeNode>();
void Add(ProcessTreeNode n)
{
list.Add(n);
foreach (var c in n.Children) Add(c);
}
foreach (var root in _viewModel.RootNodes)
Add(root);
return list;
}
// Call this to find the next match for the given query.
// Query supports multiple words; all words must be matched (AND) across any of the fields.
public void FindNext(string query)
{
if (string.IsNullOrWhiteSpace(query)) return;
// ensure list is fresh
FlattenNodes();
if (_allNodes.Count == 0) return;
var keywords = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
.Select(k => k.Trim())
.Where(k => k.Length > 0)
.ToArray();
if (keywords.Length == 0) return;
// start searching from next index
_searchIndex = (_searchIndex + 1) % _allNodes.Count;
for (int i = 0; i < _allNodes.Count; i++)
{
int idx = (_searchIndex + i) % _allNodes.Count;
var node = _allNodes[idx];
bool matchesAll = keywords.All(k =>
(!string.IsNullOrEmpty(node.Name) && node.Name.IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0) ||
(!string.IsNullOrEmpty(node.WindowTitle) && node.WindowTitle.IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0) ||
node.Model.Id.ToString().IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0
);
if (matchesAll)
{
// deselect previous selection(s)
foreach (var n in _allNodes) n.IsSelected = false;
// select and expand this node
node.IsSelected = true;
node.IsExpanded = true;
// expand ancestors so the node is visible
ExpandAncestorsForNode(node);
// scroll into view if possible
var tvi = GetTreeViewItem(node);
tvi?.BringIntoView();
_searchIndex = idx;
break;
}
}
}
// very small helper: expand ancestors by walking from root — simple and non-invasive
private void ExpandAncestorsForNode(ProcessTreeNode target)
{
// walk all root branches and expand while searching for the target; when found, keep ancestors expanded
bool TryExpandPath(ProcessTreeNode node)
{
if (node == target) return true;
foreach (var child in node.Children)
{
if (TryExpandPath(child))
{
node.IsExpanded = true; // keep ancestor expanded
return true;
}
}
return false;
}
foreach (var root in _viewModel.RootNodes)
{
if (TryExpandPath(root)) break;
}
}
// Highlight processes by keyword(s) in Name, WindowTitle, or PID
public void FindProcesses(string query)
{
if (string.IsNullOrWhiteSpace(query)) return;
var keywords = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
void Highlight(ProcessTreeNode node)
{
node.IsSelected = keywords.All(k =>
(!string.IsNullOrEmpty(node.Name) && node.Name.IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0) ||
(!string.IsNullOrEmpty(node.WindowTitle) && node.WindowTitle.IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0) ||
node.Model.Id.ToString().Contains(k)
);
foreach (var child in node.Children)
Highlight(child);
}
foreach (var root in _viewModel.RootNodes)
Highlight(root);
}
// Clear all highlights / selections
public void ClearSearch()
{
void Clear(ProcessTreeNode node)
{
node.IsSelected = false;
foreach (var child in node.Children)
Clear(child);
}
foreach (var root in _viewModel.RootNodes)
Clear(root);
}
private void RestoreExpandedNodes()
{
foreach (var node in FlattenAllNodes())
node.IsExpanded = _expandedNodeIds.Contains(node.Model.Id);
}
public void UpdateProcesses(IEnumerable<Process> processes, ProcessTreeSortColumn sortColumn, bool ascending, int? ratPid)
{
// Save current selections and expanded state
var selectedIds = SelectedProcesses.Select(p => p.Id).ToArray();
SaveExpandedNodes();
_viewModel.Apply(processes, sortColumn, ascending, ratPid);
// Remove ExpandAll(), we want to restore only previous expansions
RestoreExpandedNodes();
// Restore selection
SelectProcessesById(selectedIds);
}
public void ExpandRoots()
{
foreach (var node in _viewModel.RootNodes)
node.IsExpanded = true;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
if (_scrollViewer == null)
_scrollViewer = FindDescendant<ScrollViewer>(Tree);
}
private void OnTreeSelected(object sender, RoutedPropertyChangedEventArgs<object> e)
{
SelectedProcessChanged?.Invoke(this, EventArgs.Empty);
}
private void OnNameHeaderClick(object sender, MouseButtonEventArgs e) => SortRequested?.Invoke(this, new SortRequestedEventArgs(ProcessTreeSortColumn.Name));
private void OnPidHeaderClick(object sender, MouseButtonEventArgs e) => SortRequested?.Invoke(this, new SortRequestedEventArgs(ProcessTreeSortColumn.Pid));
private void OnTitleHeaderClick(object sender, MouseButtonEventArgs e) => SortRequested?.Invoke(this, new SortRequestedEventArgs(ProcessTreeSortColumn.WindowTitle));
private void OnTreePreviewRightMouse(object sender, MouseButtonEventArgs e)
{
if (FindAncestor<TreeViewItem>((DependencyObject)e.OriginalSource) is TreeViewItem tvi)
{
tvi.IsSelected = true;
tvi.Focus();
}
}
public void SelectProcessesById(int[] ids)
{
if (ids == null || ids.Length == 0) return;
void RestoreSelection(ProcessTreeNode node)
{
node.IsSelected = ids.Contains(node.Model.Id);
foreach (var child in node.Children)
RestoreSelection(child);
}
foreach (var root in _viewModel.RootNodes)
RestoreSelection(root);
}
// Recursively get TreeViewItem for a node
private TreeViewItem GetTreeViewItem(object item)
{
return Tree.ItemContainerGenerator.ContainerFromItem(item) as TreeViewItem
?? FindContainerInChildren(Tree.ItemContainerGenerator, item);
}
private TreeViewItem FindContainerInChildren(ItemContainerGenerator parentGenerator, object item)
{
foreach (var child in parentGenerator.Items)
{
var tvi = parentGenerator.ContainerFromItem(child) as TreeViewItem;
if (tvi != null)
{
var childTvi = tvi.ItemContainerGenerator.ContainerFromItem(item) as TreeViewItem;
if (childTvi != null) return childTvi;
var recursive = FindContainerInChildren(tvi.ItemContainerGenerator, item);
if (recursive != null) return recursive;
}
}
return null;
}
private void OnTreePreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
if (_scrollViewer == null)
_scrollViewer = FindDescendant<ScrollViewer>(Tree);
if (_scrollViewer != null && e.Delta != 0)
{
var offset = _scrollViewer.VerticalOffset - e.Delta / 3.0;
_scrollViewer.ScrollToVerticalOffset(Math.Max(0, offset));
e.Handled = true;
}
}
private static T FindAncestor<T>(DependencyObject current) where T : DependencyObject
{
while (current != null)
{
if (current is T match) return match;
current = VisualTreeHelper.GetParent(current);
}
return null;
}
private static T FindDescendant<T>(DependencyObject parent) where T : DependencyObject
{
if (parent == null) return null;
for (int i = 0, count = VisualTreeHelper.GetChildrenCount(parent); i < count; i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is T match) return match;
var descendant = FindDescendant<T>(child);
if (descendant != null) return descendant;
}
return null;
}
}
public enum ProcessTreeSortColumn
{
Name = 0,
Pid = 1,
WindowTitle = 2
}
public sealed class SortRequestedEventArgs : EventArgs
{
public SortRequestedEventArgs(ProcessTreeSortColumn column) => Column = column;
public ProcessTreeSortColumn Column { get; }
}
internal sealed class ProcessTreeViewModel : INotifyPropertyChanged
{
public ObservableCollection<ProcessTreeNode> RootNodes { get; } = new ObservableCollection<ProcessTreeNode>();
private ProcessTreeSortColumn _sortColumn = ProcessTreeSortColumn.Name;
private bool _sortAscending = true;
public string HeaderGlyphName => BuildGlyph(ProcessTreeSortColumn.Name);
public string HeaderGlyphPid => BuildGlyph(ProcessTreeSortColumn.Pid);
public string HeaderGlyphTitle => BuildGlyph(ProcessTreeSortColumn.WindowTitle);
public event PropertyChangedEventHandler PropertyChanged;
public void Apply(IEnumerable<Process> processes, ProcessTreeSortColumn sortColumn, bool ascending, int? ratPid)
{
_sortColumn = sortColumn;
_sortAscending = ascending;
OnPropertyChanged(nameof(HeaderGlyphName));
OnPropertyChanged(nameof(HeaderGlyphPid));
OnPropertyChanged(nameof(HeaderGlyphTitle));
RootNodes.Clear();
var items = processes?.ToArray() ?? Array.Empty<Process>();
if (items.Length == 0) return;
var processById = items.ToDictionary(p => p.Id, p => p);
var children = new Dictionary<int, List<Process>>();
var roots = new List<Process>();
foreach (var process in items)
{
if (process.ParentId.HasValue && process.ParentId.Value > 0 && process.ParentId.Value != process.Id && processById.ContainsKey(process.ParentId.Value))
{
if (!children.TryGetValue(process.ParentId.Value, out var list))
{
list = new List<Process>();
children.Add(process.ParentId.Value, list);
}
list.Add(process);
}
else
{
roots.Add(process);
}
}
var comparer = new ProcessComparer(sortColumn, ascending);
roots.Sort(comparer);
var visited = new HashSet<int>();
foreach (var root in roots) AddNodeRecursive(root, null);
foreach (var process in items)
if (!visited.Contains(process.Id)) AddNodeRecursive(process, null);
void AddNodeRecursive(Process process, ProcessTreeNode parent)
{
if (!visited.Add(process.Id)) return;
var node = new ProcessTreeNode(process, ratPid, parent == null);
if (parent == null) RootNodes.Add(node);
else parent.Children.Add(node);
if (children.TryGetValue(process.Id, out var childList))
{
childList.Sort(comparer);
foreach (var child in childList) AddNodeRecursive(child, node);
}
}
}
public void ExpandAll()
{
foreach (var node in RootNodes)
node.SetExpandedRecursive(true);
}
private string BuildGlyph(ProcessTreeSortColumn forColumn) => _sortColumn != forColumn ? string.Empty : (_sortAscending ? "▲" : "▼");
private void OnPropertyChanged([CallerMemberName] string propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
internal sealed class ProcessTreeNode : INotifyPropertyChanged
{
private bool _isSelected;
public bool IsSelected
{
get => _isSelected;
set { if (_isSelected != value) { _isSelected = value; OnPropertyChanged(); } }
}
public ProcessTreeNode(Process model, int? ratPid, bool expandByDefault)
{
Model = model;
Children = new ObservableCollection<ProcessTreeNode>();
_isExpanded = expandByDefault;
IsRatProcess = ratPid.HasValue && model.Id == ratPid.Value;
_foreground = IsRatProcess ? new SolidColorBrush(Color.FromRgb(140, 255, 140)) : (Brush)new SolidColorBrush(Color.FromRgb(230, 230, 230));
}
public Process Model { get; }
public ObservableCollection<ProcessTreeNode> Children { get; }
public bool IsRatProcess { get; }
public string Name => string.IsNullOrWhiteSpace(Model.Name) ? "(unknown)" : Model.Name;
public string PidDisplay => Model.Id.ToString();
public string WindowTitle => string.IsNullOrWhiteSpace(Model.MainWindowTitle) ? string.Empty : Model.MainWindowTitle;
private bool _isExpanded;
public bool IsExpanded
{
get => _isExpanded;
set { if (_isExpanded != value) { _isExpanded = value; OnPropertyChanged(); } }
}
private readonly Brush _foreground;
public Brush Foreground => _foreground;
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
public void SetExpandedRecursive(bool isExpanded) { IsExpanded = isExpanded; foreach (var child in Children) child.SetExpandedRecursive(isExpanded); }
}
internal sealed class ProcessComparer : IComparer<Process>
{
private readonly ProcessTreeSortColumn _column;
private readonly bool _ascending;
public ProcessComparer(ProcessTreeSortColumn column, bool ascending) { _column = column; _ascending = ascending; }
public int Compare(Process x, Process y)
{
if (ReferenceEquals(x, y)) return 0;
if (x is null) return _ascending ? -1 : 1;
if (y is null) return _ascending ? 1 : -1;
int result = _column switch
{
ProcessTreeSortColumn.Pid => x.Id.CompareTo(y.Id),
ProcessTreeSortColumn.WindowTitle => string.Compare(x.MainWindowTitle ?? string.Empty, y.MainWindowTitle ?? string.Empty, StringComparison.CurrentCultureIgnoreCase),
_ => string.Compare(x.Name ?? string.Empty, y.Name ?? string.Empty, StringComparison.CurrentCultureIgnoreCase)
};
if (result == 0) result = x.Id.CompareTo(y.Id);
return _ascending ? result : -result;
}
}
}
@@ -0,0 +1,36 @@
using System;
using System.Windows.Input;
#nullable enable
namespace Pulsar.Server.Controls.Wpf
{
internal sealed class RelayCommand<T> : ICommand
{
private readonly Action<T?> _execute;
private readonly Func<T?, bool>? _canExecute;
public RelayCommand(Action<T?> execute, Func<T?, bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter)
{
return _canExecute?.Invoke((T?)parameter) ?? true;
}
public void Execute(object? parameter)
{
_execute((T?)parameter);
}
public void RaiseCanExecuteChanged()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}
}
+275
View File
@@ -0,0 +1,275 @@
<UserControl x:Class="Pulsar.Server.Controls.Wpf.StatsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignWidth="900"
d:DesignHeight="620">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<SolidColorBrush x:Key="StatsBackgroundBrush" Color="#FFFFFFFF" />
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFF5F5F5" />
<SolidColorBrush x:Key="CardBorderBrush" Color="#FFE0E0E0" />
<SolidColorBrush x:Key="CardForegroundBrush" Color="#FF1F1F1F" />
<SolidColorBrush x:Key="SectionHeaderBrush" Color="#FF1F1F1F" />
<SolidColorBrush x:Key="MutedTextBrush" Color="#FF5F6368" />
<SolidColorBrush x:Key="AccentBrush" Color="#FF1976D2" />
<SolidColorBrush x:Key="PositiveAccentBrush" Color="#FF2E7D32" />
<SolidColorBrush x:Key="NegativeAccentBrush" Color="#FFC62828" />
<SolidColorBrush x:Key="ChartBackgroundBrush" Color="#FFFFFFFF" />
<SolidColorBrush x:Key="ChartBorderBrush" Color="#FFE0E0E0" />
<SolidColorBrush x:Key="ScrollBarTrackBrush" Color="#FFE5E5E5" />
<SolidColorBrush x:Key="ScrollBarThumbBrush" Color="#FFB5B5B5" />
<SolidColorBrush x:Key="ScrollBarThumbHoverBrush" Color="#FF9E9E9E" />
<SolidColorBrush x:Key="ScrollBarThumbPressedBrush" Color="#FF7C7C7C" />
<Style x:Key="RightAlignedCell" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style x:Key="SlimScrollBarThumbStyle" TargetType="Thumb">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbBrush}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border Background="{TemplateBinding Background}" CornerRadius="4" />
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbHoverBrush}" />
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbPressedBrush}" />
</Trigger>
</Style.Triggers>
</Style>
<ControlTemplate x:Key="SlimVerticalScrollBarTemplate" TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}" Width="{TemplateBinding Width}">
<Grid.RowDefinitions>
<RowDefinition Height="0" />
<RowDefinition Height="*" />
<RowDefinition Height="0" />
</Grid.RowDefinitions>
<Track x:Name="PART_Track"
Grid.Row="1"
IsDirectionReversed="True"
Orientation="Vertical"
Maximum="{TemplateBinding Maximum}"
Minimum="{TemplateBinding Minimum}"
Value="{TemplateBinding Value}"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.Thumb>
<Thumb Style="{StaticResource SlimScrollBarThumbStyle}" />
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
<ControlTemplate x:Key="SlimHorizontalScrollBarTemplate" TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}" Height="{TemplateBinding Height}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="0" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="0" />
</Grid.ColumnDefinitions>
<Track x:Name="PART_Track"
Grid.Column="1"
Orientation="Horizontal"
Maximum="{TemplateBinding Maximum}"
Minimum="{TemplateBinding Minimum}"
Value="{TemplateBinding Value}"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.Thumb>
<Thumb Style="{StaticResource SlimScrollBarThumbStyle}" />
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
<Style x:Key="SlimScrollBarStyle" TargetType="ScrollBar">
<Setter Property="Background" Value="{DynamicResource ScrollBarTrackBrush}" />
<Setter Property="Width" Value="10" />
<Setter Property="Template" Value="{StaticResource SlimVerticalScrollBarTemplate}" />
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Height" Value="10" />
<Setter Property="Width" Value="Auto" />
<Setter Property="Template" Value="{StaticResource SlimHorizontalScrollBarTemplate}" />
</Trigger>
</Style.Triggers>
</Style>
</UserControl.Resources>
<Grid x:Name="LayoutRoot" Background="{StaticResource StatsBackgroundBrush}">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Background="Transparent"
Visibility="{Binding IsContentVisible, Converter={StaticResource BoolToVisibilityConverter}}">
<ScrollViewer.Resources>
<Style TargetType="ScrollBar" BasedOn="{StaticResource SlimScrollBarStyle}" />
</ScrollViewer.Resources>
<StackPanel Margin="24">
<TextBlock Text="Overview"
FontSize="20"
FontWeight="SemiBold"
Foreground="{StaticResource SectionHeaderBrush}" />
<ItemsControl ItemsSource="{Binding StatCards}" Margin="0,16,0,24">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="2" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Margin="8"
Padding="16"
CornerRadius="12"
Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1">
<StackPanel>
<TextBlock Text="{Binding Title}"
FontSize="14"
FontWeight="SemiBold"
Foreground="{StaticResource CardForegroundBrush}" />
<TextBlock x:Name="ValueText"
Text="{Binding Value}"
FontSize="28"
FontWeight="Bold"
Margin="0,8,0,4"
Foreground="{StaticResource AccentBrush}" />
<TextBlock Text="{Binding Subtitle}"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Opacity="0.7" />
</StackPanel>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding Title}" Value="Online Now">
<Setter TargetName="ValueText"
Property="Foreground"
Value="{StaticResource PositiveAccentBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding Title}" Value="Offline">
<Setter TargetName="ValueText"
Property="Foreground"
Value="{StaticResource NegativeAccentBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid Margin="0,0,0,24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0"
Margin="0,0,12,0"
Padding="16"
CornerRadius="12"
Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1">
<StackPanel>
<TextBlock Text="New clients per day"
FontSize="16"
FontWeight="SemiBold"
Margin="0,0,0,12"
Foreground="{StaticResource SectionHeaderBrush}" />
<ContentControl x:Name="NewClientsChartHost"
Height="240"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch" />
</StackPanel>
</Border>
<StackPanel Grid.Column="1">
<Border Padding="16"
Margin="0,0,0,12"
CornerRadius="12"
Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1">
<StackPanel>
<TextBlock Text="Clients by country"
FontSize="16"
FontWeight="SemiBold"
Margin="0,0,0,12"
Foreground="{StaticResource SectionHeaderBrush}" />
<ContentControl x:Name="CountryChartHost"
Height="160"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch" />
</StackPanel>
</Border>
<Border Padding="16"
CornerRadius="12"
Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1">
<StackPanel>
<TextBlock Text="Clients by operating system"
FontSize="16"
FontWeight="SemiBold"
Margin="0,0,0,12"
Foreground="{StaticResource SectionHeaderBrush}" />
<ContentControl x:Name="OperatingSystemChartHost"
Height="160"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch" />
</StackPanel>
</Border>
</StackPanel>
</Grid>
<TextBlock Text="{Binding LastUpdated}"
Margin="0,16,0,0"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Opacity="0.7" />
</StackPanel>
</ScrollViewer>
<Border Background="#AA000000"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="Loading statistics..."
Foreground="White"
FontSize="18"
FontWeight="SemiBold"
TextAlignment="Center" />
</StackPanel>
</Border>
<Border Background="#33FF0000"
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibilityConverter}}">
<Border Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="24"
HorizontalAlignment="Center"
VerticalAlignment="Center"
MaxWidth="420">
<StackPanel>
<TextBlock Text="Unable to load statistics"
FontSize="18"
FontWeight="SemiBold"
Foreground="{StaticResource CardForegroundBrush}"
TextAlignment="Center" />
<TextBlock Text="{Binding ErrorMessage}"
Margin="0,12,0,0"
TextWrapping="Wrap"
Foreground="{StaticResource CardForegroundBrush}"
Opacity="0.8"
TextAlignment="Center" />
</StackPanel>
</Border>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,179 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Media;
using LiveChartsCore.SkiaSharpView.WPF;
using Pulsar.Server.Statistics;
#nullable enable
namespace Pulsar.Server.Controls.Wpf
{
public partial class StatsView : UserControl
{
private readonly StatsViewModel _viewModel;
private readonly CartesianChart _newClientsChart;
private readonly PieChart _countryChart;
private readonly PieChart _operatingSystemChart;
public StatsView()
{
InitializeComponent();
_viewModel = new StatsViewModel();
DataContext = _viewModel;
Dispatcher.UnhandledException += OnDispatcherUnhandledException;
_newClientsChart = CreateCartesianChart();
_countryChart = CreatePieChart();
_operatingSystemChart = CreatePieChart();
NewClientsChartHost.Content = _newClientsChart;
CountryChartHost.Content = _countryChart;
OperatingSystemChartHost.Content = _operatingSystemChart;
Bind(_newClientsChart, CartesianChart.SeriesProperty, nameof(StatsViewModel.NewClientsSeries));
Bind(_newClientsChart, CartesianChart.XAxesProperty, nameof(StatsViewModel.NewClientsXAxes));
Bind(_newClientsChart, CartesianChart.YAxesProperty, nameof(StatsViewModel.NewClientsYAxes));
Bind(_countryChart, PieChart.SeriesProperty, nameof(StatsViewModel.ClientsByCountrySeries));
Bind(_operatingSystemChart, PieChart.SeriesProperty, nameof(StatsViewModel.ClientsByOperatingSystemSeries));
}
private void OnDispatcherUnhandledException(object? sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
{
if (e.Exception is NullReferenceException &&
e.Exception.StackTrace?.Contains("LiveChartsCore.SkiaSharpView.WPF.Rendering.CompositionTargetTicker.DisposeTicker", StringComparison.Ordinal) == true)
{
e.Handled = true;
}
}
public void ShowLoading()
{
Dispatcher.Invoke(() => _viewModel.SetLoading());
}
public void ShowError(string message)
{
Dispatcher.Invoke(() => _viewModel.SetError(message));
}
public void UpdateSnapshot(ClientStatisticsSnapshot snapshot)
{
if (snapshot == null)
{
return;
}
Dispatcher.Invoke(() => _viewModel.UpdateSnapshot(snapshot));
}
public void ApplyTheme(bool isDarkMode)
{
Dispatcher.Invoke(() =>
{
UpdateBrush("StatsBackgroundBrush", isDarkMode ? "#FF1A1A1A" : "#FFFFFFFF");
UpdateBrush("CardBackgroundBrush", isDarkMode ? "#FF222327" : "#FFF5F5F5");
UpdateBrush("CardBorderBrush", isDarkMode ? "#FF2E3136" : "#FFE0E0E0");
UpdateBrush("CardForegroundBrush", isDarkMode ? "#FFE8EAED" : "#FF1F1F1F");
UpdateBrush("MutedTextBrush", isDarkMode ? "#FF9AA0A6" : "#FF5F6368");
UpdateBrush("AccentBrush", isDarkMode ? "#FF64B5F6" : "#FF1976D2");
UpdateBrush("PositiveAccentBrush", isDarkMode ? "#FF81C784" : "#FF2E7D32");
UpdateBrush("NegativeAccentBrush", isDarkMode ? "#FFEF5350" : "#FFC62828");
UpdateBrush("SectionHeaderBrush", isDarkMode ? "#FF64B5F6" : "#FF1976D2");
UpdateBrush("ChartBackgroundBrush", isDarkMode ? "#FF1E1F23" : "#FFFFFFFF");
UpdateBrush("ChartBorderBrush", isDarkMode ? "#FF2F3338" : "#FFE0E0E0");
UpdateBrush("ScrollBarTrackBrush", isDarkMode ? "#FF1E1E1E" : "#FFE5E5E5");
UpdateBrush("ScrollBarThumbBrush", isDarkMode ? "#FF444444" : "#FFB5B5B5");
UpdateBrush("ScrollBarThumbHoverBrush", isDarkMode ? "#FF5A5A5A" : "#FF9E9E9E");
UpdateBrush("ScrollBarThumbPressedBrush", isDarkMode ? "#FF737373" : "#FF7C7C7C");
LayoutRoot.Background = (Brush)Resources["StatsBackgroundBrush"];
ApplyChartTheme();
_viewModel.UpdateTheme(isDarkMode);
});
}
private void UpdateBrush(string resourceKey, string hex)
{
var color = (Color)ColorConverter.ConvertFromString(hex)!;
if (Resources[resourceKey] is SolidColorBrush brush)
{
if (!brush.IsFrozen)
{
brush.Color = color;
}
else
{
var mutable = brush.Clone();
mutable.Color = color;
Resources[resourceKey] = mutable;
}
}
else
{
Resources[resourceKey] = new SolidColorBrush(color);
}
}
private void ApplyChartTheme()
{
if (Resources["ChartBackgroundBrush"] is not SolidColorBrush chartBackgroundBrush)
{
return;
}
if (Resources["ChartBorderBrush"] is not SolidColorBrush chartBorderBrush)
{
return;
}
_newClientsChart.Background = chartBackgroundBrush;
_countryChart.Background = chartBackgroundBrush;
_operatingSystemChart.Background = chartBackgroundBrush;
_newClientsChart.BorderBrush = chartBorderBrush;
_countryChart.BorderBrush = chartBorderBrush;
_operatingSystemChart.BorderBrush = chartBorderBrush;
var borderThickness = new Thickness(1);
_newClientsChart.BorderThickness = borderThickness;
_countryChart.BorderThickness = borderThickness;
_operatingSystemChart.BorderThickness = borderThickness;
}
private static CartesianChart CreateCartesianChart()
{
return new CartesianChart
{
Height = 240,
Padding = new Thickness(8)
};
}
private static PieChart CreatePieChart()
{
return new PieChart
{
Height = 160,
Padding = new Thickness(8)
};
}
private static Binding CreateOneWayBinding(string path)
{
return new Binding(path)
{
Mode = BindingMode.OneWay,
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
};
}
private static void Bind(FrameworkElement element, DependencyProperty property, string path)
{
element.SetBinding(property, CreateOneWayBinding(path));
}
}
}
@@ -0,0 +1,383 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using LiveChartsCore;
using LiveChartsCore.Drawing;
using LiveChartsCore.Measure;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using Pulsar.Server.Statistics;
using SkiaSharp;
#nullable enable
namespace Pulsar.Server.Controls.Wpf
{
public sealed class StatsViewModel : INotifyPropertyChanged
{
private static readonly SKColor[] LightPalette =
{
SKColor.Parse("#1976D2"),
SKColor.Parse("#388E3C"),
SKColor.Parse("#F57C00"),
SKColor.Parse("#7B1FA2"),
SKColor.Parse("#C2185B"),
SKColor.Parse("#0097A7"),
SKColor.Parse("#AFB42B")
};
private static readonly SKColor[] DarkPalette =
{
SKColor.Parse("#64B5F6"),
SKColor.Parse("#81C784"),
SKColor.Parse("#FFB74D"),
SKColor.Parse("#BA68C8"),
SKColor.Parse("#F06292"),
SKColor.Parse("#4DD0E1"),
SKColor.Parse("#DCE775")
};
private readonly ObservableCollection<StatCardViewModel> _statCards = new()
{
new StatCardViewModel("Total Clients"),
new StatCardViewModel("Online Now"),
new StatCardViewModel("Offline"),
new StatCardViewModel("New (7 days)")
};
private ISeries[] _newClientsSeries = Array.Empty<ISeries>();
private Axis[] _newClientsXAxes = Array.Empty<Axis>();
private Axis[] _newClientsYAxes = Array.Empty<Axis>();
private ISeries[] _clientsByCountrySeries = Array.Empty<ISeries>();
private ISeries[] _clientsByOsSeries = Array.Empty<ISeries>();
private bool _isLoading;
private bool _hasError;
private string? _errorMessage;
private string _lastUpdated = "";
private bool _isDarkMode;
private ClientStatisticsSnapshot? _lastSnapshot;
public event PropertyChangedEventHandler? PropertyChanged;
public ReadOnlyObservableCollection<StatCardViewModel> StatCards { get; }
public ISeries[] NewClientsSeries
{
get => _newClientsSeries;
private set => SetField(ref _newClientsSeries, value);
}
public Axis[] NewClientsXAxes
{
get => _newClientsXAxes;
private set => SetField(ref _newClientsXAxes, value);
}
public Axis[] NewClientsYAxes
{
get => _newClientsYAxes;
private set => SetField(ref _newClientsYAxes, value);
}
public ISeries[] ClientsByCountrySeries
{
get => _clientsByCountrySeries;
private set => SetField(ref _clientsByCountrySeries, value);
}
public ISeries[] ClientsByOperatingSystemSeries
{
get => _clientsByOsSeries;
private set => SetField(ref _clientsByOsSeries, value);
}
public bool IsLoading
{
get => _isLoading;
private set
{
if (SetField(ref _isLoading, value))
{
OnPropertyChanged(nameof(IsContentVisible));
}
}
}
public bool HasError
{
get => _hasError;
private set
{
if (SetField(ref _hasError, value))
{
OnPropertyChanged(nameof(IsContentVisible));
}
}
}
public bool IsContentVisible => !IsLoading && !HasError;
public string? ErrorMessage
{
get => _errorMessage;
private set => SetField(ref _errorMessage, value);
}
public string LastUpdated
{
get => _lastUpdated;
private set => SetField(ref _lastUpdated, value);
}
public StatsViewModel()
{
StatCards = new ReadOnlyObservableCollection<StatCardViewModel>(_statCards);
}
public void SetLoading()
{
ErrorMessage = null;
HasError = false;
IsLoading = true;
}
public void SetError(string message)
{
_lastSnapshot = null;
ErrorMessage = message;
HasError = true;
IsLoading = false;
LastUpdated = string.Empty;
ClearSeries();
}
public void UpdateSnapshot(ClientStatisticsSnapshot snapshot)
{
_lastSnapshot = snapshot;
ErrorMessage = snapshot.ErrorMessage;
HasError = snapshot.HasError;
IsLoading = false;
if (snapshot.HasError)
{
LastUpdated = string.Empty;
ClearSeries();
return;
}
LastUpdated = $"Updated {snapshot.GeneratedAtUtc.ToLocalTime():g}";
UpdateCards(snapshot);
BuildSeries();
}
public void UpdateTheme(bool isDarkMode)
{
_isDarkMode = isDarkMode;
BuildSeries();
}
private void UpdateCards(ClientStatisticsSnapshot snapshot)
{
_statCards[0].Update(snapshot.TotalClients.ToString("N0"), "Unique clients recorded");
_statCards[1].Update(snapshot.OnlineClients.ToString("N0"), "Currently connected");
_statCards[2].Update(snapshot.OfflineClients.ToString("N0"), "Seen but offline");
_statCards[3].Update(snapshot.NewClientsLast7Days.ToString("N0"), "Joined in last 7 days");
}
private void BuildSeries()
{
if (_lastSnapshot == null || _lastSnapshot.HasError)
{
ClearSeries();
return;
}
var accent = GetAccentColor();
var axisText = GetAxisTextColor();
var separator = GetSeparatorColor();
var dailyValues = _lastSnapshot.NewClientsByDay.Select(d => d.Count).ToArray();
var labels = _lastSnapshot.NewClientsByDay.Select(d => d.Date.ToString("MMM dd")).ToArray();
NewClientsSeries = new ISeries[]
{
CreateColumnSeries(dailyValues, accent, axisText)
};
NewClientsXAxes = new[]
{
new Axis
{
Labels = labels,
LabelsPaint = new SolidColorPaint(axisText),
Name = "Day",
NamePaint = new SolidColorPaint(axisText),
TextSize = 13,
Padding = new Padding(10, 0, 10, 0),
SeparatorsPaint = new SolidColorPaint(separator) { StrokeThickness = 1 }
}
};
NewClientsYAxes = new[]
{
new Axis
{
LabelsPaint = new SolidColorPaint(axisText),
TextSize = 13,
Name = "Clients",
NamePaint = new SolidColorPaint(axisText),
MinLimit = 0,
SeparatorsPaint = new SolidColorPaint(separator) { StrokeThickness = 1 }
}
};
ClientsByCountrySeries = BuildPieSeries(_lastSnapshot.ClientsByCountry);
ClientsByOperatingSystemSeries = BuildPieSeries(_lastSnapshot.ClientsByOperatingSystem);
}
private ISeries[] BuildPieSeries(IReadOnlyCollection<CategoryCount> categories)
{
if (categories == null || categories.Count == 0)
{
return Array.Empty<ISeries>();
}
var palette = _isDarkMode ? DarkPalette : LightPalette;
var axisText = GetAxisTextColor();
var stroke = GetSeparatorColor();
var series = categories
.Select((entry, index) =>
{
var pieSeries = new PieSeries<int>
{
Values = new[] { entry.Count },
Name = entry.Label,
Fill = new SolidColorPaint(palette[index % palette.Length]),
Stroke = new SolidColorPaint(stroke) { StrokeThickness = 1.5f },
DataLabelsPaint = new SolidColorPaint(axisText),
DataLabelsSize = 12,
DataLabelsPosition = PolarLabelsPosition.Middle,
DataLabelsFormatter = point =>
{
var value = point.Model;
return value > 0
? $"{entry.Label}: {value:N0} ({entry.Share:P1})"
: entry.Label;
}
};
return pieSeries;
})
.Cast<ISeries>()
.ToArray();
return series;
}
private static ColumnSeries<int> CreateColumnSeries(int[] values, SKColor accent, SKColor axisText)
{
var series = new ColumnSeries<int>
{
Values = values,
Fill = new SolidColorPaint(accent),
Stroke = null
};
if (values.Length <= 10 && values.Any(v => v > 0))
{
series.DataLabelsPaint = new SolidColorPaint(axisText);
series.DataLabelsPosition = LiveChartsCore.Measure.DataLabelsPosition.Top;
series.DataLabelsFormatter = point => point.Model.ToString("N0");
}
return series;
}
private void ClearSeries()
{
NewClientsSeries = Array.Empty<ISeries>();
NewClientsXAxes = Array.Empty<Axis>();
NewClientsYAxes = Array.Empty<Axis>();
ClientsByCountrySeries = Array.Empty<ISeries>();
ClientsByOperatingSystemSeries = Array.Empty<ISeries>();
}
private SKColor GetAccentColor() => _isDarkMode ? SKColor.Parse("#64B5F6") : SKColor.Parse("#1E88E5");
private SKColor GetAxisTextColor() => _isDarkMode ? SKColors.White : SKColor.Parse("#1A1A1A");
private SKColor GetSeparatorColor() => _isDarkMode ? SKColor.Parse("#424242") : SKColor.Parse("#BDBDBD");
private bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
public sealed class StatCardViewModel : INotifyPropertyChanged
{
private string _value = "0";
private string _subtitle = string.Empty;
public StatCardViewModel(string title)
{
Title = title;
}
public string Title { get; }
public string Value
{
get => _value;
private set => SetField(ref _value, value);
}
public string Subtitle
{
get => _subtitle;
private set => SetField(ref _subtitle, value);
}
public event PropertyChangedEventHandler? PropertyChanged;
public void Update(string value, string subtitle)
{
Value = value;
Subtitle = subtitle;
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
}
}