Files
Libation/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs
T
Allamagoosa 986dda5eaa Review #1885: Avalonia parity, and cap concurrency by processor count
Chardonnay had parallel downloads with no way to configure them, since
the queue logic lives in shared UI code but each UI supplies its own
controls. Adds the Auto-scroll toggle and the 'At once' spinner to
Chardonnay's queue panel, bound to the same view model properties the
WinForms panel uses.

Also uses Environment.ProcessorCount as the spinner's ceiling rather
than its default: min(ProcessorCount, 10). Downloading is bound by
Audible's license throttling rather than local CPU, so core count says
nothing about how many concurrent downloads will succeed - it only
bounds how many decrypts can usefully run at once. The default stays 3.

Spinner bounds are bound rather than hardcoded, so the two UIs cannot
drift apart.
2026-08-16 14:08:49 -07:00

216 lines
7.1 KiB
C#

using LibationUiBase;
using LibationUiBase.ProcessQueue;
using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
namespace LibationWinForms.ProcessQueue;
internal partial class ProcessQueueControl : UserControl
{
public ProcessQueueViewModel ViewModel { get; } = new();
public ToolStripButton PopoutButton { get; } = new()
{
DisplayStyle = ToolStripItemDisplayStyle.Text,
Name = nameof(PopoutButton),
Text = "Pop Out",
TextAlign = ContentAlignment.MiddleCenter,
Alignment = ToolStripItemAlignment.Right,
Anchor = AnchorStyles.Bottom | AnchorStyles.Right,
};
public ProcessQueueControl()
{
InitializeComponent();
statusStrip1.Items.Add(PopoutButton);
virtualFlowControl2.ButtonClicked += VirtualFlowControl2_ButtonClicked;
virtualFlowControl2.DataContext = ViewModel.Queue;
queueNumberLbl.Image = Application.IsDarkModeEnabled ? Properties.Resources.queue_queued_dark : Properties.Resources.queue_queued;
errorNumberLbl.Image = Application.IsDarkModeEnabled ? Properties.Resources.queue_error_dark : Properties.Resources.queue_error;
completedNumberLbl.Image = Application.IsDarkModeEnabled ? Properties.Resources.queue_completed_dark : Properties.Resources.queue_completed;
logDGV.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled;
ViewModel.PropertyChanged += ProcessQueue_PropertyChanged;
ViewModel.LogEntries.CollectionChanged += LogEntries_CollectionChanged;
ViewModel.ProcessStart += Book_ProcessStart;
autoScrollChk.CheckedChanged += (s, e) => ViewModel.AutoScrollQueue = autoScrollChk.Checked;
concurrencyNum.Minimum = ViewModel.MinConcurrentDownloads;
concurrencyNum.Maximum = ViewModel.MaxAllowedConcurrentDownloads;
concurrencyNum.ValueChanged += (s, e) => ViewModel.MaxConcurrentDownloads = (int)concurrencyNum.Value;
ProcessQueue_PropertyChanged(this, new PropertyChangedEventArgs(null));
}
private void Book_ProcessStart(object? sender, ProcessBookViewModel e)
{
if (!ViewModel.AutoScrollQueue) return;
Invoke(() =>
{
// Pin the first active download to the top so all parallel in-progress items are visible.
int firstActiveIndex = ViewModel.Queue.Completed.Count;
if (firstActiveIndex < ViewModel.Queue.Count)
virtualFlowControl2.ScrollToTop(firstActiveIndex);
});
}
private void LogEntries_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (!IsDisposed && e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
{
foreach (var entry in e.NewItems?.OfType<LogEntry>() ?? [])
logDGV.Rows.Add(entry.LogDate, entry.LogMessage);
}
}
private async void cancelAllBtn_Click(object? sender, EventArgs e)
=> await ViewModel.CancelAllAsync();
private void btnClearFinished_Click(object? sender, EventArgs e)
{
ViewModel.Queue.ClearCompleted();
if (!ViewModel.Running)
runningTimeLbl.Text = string.Empty;
}
private void clearLogBtn_Click(object? sender, EventArgs e)
{
ViewModel.LogEntries.Clear();
logDGV.Rows.Clear();
}
private void LogCopyBtn_Click(object? sender, EventArgs e)
{
string logText = string.Join("\r\n", logDGV.Rows.Cast<DataGridViewRow>().Select(r => $"{r.Cells[0].Value}\t{r.Cells[1].Value}"));
Clipboard.SetDataObject(logText, false, 5, 150);
}
private void LogDGV_Resize(object? sender, EventArgs e)
{
logDGV.Columns[1].Width = logDGV.Width - logDGV.Columns[0].Width;
}
#region View-Model update event handling
private void ProcessQueue_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is null or nameof(ViewModel.QueuedCount))
{
queueNumberLbl.Text = ViewModel.QueuedCount.ToString();
queueNumberLbl.Visible = ViewModel.QueuedCount > 0;
}
if (e.PropertyName is null or nameof(ViewModel.ErrorCount))
{
errorNumberLbl.Text = ViewModel.ErrorCount.ToString();
errorNumberLbl.Visible = ViewModel.ErrorCount > 0;
}
if (e.PropertyName is null or nameof(ViewModel.CompletedCount))
{
completedNumberLbl.Text = ViewModel.CompletedCount.ToString();
completedNumberLbl.Visible = ViewModel.CompletedCount > 0;
}
if (e.PropertyName is null or nameof(ViewModel.Progress))
{
toolStripProgressBar1.Maximum = ViewModel.Queue.Count;
toolStripProgressBar1.Value = ViewModel.Queue.Completed.Count;
}
if (e.PropertyName is null or nameof(ViewModel.ProgressBarVisible))
{
toolStripProgressBar1.Visible = ViewModel.ProgressBarVisible;
}
if (e.PropertyName is null or nameof(ViewModel.RunningTime))
{
runningTimeLbl.Text = ViewModel.RunningTime;
}
if (e.PropertyName is null or nameof(ViewModel.SpeedLimit))
{
numericUpDown1.Value = ViewModel.SpeedLimit;
numericUpDown1.Increment = ViewModel.SpeedLimitIncrement;
numericUpDown1.DecimalPlaces = ViewModel.SpeedLimit >= 10 ? 0 : ViewModel.SpeedLimit >= 1 ? 1 : 2;
}
if (e.PropertyName is null or nameof(ViewModel.AutoScrollQueue))
autoScrollChk.Checked = ViewModel.AutoScrollQueue;
if (e.PropertyName is null or nameof(ViewModel.MaxConcurrentDownloads))
concurrencyNum.Value = ViewModel.MaxConcurrentDownloads;
}
/// <summary>
/// View notified the model that a button was clicked
/// </summary>
/// <param name="sender">the <see cref="ProcessBookControl"/> whose button was clicked</param>
/// <param name="buttonName">The name of the button clicked</param>
private async void VirtualFlowControl2_ButtonClicked(object? sender, string buttonName)
{
if (sender is not ProcessBookControl control || control.DataContext is not ProcessBookViewModel item)
return;
try
{
if (buttonName is nameof(ProcessBookControl.cancelBtn))
{
await item.CancelAsync();
ViewModel.Queue.RemoveQueued(item);
}
else
{
QueuePosition? position = buttonName switch
{
nameof(ProcessBookControl.moveFirstBtn) => QueuePosition.First,
nameof(ProcessBookControl.moveUpBtn) => QueuePosition.OneUp,
nameof(ProcessBookControl.moveDownBtn) => QueuePosition.OneDown,
nameof(ProcessBookControl.moveLastBtn) => QueuePosition.Last,
_ => null
};
if (position is not null)
ViewModel.Queue.MoveQueuePosition(item, position.Value);
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error handling button click from queued item");
}
}
#endregion
private void numericUpDown1_ValueChanged(object? sender, EventArgs e)
=> ViewModel.SpeedLimit = numericUpDown1.Value;
}
public class NumericUpDownSuffix : NumericUpDown
{
[Description("Suffix displayed after numeric value."), Category("Data")]
[Browsable(true)]
[EditorBrowsable(EditorBrowsableState.Always)]
[DisallowNull]
public string Suffix
{
get => _suffix;
set
{
base.Text = string.IsNullOrEmpty(_suffix) ? base.Text : base.Text.Replace(_suffix, value);
_suffix = value;
ChangingText = true;
}
}
private string _suffix = string.Empty;
[AllowNull]
public override string Text
{
get => string.IsNullOrEmpty(Suffix) ? base.Text : base.Text.Replace(Suffix, string.Empty);
set
{
if (Value == Minimum)
base.Text = "∞";
else
base.Text = value + Suffix;
}
}
}