diff --git a/AndroidSideloader.csproj b/AndroidSideloader.csproj index 0fe2ebb..3185b2c 100644 --- a/AndroidSideloader.csproj +++ b/AndroidSideloader.csproj @@ -194,6 +194,9 @@ Component + + Component + True True diff --git a/MainForm.Designer.cs b/MainForm.Designer.cs index 8ec8f9c..17012c2 100644 --- a/MainForm.Designer.cs +++ b/MainForm.Designer.cs @@ -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 // diff --git a/MainForm.cs b/MainForm.cs index b3af3ce..0ebd11c 100755 --- a/MainForm.cs +++ b/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 gamesQueueList = new BindingList(); 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 diff --git a/ModernQueuePanel.cs b/ModernQueuePanel.cs new file mode 100644 index 0000000..5ee6b14 --- /dev/null +++ b/ModernQueuePanel.cs @@ -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 _items = new List(); + 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 ItemRemoved; + public event EventHandler 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 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; } + } +} \ No newline at end of file