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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/ManagedDrive.App/Controls/SpeedHistoryChart.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<UserControl x:Class="ManagedDrive.App.Controls.SpeedHistoryChart"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="320">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid Grid.Column="0" Height="140" Margin="0,0,4,0">
<TextBlock x:Name="AxisMaxLabel" FontSize="10" HorizontalAlignment="Right" VerticalAlignment="Top"
Foreground="{DynamicResource AppForegroundLight}"/>
<TextBlock x:Name="AxisMidLabel" FontSize="10" HorizontalAlignment="Right" VerticalAlignment="Center"
Foreground="{DynamicResource AppForegroundLight}"/>
<TextBlock x:Name="AxisZeroLabel" FontSize="10" HorizontalAlignment="Right" VerticalAlignment="Bottom"
Foreground="{DynamicResource AppForegroundLight}"/>
</Grid>
<Grid x:Name="ChartArea" Grid.Column="1" Height="140" ClipToBounds="True"
SizeChanged="ChartArea_SizeChanged">
<Line x:Name="TopGridLine" Stroke="{DynamicResource AppDivider}" StrokeThickness="1" StrokeDashArray="2,2"/>
<Line x:Name="MidGridLine" Stroke="{DynamicResource AppDivider}" StrokeThickness="1" StrokeDashArray="2,2"/>
<Line x:Name="BottomGridLine" Stroke="{DynamicResource AppDivider}" StrokeThickness="1"/>
<Polyline x:Name="ReadLine" Stroke="{DynamicResource AppPrimary}" StrokeThickness="2"/>
<Polyline x:Name="WriteLine" Stroke="{DynamicResource AppChartWrite}" StrokeThickness="2"/>
</Grid>
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,6,0,0" HorizontalAlignment="Right">
<Rectangle Width="10" Height="10" Fill="{DynamicResource AppPrimary}" Margin="0,0,4,0"/>
<TextBlock Text="{DynamicResource Card.ReadSpeed}" FontSize="11"
Foreground="{DynamicResource AppForegroundLight}" Margin="0,0,12,0"/>
<Rectangle Width="10" Height="10" Fill="{DynamicResource AppChartWrite}" Margin="0,0,4,0"/>
<TextBlock Text="{DynamicResource Card.WriteSpeed}" FontSize="11"
Foreground="{DynamicResource AppForegroundLight}"/>
</StackPanel>
</StackPanel>
</UserControl>
119 changes: 119 additions & 0 deletions src/ManagedDrive.App/Controls/SpeedHistoryChart.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using System.Windows.Media;
using ManagedDrive.Cli.Core;

namespace ManagedDrive.App.Controls;

/// <summary>
/// Renders a fixed-size read/write speed history as two overlaid polylines. Intended for use
/// inside a hover-triggered <see cref="System.Windows.Controls.Primitives.Popup"/> — it is not
/// rendered while off-screen, so no visibility/throttling logic lives here.
/// </summary>
public partial class SpeedHistoryChart
{
/// <summary>Identifies the <see cref="ReadHistory"/> dependency property.</summary>
public static readonly DependencyProperty ReadHistoryProperty = DependencyProperty.Register(
nameof(ReadHistory), typeof(IReadOnlyList<double>), typeof(SpeedHistoryChart),
new PropertyMetadata(null, OnHistoryChanged));

/// <summary>Identifies the <see cref="WriteHistory"/> dependency property.</summary>
public static readonly DependencyProperty WriteHistoryProperty = DependencyProperty.Register(
nameof(WriteHistory), typeof(IReadOnlyList<double>), typeof(SpeedHistoryChart),
new PropertyMetadata(null, OnHistoryChanged));

/// <summary>
/// Initializes a new instance of <see cref="SpeedHistoryChart"/>.
/// </summary>
public SpeedHistoryChart()
{
InitializeComponent();
}

/// <summary>
/// Gets or sets the oldest-first read-speed history (bytes/sec) to plot.
/// </summary>
public IReadOnlyList<double>? ReadHistory
{
get => (IReadOnlyList<double>?)GetValue(ReadHistoryProperty);
set => SetValue(ReadHistoryProperty, value);
}

/// <summary>
/// Gets or sets the oldest-first write-speed history (bytes/sec) to plot.
/// </summary>
public IReadOnlyList<double>? WriteHistory
{
get => (IReadOnlyList<double>?)GetValue(WriteHistoryProperty);
set => SetValue(WriteHistoryProperty, value);
}

/// <summary>
/// Maps a sequence of values onto polyline points that fit within
/// <paramref name="width"/> x <paramref name="height"/>, scaled by the largest value across
/// both series (<paramref name="scaleMax"/>) so the read and write lines share one scale.
/// A flat, near-baseline line is returned when every value is zero, rather than an empty
/// point list, so the chart never looks unloaded.
/// </summary>
public static PointCollection NormalizePoints(IReadOnlyList<double> values, double width, double height, double scaleMax)
{
var points = new PointCollection();
if (values.Count == 0)
{
return points;
}

var stepX = values.Count > 1 ? width / (values.Count - 1) : 0;
for (var i = 0; i < values.Count; i++)
{
var x = i * stepX;
var y = scaleMax > 0
? height - (values[i] / scaleMax * height)
: height;
points.Add(new(x, y));
}

return points;
}

private static void OnHistoryChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) =>
((SpeedHistoryChart)d).Redraw();

private void ChartArea_SizeChanged(object sender, SizeChangedEventArgs e) => Redraw();

private void Redraw()
{
var width = ChartArea.ActualWidth;
var height = ChartArea.ActualHeight;
if (width <= 0 || height <= 0)
{
return;
}

var read = ReadHistory ?? Array.Empty<double>();
var write = WriteHistory ?? Array.Empty<double>();
var scaleMax = Math.Max(
read.Count > 0 ? read.Max() : 0.0,
write.Count > 0 ? write.Max() : 0.0);

ReadLine.Points = NormalizePoints(read, width, height, scaleMax);
WriteLine.Points = NormalizePoints(write, width, height, scaleMax);

TopGridLine.X1 = 0;
TopGridLine.Y1 = 0;
TopGridLine.X2 = width;
TopGridLine.Y2 = 0;

MidGridLine.X1 = 0;
MidGridLine.Y1 = height / 2;
MidGridLine.X2 = width;
MidGridLine.Y2 = height / 2;

BottomGridLine.X1 = 0;
BottomGridLine.Y1 = height;
BottomGridLine.X2 = width;
BottomGridLine.Y2 = height;

AxisMaxLabel.Text = scaleMax > 0 ? ByteFormatter.FormatRate(scaleMax) : string.Empty;
AxisMidLabel.Text = scaleMax > 0 ? ByteFormatter.FormatRate(scaleMax / 2) : string.Empty;
AxisZeroLabel.Text = "0 B/s";
}
}
1 change: 1 addition & 0 deletions src/ManagedDrive.App/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
global using ManagedDrive.App.Cli;
global using ManagedDrive.App.Controls;
global using ManagedDrive.App.Infrastructure;
global using ManagedDrive.App.Localization;
global using ManagedDrive.App.Models;
Expand Down
2 changes: 2 additions & 0 deletions src/ManagedDrive.App/Localization/Strings.en-US.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@
<sys:String x:Key="Card.NeverWritten">Not yet modified</sys:String>
<sys:String x:Key="Card.LastAutoSavePrefix">Last image save: {0}</sys:String>
<sys:String x:Key="Card.NeverAutoSaved">Image not yet saved</sys:String>
<sys:String x:Key="Card.ReadSpeed">Read speed</sys:String>
<sys:String x:Key="Card.WriteSpeed">Write speed</sys:String>
<sys:String x:Key="Time.JustNow">Just now</sys:String>
<sys:String x:Key="Time.MinutesAgo">{0} min ago</sys:String>
<sys:String x:Key="Time.HoursAgo">{0} hours ago</sys:String>
Expand Down
2 changes: 2 additions & 0 deletions src/ManagedDrive.App/Localization/Strings.zh-CN.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@
<sys:String x:Key="Card.NeverWritten">尚未写入</sys:String>
<sys:String x:Key="Card.LastAutoSavePrefix">上次保存镜像:{0}</sys:String>
<sys:String x:Key="Card.NeverAutoSaved">镜像尚未保存</sys:String>
<sys:String x:Key="Card.ReadSpeed">读取速度</sys:String>
<sys:String x:Key="Card.WriteSpeed">写入速度</sys:String>
<sys:String x:Key="Time.JustNow">刚才</sys:String>
<sys:String x:Key="Time.MinutesAgo">{0} 分钟前</sys:String>
<sys:String x:Key="Time.HoursAgo">{0} 小时前</sys:String>
Expand Down
32 changes: 32 additions & 0 deletions src/ManagedDrive.App/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:ManagedDrive.App.ViewModels"
xmlns:infra="clr-namespace:ManagedDrive.App.Infrastructure"
xmlns:controls="clr-namespace:ManagedDrive.App.Controls"
mc:Ignorable="d"
Style="{StaticResource AppWindow}"
WindowStyle="None"
Expand Down Expand Up @@ -400,6 +401,37 @@
Foreground="{DynamicResource AppForegroundLight}"
Visibility="{Binding ShowLastAutoSave, Converter={StaticResource BoolToVis}}"/>
</StackPanel>

<!-- Read/write throughput; hovering opens a history-chart popup (see below) -->
<StackPanel x:Name="SpeedRow" Orientation="Horizontal" Margin="0,2,0,0"
Visibility="{Binding IsNotReadOnly, Converter={StaticResource BoolToVis}}"
MouseEnter="SpeedRow_MouseEnter" MouseLeave="SpeedRow_MouseLeave">
<TextBlock Text="&#xE898;" FontFamily="Segoe Fluent Icons" FontSize="10"
Foreground="{DynamicResource AppForegroundLight}"
VerticalAlignment="Center" Margin="0,0,4,0"
ToolTip="{DynamicResource Card.ReadSpeed}"
AutomationProperties.Name="{DynamicResource Card.ReadSpeed}"/>
<TextBlock Text="{Binding ReadSpeedFormatted}" FontSize="11"
Foreground="{DynamicResource AppForegroundLight}"/>
<TextBlock Text="&#xE896;" FontFamily="Segoe Fluent Icons" FontSize="10"
Foreground="{DynamicResource AppForegroundLight}"
VerticalAlignment="Center" Margin="10,0,4,0"
ToolTip="{DynamicResource Card.WriteSpeed}"
AutomationProperties.Name="{DynamicResource Card.WriteSpeed}"/>
<TextBlock Text="{Binding WriteSpeedFormatted}" FontSize="11"
Foreground="{DynamicResource AppForegroundLight}"/>
<Popup x:Name="SpeedPopup" PlacementTarget="{Binding ElementName=SpeedRow}" Placement="Bottom"
StaysOpen="True" AllowsTransparency="True">
<Border Background="{DynamicResource AppSurface}"
BorderBrush="{DynamicResource AppDivider}"
BorderThickness="1" CornerRadius="4" Padding="8"
Tag="{Binding ElementName=SpeedPopup}"
MouseEnter="SpeedRow_MouseEnter" MouseLeave="SpeedRow_MouseLeave">
<controls:SpeedHistoryChart ReadHistory="{Binding ReadSpeedHistory}"
WriteHistory="{Binding WriteSpeedHistory}"/>
</Border>
</Popup>
</StackPanel>
</StackPanel>

<!-- Free space -->
Expand Down
73 changes: 73 additions & 0 deletions src/ManagedDrive.App/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;

namespace ManagedDrive.App;

/// <summary>
/// Interaction logic for MainWindow.xaml.
/// </summary>
public partial class MainWindow
{
private static readonly TimeSpan SpeedPopupCloseDelay = TimeSpan.FromMilliseconds(150);

private DispatcherTimer? _speedPopupCloseTimer;
private Popup? _pendingCloseSpeedPopup;

/// <summary>
/// Initializes the main window and binds the supplied view model.
/// </summary>
Expand Down Expand Up @@ -33,4 +42,68 @@ private void OpenAttachedContextMenu(object sender)
btn.ContextMenu.IsOpen = true;
}
}

/// <summary>
/// Opens the speed-history popup when the mouse enters the speed row or the popup itself,
/// cancelling any pending close scheduled for a different popup.
/// </summary>
private void SpeedRow_MouseEnter(object sender, MouseEventArgs e)
{
var popup = FindSpeedPopup(sender);
if (popup is null)
{
return;
}

if (_pendingCloseSpeedPopup is not null && _pendingCloseSpeedPopup != popup)
{
CloseSpeedPopup(_pendingCloseSpeedPopup);
}

_speedPopupCloseTimer?.Stop();
_pendingCloseSpeedPopup = null;
popup.IsOpen = true;
}

/// <summary>
/// Schedules the speed-history popup to close shortly after the mouse leaves the speed row
/// or the popup itself, so briefly crossing the gap between them doesn't flicker it shut.
/// </summary>
private void SpeedRow_MouseLeave(object sender, MouseEventArgs e)
{
var popup = FindSpeedPopup(sender);
if (popup is null)
{
return;
}

_speedPopupCloseTimer?.Stop();
_pendingCloseSpeedPopup = popup;
_speedPopupCloseTimer = new DispatcherTimer { Interval = SpeedPopupCloseDelay };
_speedPopupCloseTimer.Tick += (_, _) =>
{
_speedPopupCloseTimer?.Stop();
CloseSpeedPopup(popup);
_pendingCloseSpeedPopup = null;
};
_speedPopupCloseTimer.Start();
}

private static void CloseSpeedPopup(Popup popup) => popup.IsOpen = false;

/// <summary>
/// Resolves the speed-history <see cref="Popup"/> for a mouse event raised either by the
/// speed row itself or by the popup's own content border. The border's <c>Tag</c> is bound to
/// the popup by name in XAML (<c>Tag="{Binding ElementName=SpeedPopup}"</c>) rather than
/// resolved here via <c>FrameworkElement.Parent</c> — a <see cref="Popup"/>'s child is hosted
/// in a separate visual root, so relying on logical-tree parentage to hold at arbitrary
/// MouseEnter/MouseLeave timing is fragile; the explicit binding is resolved once when the
/// template loads, before any such event can fire.
/// </summary>
private static Popup? FindSpeedPopup(object sender) => sender switch
{
StackPanel speedRow => speedRow.Children.OfType<Popup>().FirstOrDefault(),
FrameworkElement { Tag: Popup popup } => popup,
_ => null,
};
}
40 changes: 40 additions & 0 deletions src/ManagedDrive.App/Services/DiskNotificationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ namespace ManagedDrive.App.Services;
/// </summary>
public sealed class DiskNotificationService
{
private readonly HashSet<DiskViewModel> _highUsageDisks = [];
private readonly Func<bool> _isMainWindowVisible;
private readonly MainViewModel _mainViewModel;
private readonly TrayIconController _trayIconController;
Expand All @@ -33,12 +34,19 @@ public DiskNotificationService(MainViewModel mainViewModel, TrayIconController t
vm.HighUsageWarning += OnDiskHighUsageWarning;
vm.SaveFailed += OnDiskSaveFailed;
vm.ActivityObserved += OnDiskActivityObserved;
vm.PropertyChanged += OnDiskPropertyChanged;
vm.SetActivityTrackingEnabled(_isMainWindowVisible());

if (vm is { CapacityAdjustedOnLoad: true, Disk.Options.SourceArchivePath: null })
{
OnDiskCapacityAdjusted(vm);
}

if (vm.IsHighUsage)
{
_highUsageDisks.Add(vm);
_trayIconController.SetHighUsageWarningActive(true);
}
}
}

Expand All @@ -49,6 +57,12 @@ public DiskNotificationService(MainViewModel mainViewModel, TrayIconController t
vm.HighUsageWarning -= OnDiskHighUsageWarning;
vm.SaveFailed -= OnDiskSaveFailed;
vm.ActivityObserved -= OnDiskActivityObserved;
vm.PropertyChanged -= OnDiskPropertyChanged;

if (_highUsageDisks.Remove(vm))
{
_trayIconController.SetHighUsageWarningActive(_highUsageDisks.Count > 0);
}
}
}
};
Expand Down Expand Up @@ -76,6 +90,32 @@ private void OnDiskCapacityAdjusted(DiskViewModel vm)
_mainViewModel.StatusText = Loc.Format("Status.CapacityAdjusted", vm.MountPoint, originalMb, newMb);
}

/// <summary>
/// Tracks every disk currently in the <see cref="DiskViewModel.IsHighUsage"/> state (which
/// fires on both the rising and falling edge, unlike the one-shot <see cref="DiskViewModel.HighUsageWarning"/>
/// event used for the balloon tip below) and reduces it to a single tray blink call: the tray
/// icon has no per-disk concept, so it only needs to know whether any disk is currently over
/// its threshold.
/// </summary>
private void OnDiskPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(DiskViewModel.IsHighUsage) || sender is not DiskViewModel vm)
{
return;
}

if (vm.IsHighUsage)
{
_highUsageDisks.Add(vm);
}
else
{
_highUsageDisks.Remove(vm);
}

_trayIconController.SetHighUsageWarningActive(_highUsageDisks.Count > 0);
}

private void OnDiskHighUsageWarning(object? sender, EventArgs e)
{
if (sender is not DiskViewModel vm)
Expand Down
Loading
Loading