Replaced dated download queue ListBox with custom ModernQueuePanel
Introduced ModernQueuePanel as a modern control for the download queue, supporting drag-and-drop reordering, auto-scroll, and entry removal via close buttons (replaced double-click) in a modernized look. Removed legacy ListBox event handlers and updated MainForm to use the new panel, synchronizing it with the queue's binding list and refactored UI logic for queue management and download cancellation
This commit is contained in:
@@ -194,6 +194,9 @@
|
||||
<Compile Include="ModernProgessBar.cs">
|
||||
<SubType>Component</SubType>
|
||||
</Compile>
|
||||
<Compile Include="ModernQueuePanel.cs">
|
||||
<SubType>Component</SubType>
|
||||
</Compile>
|
||||
<Compile Include="Properties\Resources.Designer.cs">
|
||||
<AutoGen>True</AutoGen>
|
||||
<DesignTime>True</DesignTime>
|
||||
|
||||
5
MainForm.Designer.cs
generated
5
MainForm.Designer.cs
generated
@@ -202,6 +202,7 @@ namespace AndroidSideloader
|
||||
//
|
||||
// gamesQueListBox
|
||||
//
|
||||
this.gamesQueListBox.AllowDrop = false;
|
||||
this.gamesQueListBox.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.gamesQueListBox.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(24)))), ((int)(((byte)(26)))), ((int)(((byte)(30)))));
|
||||
this.gamesQueListBox.BorderStyle = System.Windows.Forms.BorderStyle.None;
|
||||
@@ -215,10 +216,6 @@ namespace AndroidSideloader
|
||||
this.gamesQueListBox.Name = "gamesQueListBox";
|
||||
this.gamesQueListBox.Size = new System.Drawing.Size(266, 192);
|
||||
this.gamesQueListBox.TabIndex = 9;
|
||||
this.gamesQueListBox.MouseClick += new System.Windows.Forms.MouseEventHandler(this.gamesQueListBox_MouseClick);
|
||||
this.gamesQueListBox.DrawItem += new System.Windows.Forms.DrawItemEventHandler(this.gamesQueListBox_DrawItem);
|
||||
this.gamesQueListBox.DragDrop += new System.Windows.Forms.DragEventHandler(this.Form1_DragDrop);
|
||||
this.gamesQueListBox.DragEnter += new System.Windows.Forms.DragEventHandler(this.Form1_DragEnter);
|
||||
//
|
||||
// devicesComboBox
|
||||
//
|
||||
|
||||
280
MainForm.cs
280
MainForm.cs
@@ -127,7 +127,7 @@ namespace AndroidSideloader
|
||||
|
||||
_debounceTimer = new System.Windows.Forms.Timer { Interval = 100, Enabled = false };
|
||||
_debounceTimer.Tick += async (sender, e) => await RunSearch();
|
||||
gamesQueListBox.DataSource = gamesQueueList;
|
||||
|
||||
SetCurrentLogPath();
|
||||
StartTimers();
|
||||
|
||||
@@ -514,6 +514,9 @@ namespace AndroidSideloader
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure bottom panels are properly laid out
|
||||
LayoutBottomPanels();
|
||||
|
||||
webView21.Visible = settings.TrailersEnabled;
|
||||
|
||||
// Continue with Form1_Shown
|
||||
@@ -3091,6 +3094,7 @@ Additional Thanks & Resources
|
||||
public static bool updatedConfig = false;
|
||||
public static int steps = 0;
|
||||
public static bool gamesAreDownloading = false;
|
||||
private ModernQueuePanel _queuePanel;
|
||||
private readonly BindingList<string> gamesQueueList = new BindingList<string>();
|
||||
public static int quotaTries = 0;
|
||||
public static bool timerticked = false;
|
||||
@@ -3164,7 +3168,9 @@ If the problem persists, visit our Telegram (https://t.me/VRPirates) or Discord
|
||||
{
|
||||
speedLabel.Text = String.Empty;
|
||||
progressBar.Value = 0;
|
||||
gamesQueueList.RemoveAt(0);
|
||||
if (gamesQueueList.Count > 0)
|
||||
gamesQueueList.RemoveAt(0);
|
||||
_queuePanel?.Invalidate();
|
||||
}
|
||||
|
||||
public void SetProgress(float progress)
|
||||
@@ -3239,7 +3245,7 @@ If the problem persists, visit our Telegram (https://t.me/VRPirates) or Discord
|
||||
}
|
||||
|
||||
gamesAreDownloading = true;
|
||||
|
||||
if (_queuePanel != null) _queuePanel.IsDownloading = true;
|
||||
|
||||
//Do user json on firsttime
|
||||
if (settings.UserJsonOnGameInstall)
|
||||
@@ -3467,33 +3473,44 @@ If the problem persists, visit our Telegram (https://t.me/VRPirates) or Discord
|
||||
|
||||
if (removedownloading)
|
||||
{
|
||||
removedownloading = false;
|
||||
|
||||
// Store game info before removing from queue
|
||||
string cancelledGame = gameName;
|
||||
string cancelledHash = gameNameHash;
|
||||
|
||||
// Remove the cancelled item from queue
|
||||
if (gamesQueueList.Count > 0)
|
||||
{
|
||||
gamesQueueList.RemoveAt(0);
|
||||
}
|
||||
|
||||
// Reset progress UI
|
||||
speedLabel.Text = String.Empty;
|
||||
progressBar.Value = 0;
|
||||
|
||||
// Ask about keeping files
|
||||
changeTitle("Keep game files?");
|
||||
try
|
||||
{
|
||||
cleanupActiveDownloadStatus();
|
||||
|
||||
DialogResult res = FlexibleMessageBox.Show(
|
||||
$"{gameName} exists in destination directory, do you want to delete it?\n\nClick NO to keep the files if you wish to resume your download later.",
|
||||
$"{cancelledGame} download was cancelled. Do you want to delete the partial files?\n\nClick NO to keep the files if you wish to resume your download later.",
|
||||
"Delete Temporary Files?", MessageBoxButtons.YesNo);
|
||||
|
||||
if (res == DialogResult.Yes)
|
||||
{
|
||||
changeTitle("Deleting game files");
|
||||
changeTitle("Deleting game files...");
|
||||
if (UsingPublicConfig)
|
||||
{
|
||||
if (Directory.Exists($"{settings.DownloadDir}\\{gameNameHash}"))
|
||||
{
|
||||
Directory.Delete($"{settings.DownloadDir}\\{gameNameHash}", true);
|
||||
}
|
||||
|
||||
if (Directory.Exists($"{settings.DownloadDir}\\{gameName}"))
|
||||
{
|
||||
Directory.Delete($"{settings.DownloadDir}\\{gameName}", true);
|
||||
}
|
||||
if (Directory.Exists($"{settings.DownloadDir}\\{cancelledHash}"))
|
||||
Directory.Delete($"{settings.DownloadDir}\\{cancelledHash}", true);
|
||||
if (Directory.Exists($"{settings.DownloadDir}\\{cancelledGame}"))
|
||||
Directory.Delete($"{settings.DownloadDir}\\{cancelledGame}", true);
|
||||
}
|
||||
else
|
||||
{
|
||||
Directory.Delete(settings.DownloadDir + "\\" + gameName, true);
|
||||
if (Directory.Exists($"{settings.DownloadDir}\\{cancelledGame}"))
|
||||
Directory.Delete($"{settings.DownloadDir}\\{cancelledGame}", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3501,8 +3518,9 @@ If the problem persists, visit our Telegram (https://t.me/VRPirates) or Discord
|
||||
{
|
||||
_ = FlexibleMessageBox.Show(Program.form, $"Error deleting game files: {ex.Message}");
|
||||
}
|
||||
|
||||
changeTitle("");
|
||||
break;
|
||||
continue; // Continue to next item in queue
|
||||
}
|
||||
{
|
||||
//Quota Errors
|
||||
@@ -3774,13 +3792,6 @@ If the problem persists, visit our Telegram (https://t.me/VRPirates) or Discord
|
||||
}
|
||||
}
|
||||
}
|
||||
if (removedownloading)
|
||||
{
|
||||
removedownloading = false;
|
||||
gamesAreDownloading = false;
|
||||
isinstalling = false;
|
||||
return;
|
||||
}
|
||||
if (!obbsMismatch)
|
||||
{
|
||||
changeTitle("Refreshing games list, please wait...\n");
|
||||
@@ -3788,12 +3799,15 @@ If the problem persists, visit our Telegram (https://t.me/VRPirates) or Discord
|
||||
|
||||
await RefreshGameListAsync();
|
||||
|
||||
if (settings.EnableMessageBoxes)
|
||||
// Only show output if there's content
|
||||
if (settings.EnableMessageBoxes && !string.IsNullOrWhiteSpace(output.Output + output.Error))
|
||||
{
|
||||
ShowPrcOutput(output);
|
||||
}
|
||||
|
||||
progressBar.IsIndeterminate = false;
|
||||
gamesAreDownloading = false;
|
||||
if (_queuePanel != null) _queuePanel.IsDownloading = false;
|
||||
isinstalling = false;
|
||||
|
||||
changeTitle("");
|
||||
@@ -3909,6 +3923,7 @@ If the problem persists, visit our Telegram (https://t.me/VRPirates) or Discord
|
||||
}
|
||||
progressBar.IsIndeterminate = false;
|
||||
gamesAreDownloading = false;
|
||||
if (_queuePanel != null) _queuePanel.IsDownloading = false;
|
||||
isinstalling = false;
|
||||
|
||||
changeTitle("");
|
||||
@@ -4615,20 +4630,6 @@ If the problem persists, visit our Telegram (https://t.me/VRPirates) or Discord
|
||||
ADBWirelessToggle.Text = isWirelessEnabled ? "WIRELESS ADB" : "ENABLE WIRELESS ADB";
|
||||
}
|
||||
|
||||
private void gamesQueListBox_MouseClick(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (gamesQueListBox.SelectedIndex == 0 && gamesQueueList.Count == 1)
|
||||
{
|
||||
removedownloading = true;
|
||||
RCLONE.killRclone();
|
||||
}
|
||||
if (gamesQueListBox.SelectedIndex != -1 && gamesQueListBox.SelectedIndex != 0)
|
||||
{
|
||||
_ = gamesQueueList.Remove(gamesQueListBox.SelectedItem.ToString());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void devicesComboBox_SelectedIndexChanged(object sender, EventArgs e)
|
||||
{
|
||||
showAvailableSpace();
|
||||
@@ -5450,21 +5451,6 @@ function onYouTubeIframeAPIReady() {
|
||||
btnUpdateAvailable.Click += btnUpdateAvailable_Click;
|
||||
btnNewerThanList.Click += btnNewerThanList_Click;
|
||||
}
|
||||
|
||||
private void gamesQueListBox_MouseDown(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (gamesQueListBox.SelectedItem == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = gamesQueListBox.DoDragDrop(gamesQueListBox.SelectedItem, DragDropEffects.Move);
|
||||
}
|
||||
|
||||
private void gamesQueListBox_DragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
e.Effect = DragDropEffects.Move;
|
||||
}
|
||||
|
||||
private async void pullAppToDesktopBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
@@ -7104,17 +7090,113 @@ function onYouTubeIframeAPIReady() {
|
||||
{
|
||||
Color panelColor = Color.FromArgb(24, 26, 30);
|
||||
|
||||
// Create rounded panel for notesRichTextBox
|
||||
// Initialize modern queue panel
|
||||
_queuePanel = new ModernQueuePanel
|
||||
{
|
||||
Size = new Size(250, 150), // Placeholder; resized by LayoutBottomPanels
|
||||
BackColor = panelColor
|
||||
};
|
||||
_queuePanel.ItemRemoved += QueuePanel_ItemRemoved;
|
||||
_queuePanel.ItemReordered += QueuePanel_ItemReordered;
|
||||
|
||||
// Sync with binding list
|
||||
gamesQueueList.ListChanged += (s, e) => SyncQueuePanel();
|
||||
|
||||
// Notes panel
|
||||
notesPanel = CreateRoundedPanel(notesRichTextBox, panelColor, 8, true);
|
||||
|
||||
// Create rounded panel for gamesQueListBox
|
||||
queuePanel = CreateRoundedPanel(gamesQueListBox, panelColor, 8, false);
|
||||
// Queue panel
|
||||
queuePanel = CreateQueuePanel(panelColor, 8);
|
||||
|
||||
// Bring labels to front so they appear above the panels
|
||||
gamesQueueLabel.BringToFront();
|
||||
lblNotes.BringToFront();
|
||||
|
||||
// Trigger initial layout
|
||||
LayoutBottomPanels();
|
||||
}
|
||||
|
||||
|
||||
private Panel CreateQueuePanel(Color panelColor, int radius)
|
||||
{
|
||||
var panel = new Panel
|
||||
{
|
||||
Location = gamesQueListBox.Location,
|
||||
Size = new Size(
|
||||
gamesQueListBox.Width + ChildHorizontalPadding + ChildRightMargin,
|
||||
gamesQueListBox.Height + ReservedLabelHeight
|
||||
),
|
||||
BackColor = Color.Transparent,
|
||||
Padding = new Padding(ChildHorizontalPadding, ChildTopMargin, ChildRightMargin, ChildTopMargin)
|
||||
};
|
||||
|
||||
// Double buffering
|
||||
typeof(Panel).InvokeMember("DoubleBuffered",
|
||||
System.Reflection.BindingFlags.SetProperty |
|
||||
System.Reflection.BindingFlags.Instance |
|
||||
System.Reflection.BindingFlags.NonPublic,
|
||||
null, panel, new object[] { true });
|
||||
|
||||
panel.Paint += (s, e) =>
|
||||
{
|
||||
var p = (Panel)s;
|
||||
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
var rect = new Rectangle(0, 0, p.Width - 1, p.Height - 1);
|
||||
|
||||
using (var path = CreateRoundedRectPath(rect, radius))
|
||||
using (var brush = new SolidBrush(panelColor))
|
||||
{
|
||||
e.Graphics.FillPath(brush, path);
|
||||
}
|
||||
|
||||
using (var regionPath = CreateRoundedRectPath(new Rectangle(0, 0, p.Width, p.Height), radius))
|
||||
{
|
||||
p.Region = new Region(regionPath);
|
||||
}
|
||||
};
|
||||
|
||||
var parent = gamesQueListBox.Parent;
|
||||
parent.Controls.Remove(gamesQueListBox);
|
||||
gamesQueListBox.Dispose();
|
||||
|
||||
_queuePanel.Location = new Point(ChildHorizontalPadding, ChildTopMargin);
|
||||
_queuePanel.Anchor = AnchorStyles.None;
|
||||
panel.Controls.Add(_queuePanel);
|
||||
parent.Controls.Add(panel);
|
||||
panel.BringToFront();
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
private void SyncQueuePanel()
|
||||
{
|
||||
if (_queuePanel == null) return;
|
||||
_queuePanel.SetItems(gamesQueueList);
|
||||
_queuePanel.IsDownloading = gamesAreDownloading && gamesQueueList.Count > 0;
|
||||
}
|
||||
|
||||
private void QueuePanel_ItemRemoved(object sender, int index)
|
||||
{
|
||||
if (index == 0 && gamesQueueList.Count >= 1)
|
||||
{
|
||||
removedownloading = true;
|
||||
RCLONE.killRclone();
|
||||
}
|
||||
else if (index > 0 && index < gamesQueueList.Count)
|
||||
{
|
||||
gamesQueueList.RemoveAt(index);
|
||||
}
|
||||
}
|
||||
|
||||
private void QueuePanel_ItemReordered(object sender, ReorderEventArgs e)
|
||||
{
|
||||
if (e.FromIndex <= 0 || e.FromIndex >= gamesQueueList.Count) return;
|
||||
|
||||
var item = gamesQueueList[e.FromIndex];
|
||||
gamesQueueList.RemoveAt(e.FromIndex);
|
||||
|
||||
int insertAt = Math.Max(1, Math.Min(e.ToIndex, gamesQueueList.Count));
|
||||
gamesQueueList.Insert(insertAt, item);
|
||||
}
|
||||
|
||||
private void notesRichTextBox_LinkClicked(object sender, LinkClickedEventArgs e)
|
||||
{
|
||||
try
|
||||
@@ -7127,69 +7209,6 @@ function onYouTubeIframeAPIReady() {
|
||||
}
|
||||
}
|
||||
|
||||
private void gamesQueListBox_DrawItem(object sender, DrawItemEventArgs e)
|
||||
{
|
||||
if (e.Index < 0) return;
|
||||
|
||||
// Determine colors based on selection state
|
||||
Color backColor = (e.State & DrawItemState.Selected) == DrawItemState.Selected
|
||||
? Color.FromArgb(93, 203, 173) // Accent color for selected
|
||||
: gamesQueListBox.BackColor;
|
||||
|
||||
Color foreColor = (e.State & DrawItemState.Selected) == DrawItemState.Selected
|
||||
? Color.FromArgb(20, 20, 20) // Dark text on accent
|
||||
: gamesQueListBox.ForeColor;
|
||||
|
||||
Font font = (e.State & DrawItemState.Selected) == DrawItemState.Selected
|
||||
? new Font("Microsoft Sans Serif", 10F, FontStyle.Bold)
|
||||
: new Font("Microsoft Sans Serif", 10F, FontStyle.Regular);
|
||||
|
||||
// Clear the item background first
|
||||
using (SolidBrush clearBrush = new SolidBrush(gamesQueListBox.BackColor))
|
||||
{
|
||||
e.Graphics.FillRectangle(clearBrush, e.Bounds);
|
||||
}
|
||||
|
||||
// Draw rounded background
|
||||
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
e.Graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||
int radius = 1;
|
||||
int margin = 4;
|
||||
Rectangle roundedRect = new Rectangle(
|
||||
e.Bounds.X,
|
||||
e.Bounds.Y,
|
||||
e.Bounds.Width - (margin * 2),
|
||||
e.Bounds.Height
|
||||
);
|
||||
|
||||
using (GraphicsPath path = CreateRoundedRectPath(roundedRect, radius))
|
||||
using (SolidBrush backgroundBrush = new SolidBrush(backColor))
|
||||
{
|
||||
e.Graphics.FillPath(backgroundBrush, path);
|
||||
}
|
||||
|
||||
// Draw text with padding
|
||||
string text = gamesQueListBox.Items[e.Index].ToString();
|
||||
Rectangle textRect = new Rectangle(
|
||||
roundedRect.X + 4,
|
||||
roundedRect.Y,
|
||||
roundedRect.Width - 8,
|
||||
roundedRect.Height
|
||||
);
|
||||
|
||||
using (SolidBrush textBrush = new SolidBrush(foreColor))
|
||||
{
|
||||
var sf = new StringFormat
|
||||
{
|
||||
Alignment = StringAlignment.Near,
|
||||
LineAlignment = StringAlignment.Center,
|
||||
Trimming = StringTrimming.EllipsisCharacter,
|
||||
FormatFlags = StringFormatFlags.NoWrap
|
||||
};
|
||||
e.Graphics.DrawString(text, font, textBrush, textRect, sf);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyWebViewRoundedCorners()
|
||||
{
|
||||
if (webView21 == null) return;
|
||||
@@ -7305,7 +7324,7 @@ function onYouTubeIframeAPIReady() {
|
||||
{
|
||||
// Skip if panels aren't initialized yet
|
||||
if (notesPanel == null || queuePanel == null) return;
|
||||
if (gamesQueListBox == null || notesRichTextBox == null) return;
|
||||
if (notesRichTextBox == null) return;
|
||||
|
||||
// Panels start after webView21 (webView21 ends at 259 + 384 = 643, add spacing)
|
||||
int panelsStartX = 654;
|
||||
@@ -7323,13 +7342,22 @@ function onYouTubeIframeAPIReady() {
|
||||
queuePanel.Location = new Point(panelsStartX, panelY);
|
||||
queuePanel.Size = new Size(queueWidth, BottomPanelHeight);
|
||||
|
||||
// Layout queue panel child (_queuePanel)
|
||||
if (_queuePanel != null)
|
||||
{
|
||||
_queuePanel.Location = new Point(ChildHorizontalPadding, ChildTopMargin);
|
||||
_queuePanel.Size = new Size(
|
||||
queuePanel.Width - ChildHorizontalPadding - ChildRightMargin,
|
||||
queuePanel.Height - ChildTopMargin * 2 - ReservedLabelHeight
|
||||
);
|
||||
}
|
||||
|
||||
// Notes panel
|
||||
int notesX = panelsStartX + queueWidth + PanelSpacing;
|
||||
notesPanel.Location = new Point(notesX, panelY);
|
||||
notesPanel.Size = new Size(this.ClientSize.Width - notesX - RightMargin, BottomPanelHeight);
|
||||
|
||||
// Layout children using the same helper as CreateRoundedPanel
|
||||
LayoutChildInPanel(queuePanel, gamesQueListBox, isNotesPanel: false);
|
||||
// Layout notes child
|
||||
LayoutChildInPanel(notesPanel, notesRichTextBox, isNotesPanel: true);
|
||||
|
||||
// Position labels at bottom of their panels
|
||||
|
||||
511
ModernQueuePanel.cs
Normal file
511
ModernQueuePanel.cs
Normal file
@@ -0,0 +1,511 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace AndroidSideloader
|
||||
{
|
||||
// Modern download queue panel with drag-reorder, cancel buttons
|
||||
// and custom scrollbar with auto-scrolling during drag
|
||||
public sealed class ModernQueuePanel : Control
|
||||
{
|
||||
// Layout constants
|
||||
private const int ItemHeight = 28, ItemMargin = 4, ItemRadius = 5;
|
||||
private const int XButtonSize = 18, DragHandleWidth = 20, TextPadding = 6;
|
||||
private const int ScrollbarWidth = 6, ScrollbarWidthHover = 8, ScrollbarMargin = 2;
|
||||
private const int ScrollbarRadius = 3, MinThumbHeight = 20;
|
||||
private const int AutoScrollZoneHeight = 30, AutoScrollSpeed = 3;
|
||||
|
||||
// Color palette
|
||||
private static readonly Color BgColor = Color.FromArgb(24, 26, 30);
|
||||
private static readonly Color ItemBg = Color.FromArgb(32, 36, 44);
|
||||
private static readonly Color ItemHoverBg = Color.FromArgb(42, 46, 54);
|
||||
private static readonly Color ItemDragBg = Color.FromArgb(45, 55, 70);
|
||||
private static readonly Color TextColor = Color.FromArgb(210, 210, 210);
|
||||
private static readonly Color TextDimColor = Color.FromArgb(140, 140, 140);
|
||||
private static readonly Color AccentColor = Color.FromArgb(93, 203, 173);
|
||||
private static readonly Color XButtonBg = Color.FromArgb(55, 60, 70);
|
||||
private static readonly Color XButtonHoverBg = Color.FromArgb(200, 60, 60);
|
||||
private static readonly Color GripColor = Color.FromArgb(70, 75, 85);
|
||||
private static readonly Color ItemDragBorder = Color.FromArgb(55, 65, 80);
|
||||
private static readonly Color ScrollTrackColor = Color.FromArgb(35, 38, 45);
|
||||
private static readonly Color ScrollThumbColor = Color.FromArgb(70, 75, 85);
|
||||
private static readonly Color ScrollThumbHoverColor = Color.FromArgb(90, 95, 105);
|
||||
private static readonly Color ScrollThumbDragColor = Color.FromArgb(110, 115, 125);
|
||||
|
||||
private readonly List<string> _items = new List<string>();
|
||||
private readonly Timer _autoScrollTimer;
|
||||
|
||||
// State tracking
|
||||
private int _hoveredIndex = -1, _dragIndex = -1, _dropIndex = -1, _scrollOffset;
|
||||
private bool _hoveringX, _scrollbarHovered, _scrollbarDragging;
|
||||
private int _scrollDragStartY, _scrollDragStartOffset, _autoScrollDirection;
|
||||
private Rectangle _scrollThumbRect, _scrollTrackRect;
|
||||
|
||||
public event EventHandler<int> ItemRemoved;
|
||||
public event EventHandler<ReorderEventArgs> ItemReordered;
|
||||
|
||||
public ModernQueuePanel()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = BgColor;
|
||||
_autoScrollTimer = new Timer { Interval = 16 }; // ~60 FPS
|
||||
_autoScrollTimer.Tick += (s, e) => HandleAutoScroll();
|
||||
}
|
||||
|
||||
public bool IsDownloading { get; set; }
|
||||
public int Count => _items.Count;
|
||||
private int ContentHeight => _items.Count * (ItemHeight + ItemMargin) + ItemMargin;
|
||||
private int MaxScroll => Math.Max(0, ContentHeight - Height);
|
||||
private bool ScrollbarVisible => ContentHeight > Height;
|
||||
|
||||
public void SetItems(IEnumerable<string> items)
|
||||
{
|
||||
_items.Clear();
|
||||
_items.AddRange(items);
|
||||
ResetState();
|
||||
}
|
||||
|
||||
private void ResetState()
|
||||
{
|
||||
_hoveredIndex = _dragIndex = -1;
|
||||
ClampScroll();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void ClampScroll() =>
|
||||
_scrollOffset = Math.Max(0, Math.Min(MaxScroll, _scrollOffset));
|
||||
|
||||
// Auto-scroll when dragging near edges
|
||||
private void HandleAutoScroll()
|
||||
{
|
||||
if (_dragIndex < 0 || _autoScrollDirection == 0)
|
||||
{
|
||||
_autoScrollTimer.Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
int oldOffset = _scrollOffset;
|
||||
_scrollOffset += _autoScrollDirection * AutoScrollSpeed;
|
||||
ClampScroll();
|
||||
|
||||
if (_scrollOffset != oldOffset)
|
||||
{
|
||||
UpdateDropIndex(PointToClient(MousePosition).Y);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateAutoScroll(int mouseY)
|
||||
{
|
||||
if (_dragIndex < 0 || MaxScroll <= 0)
|
||||
{
|
||||
StopAutoScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
_autoScrollDirection = mouseY < AutoScrollZoneHeight && _scrollOffset > 0 ? -1 :
|
||||
mouseY > Height - AutoScrollZoneHeight && _scrollOffset < MaxScroll ? 1 : 0;
|
||||
|
||||
if (_autoScrollDirection != 0 && !_autoScrollTimer.Enabled)
|
||||
_autoScrollTimer.Start();
|
||||
else if (_autoScrollDirection == 0)
|
||||
_autoScrollTimer.Stop();
|
||||
}
|
||||
|
||||
private void StopAutoScroll()
|
||||
{
|
||||
_autoScrollDirection = 0;
|
||||
_autoScrollTimer.Stop();
|
||||
}
|
||||
|
||||
private void UpdateDropIndex(int mouseY) =>
|
||||
_dropIndex = Math.Max(1, Math.Min(_items.Count, (mouseY + _scrollOffset + ItemHeight / 2) / (ItemHeight + ItemMargin)));
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
g.Clear(BgColor);
|
||||
|
||||
if (_items.Count == 0)
|
||||
{
|
||||
DrawEmptyState(g);
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw visible items
|
||||
for (int i = 0; i < _items.Count; i++)
|
||||
{
|
||||
var rect = GetItemRect(i);
|
||||
if (rect.Bottom >= 0 && rect.Top <= Height)
|
||||
DrawItem(g, i, rect);
|
||||
}
|
||||
|
||||
// Draw drop indicator and scrollbar
|
||||
if (_dragIndex >= 0 && _dropIndex >= 0 && _dropIndex != _dragIndex)
|
||||
DrawDropIndicator(g);
|
||||
if (ScrollbarVisible)
|
||||
DrawScrollbar(g);
|
||||
}
|
||||
|
||||
private void DrawEmptyState(Graphics g)
|
||||
{
|
||||
using (var brush = new SolidBrush(TextDimColor))
|
||||
using (var font = new Font("Segoe UI", 8.5f, FontStyle.Italic))
|
||||
{
|
||||
var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
|
||||
g.DrawString("Queue is empty", font, brush, ClientRectangle, sf);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawItem(Graphics g, int index, Rectangle rect)
|
||||
{
|
||||
bool isFirst = index == 0;
|
||||
bool isDragging = index == _dragIndex;
|
||||
bool isHovered = !isDragging && index == _hoveredIndex;
|
||||
Color bg = isDragging ? ItemDragBg : isHovered ? ItemHoverBg : ItemBg;
|
||||
|
||||
// Draw item background
|
||||
using (var path = CreateRoundedRect(rect, ItemRadius))
|
||||
using (var brush = new SolidBrush(bg))
|
||||
g.FillPath(brush, path);
|
||||
|
||||
// Active download (first item) gets gradient accent and border
|
||||
if (isFirst)
|
||||
DrawFirstItemAccent(g, rect);
|
||||
// Dragged items get subtle highlight border
|
||||
else if (isDragging)
|
||||
DrawBorder(g, rect, ItemDragBorder, 0.5f);
|
||||
|
||||
// Draw drag handle, text, and close button
|
||||
if (!isFirst)
|
||||
DrawDragHandle(g, rect);
|
||||
DrawItemText(g, index, rect, isFirst);
|
||||
DrawXButton(g, rect, isHovered && _hoveringX);
|
||||
}
|
||||
|
||||
// Draw gradient accent and border for active download (first item)
|
||||
private void DrawFirstItemAccent(Graphics g, Rectangle rect)
|
||||
{
|
||||
using (var path = CreateRoundedRect(rect, ItemRadius))
|
||||
using (var gradBrush = new LinearGradientBrush(rect,
|
||||
Color.FromArgb(60, AccentColor), Color.FromArgb(0, AccentColor), LinearGradientMode.Horizontal))
|
||||
{
|
||||
var oldClip = g.Clip;
|
||||
g.SetClip(path);
|
||||
g.FillRectangle(gradBrush, rect);
|
||||
g.Clip = oldClip;
|
||||
}
|
||||
DrawBorder(g, rect, AccentColor, 1.5f);
|
||||
}
|
||||
|
||||
private void DrawBorder(Graphics g, Rectangle rect, Color color, float width)
|
||||
{
|
||||
using (var path = CreateRoundedRect(rect, ItemRadius))
|
||||
using (var pen = new Pen(color, width))
|
||||
g.DrawPath(pen, path);
|
||||
}
|
||||
|
||||
private void DrawDragHandle(Graphics g, Rectangle rect)
|
||||
{
|
||||
int cx = rect.X + 8, cy = rect.Y + rect.Height / 2;
|
||||
using (var brush = new SolidBrush(GripColor))
|
||||
{
|
||||
for (int row = -1; row <= 1; row++)
|
||||
for (int col = 0; col < 2; col++)
|
||||
g.FillEllipse(brush, cx + col * 4, cy + row * 4 - 1, 2, 2);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawItemText(Graphics g, int index, Rectangle rect, bool isFirst)
|
||||
{
|
||||
int textLeft = isFirst ? rect.X + TextPadding : rect.X + DragHandleWidth;
|
||||
int rightPad = ScrollbarVisible ? ScrollbarWidthHover + ScrollbarMargin * 2 : 0;
|
||||
var textRect = new Rectangle(textLeft, rect.Y, rect.Right - XButtonSize - 6 - textLeft - rightPad, rect.Height);
|
||||
|
||||
using (var brush = new SolidBrush(TextColor))
|
||||
using (var font = new Font("Segoe UI", isFirst ? 8.5f : 8f, isFirst ? FontStyle.Bold : FontStyle.Regular))
|
||||
{
|
||||
var sf = new StringFormat
|
||||
{
|
||||
Alignment = StringAlignment.Near,
|
||||
LineAlignment = StringAlignment.Center,
|
||||
Trimming = StringTrimming.EllipsisCharacter,
|
||||
FormatFlags = StringFormatFlags.NoWrap
|
||||
};
|
||||
g.DrawString(_items[index], font, brush, textRect, sf);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawXButton(Graphics g, Rectangle itemRect, bool hovered)
|
||||
{
|
||||
var xRect = GetXButtonRect(itemRect);
|
||||
using (var path = CreateRoundedRect(xRect, 3))
|
||||
using (var brush = new SolidBrush(hovered ? XButtonHoverBg : XButtonBg))
|
||||
g.FillPath(brush, path);
|
||||
|
||||
using (var pen = new Pen(Color.White, 1.4f) { StartCap = LineCap.Round, EndCap = LineCap.Round })
|
||||
{
|
||||
int p = 4;
|
||||
g.DrawLine(pen, xRect.X + p, xRect.Y + p, xRect.Right - p, xRect.Bottom - p);
|
||||
g.DrawLine(pen, xRect.Right - p, xRect.Y + p, xRect.X + p, xRect.Bottom - p);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawDropIndicator(Graphics g)
|
||||
{
|
||||
int y = (_dropIndex >= _items.Count ? _items.Count : _dropIndex) * (ItemHeight + ItemMargin) + ItemMargin / 2 - _scrollOffset;
|
||||
int left = ItemMargin + 2;
|
||||
int right = Width - ItemMargin - 2 - (ScrollbarVisible ? ScrollbarWidthHover + ScrollbarMargin : 0);
|
||||
|
||||
using (var pen = new Pen(AccentColor, 2.5f) { StartCap = LineCap.Round, EndCap = LineCap.Round })
|
||||
g.DrawLine(pen, left, y, right, y);
|
||||
}
|
||||
|
||||
// Draw custom scrollbar with hover expansion
|
||||
private void DrawScrollbar(Graphics g)
|
||||
{
|
||||
if (MaxScroll <= 0) return;
|
||||
|
||||
bool expanded = _scrollbarHovered || _scrollbarDragging;
|
||||
int sbWidth = expanded ? ScrollbarWidthHover : ScrollbarWidth;
|
||||
int trackX = Width - ScrollbarWidth - ScrollbarMargin - (expanded ? (ScrollbarWidthHover - ScrollbarWidth) / 2 : 0);
|
||||
|
||||
_scrollTrackRect = new Rectangle(trackX, ScrollbarMargin, sbWidth, Height - ScrollbarMargin * 2);
|
||||
|
||||
using (var trackBrush = new SolidBrush(Color.FromArgb(40, ScrollTrackColor)))
|
||||
using (var trackPath = CreateRoundedRect(_scrollTrackRect, ScrollbarRadius))
|
||||
g.FillPath(trackBrush, trackPath);
|
||||
|
||||
// Calculate thumb position and size
|
||||
int trackHeight = _scrollTrackRect.Height;
|
||||
int thumbHeight = Math.Max(MinThumbHeight, (int)(trackHeight * ((float)Height / ContentHeight)));
|
||||
float scrollRatio = MaxScroll > 0 ? (float)_scrollOffset / MaxScroll : 0;
|
||||
int thumbY = ScrollbarMargin + (int)((trackHeight - thumbHeight) * scrollRatio);
|
||||
|
||||
_scrollThumbRect = new Rectangle(trackX, thumbY, sbWidth, thumbHeight);
|
||||
Color thumbColor = _scrollbarDragging ? ScrollThumbDragColor : _scrollbarHovered ? ScrollThumbHoverColor : ScrollThumbColor;
|
||||
|
||||
using (var thumbBrush = new SolidBrush(thumbColor))
|
||||
using (var thumbPath = CreateRoundedRect(_scrollThumbRect, ScrollbarRadius))
|
||||
g.FillPath(thumbBrush, thumbPath);
|
||||
}
|
||||
|
||||
private Rectangle GetItemRect(int index)
|
||||
{
|
||||
int y = index * (ItemHeight + ItemMargin) + ItemMargin - _scrollOffset;
|
||||
int w = Width - ItemMargin * 2 - (ScrollbarVisible ? ScrollbarWidthHover + ScrollbarMargin + 2 : 0);
|
||||
return new Rectangle(ItemMargin, y, w, ItemHeight);
|
||||
}
|
||||
|
||||
private Rectangle GetXButtonRect(Rectangle itemRect) =>
|
||||
new Rectangle(itemRect.Right - XButtonSize - 3, itemRect.Y + (itemRect.Height - XButtonSize) / 2, XButtonSize, XButtonSize);
|
||||
|
||||
private int HitTest(Point pt)
|
||||
{
|
||||
for (int i = 0; i < _items.Count; i++)
|
||||
if (GetItemRect(i).Contains(pt)) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private bool HitTestScrollbar(Point pt) =>
|
||||
ScrollbarVisible && new Rectangle(_scrollTrackRect.X - 4, _scrollTrackRect.Y, _scrollTrackRect.Width + 8, _scrollTrackRect.Height).Contains(pt);
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseMove(e);
|
||||
|
||||
if (_scrollbarDragging)
|
||||
{
|
||||
HandleScrollbarDrag(e.Y);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update scrollbar hover state
|
||||
bool wasHovered = _scrollbarHovered;
|
||||
_scrollbarHovered = HitTestScrollbar(e.Location);
|
||||
if (_scrollbarHovered != wasHovered) Invalidate();
|
||||
|
||||
if (_scrollbarHovered)
|
||||
{
|
||||
Cursor = Cursors.Default;
|
||||
_hoveredIndex = -1;
|
||||
_hoveringX = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle drag operation
|
||||
if (_dragIndex >= 0)
|
||||
{
|
||||
UpdateAutoScroll(e.Y);
|
||||
int newDrop = Math.Max(1, Math.Min(_items.Count, (e.Y + _scrollOffset + ItemHeight / 2) / (ItemHeight + ItemMargin)));
|
||||
if (newDrop != _dropIndex) { _dropIndex = newDrop; Invalidate(); }
|
||||
return;
|
||||
}
|
||||
|
||||
// Update hover state
|
||||
int hit = HitTest(e.Location);
|
||||
bool overX = hit >= 0 && GetXButtonRect(GetItemRect(hit)).Contains(e.Location);
|
||||
|
||||
if (hit != _hoveredIndex || overX != _hoveringX)
|
||||
{
|
||||
_hoveredIndex = hit;
|
||||
_hoveringX = overX;
|
||||
Cursor = overX ? Cursors.Hand : hit > 0 ? Cursors.SizeNS : Cursors.Default;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleScrollbarDrag(int mouseY)
|
||||
{
|
||||
int trackHeight = Height - ScrollbarMargin * 2;
|
||||
int thumbHeight = Math.Max(MinThumbHeight, (int)(trackHeight * ((float)Height / ContentHeight)));
|
||||
int scrollableHeight = trackHeight - thumbHeight;
|
||||
|
||||
if (scrollableHeight > 0)
|
||||
{
|
||||
float scrollRatio = (float)(mouseY - _scrollDragStartY) / scrollableHeight;
|
||||
_scrollOffset = _scrollDragStartOffset + (int)(scrollRatio * MaxScroll);
|
||||
ClampScroll();
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseDown(e);
|
||||
if (e.Button != MouseButtons.Left) return;
|
||||
|
||||
// Handle scrollbar thumb drag
|
||||
if (ScrollbarVisible && _scrollThumbRect.Contains(e.Location))
|
||||
{
|
||||
_scrollbarDragging = true;
|
||||
_scrollDragStartY = e.Y;
|
||||
_scrollDragStartOffset = _scrollOffset;
|
||||
Capture = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle scrollbar track click
|
||||
if (ScrollbarVisible && HitTestScrollbar(e.Location))
|
||||
{
|
||||
_scrollOffset += e.Y < _scrollThumbRect.Top ? -Height : Height;
|
||||
ClampScroll();
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
int hit = HitTest(e.Location);
|
||||
if (hit < 0) return;
|
||||
|
||||
// Handle close button click
|
||||
if (GetXButtonRect(GetItemRect(hit)).Contains(e.Location))
|
||||
{
|
||||
ItemRemoved?.Invoke(this, hit);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start drag operation (only for non-first items)
|
||||
if (hit > 0)
|
||||
{
|
||||
_dragIndex = _dropIndex = hit;
|
||||
Capture = true;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseUp(e);
|
||||
StopAutoScroll();
|
||||
|
||||
if (_scrollbarDragging)
|
||||
{
|
||||
_scrollbarDragging = false;
|
||||
Capture = false;
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Complete drag reorder operation
|
||||
if (_dragIndex > 0 && _dropIndex > 0 && _dropIndex != _dragIndex)
|
||||
{
|
||||
int from = _dragIndex;
|
||||
int to = Math.Max(1, _dropIndex > _dragIndex ? _dropIndex - 1 : _dropIndex);
|
||||
var item = _items[from];
|
||||
_items.RemoveAt(from);
|
||||
_items.Insert(to, item);
|
||||
ItemReordered?.Invoke(this, new ReorderEventArgs(from, to));
|
||||
}
|
||||
|
||||
_dragIndex = _dropIndex = -1;
|
||||
Capture = false;
|
||||
Cursor = Cursors.Default;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnMouseLeave(EventArgs e)
|
||||
{
|
||||
base.OnMouseLeave(e);
|
||||
if (_dragIndex < 0 && !_scrollbarDragging)
|
||||
{
|
||||
_hoveredIndex = -1;
|
||||
_hoveringX = _scrollbarHovered = false;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnMouseWheel(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseWheel(e);
|
||||
if (MaxScroll <= 0) return;
|
||||
_scrollOffset -= e.Delta / 4;
|
||||
ClampScroll();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnResize(EventArgs e)
|
||||
{
|
||||
base.OnResize(e);
|
||||
ClampScroll();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_autoScrollTimer?.Stop();
|
||||
_autoScrollTimer?.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private static GraphicsPath CreateRoundedRect(Rectangle rect, int radius)
|
||||
{
|
||||
var path = new GraphicsPath();
|
||||
if (radius <= 0 || rect.Width <= 0 || rect.Height <= 0)
|
||||
{
|
||||
path.AddRectangle(rect);
|
||||
return path;
|
||||
}
|
||||
int d = Math.Min(radius * 2, Math.Min(rect.Width, rect.Height));
|
||||
path.AddArc(rect.X, rect.Y, d, d, 180, 90);
|
||||
path.AddArc(rect.Right - d, rect.Y, d, d, 270, 90);
|
||||
path.AddArc(rect.Right - d, rect.Bottom - d, d, d, 0, 90);
|
||||
path.AddArc(rect.X, rect.Bottom - d, d, d, 90, 90);
|
||||
path.CloseFigure();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
public class ReorderEventArgs : EventArgs
|
||||
{
|
||||
public int FromIndex { get; }
|
||||
public int ToIndex { get; }
|
||||
public ReorderEventArgs(int from, int to) { FromIndex = from; ToIndex = to; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user