initial commit
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user