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:
jp64k
2025-12-31 03:44:46 +01:00
parent fb36826091
commit 3993e574a8
4 changed files with 669 additions and 130 deletions

View File

@@ -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
View File

@@ -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
//

View File

@@ -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();
@@ -5451,21 +5452,6 @@ function onYouTubeIframeAPIReady() {
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)
{
string selectedApp = ShowInstalledAppSelector("Select an app to pull to desktop");
@@ -7104,15 +7090,111 @@ 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)
@@ -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
View 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; }
}
}