diff --git a/AndroidSideloader.csproj b/AndroidSideloader.csproj index f2de4fd..0fe2ebb 100644 --- a/AndroidSideloader.csproj +++ b/AndroidSideloader.csproj @@ -190,6 +190,7 @@ Component + Component diff --git a/ColumnSort.cs b/ColumnSort.cs index 9ba5922..acb6cd6 100644 --- a/ColumnSort.cs +++ b/ColumnSort.cs @@ -1,4 +1,5 @@ -using System.Collections; +using System; +using System.Collections; using System.Windows.Forms; /// @@ -41,23 +42,41 @@ public class ListViewColumnSorter : IComparer ListViewItem listviewX = (ListViewItem)x; ListViewItem listviewY = (ListViewItem)y; - // Determine if the column requires numeric comparison - if (SortColumn == 3 || SortColumn == 5) // Numeric columns: VersionCodeIndex, VersionNameIndex + // Special handling for column 6 (Popularity ranking) + if (SortColumn == 6) { - try - { - // Parse and compare numeric values directly - int xNum = ParseNumber(listviewX.SubItems[SortColumn].Text); - int yNum = ParseNumber(listviewY.SubItems[SortColumn].Text); + string textX = listviewX.SubItems[SortColumn].Text; + string textY = listviewY.SubItems[SortColumn].Text; - // Compare numerically - compareResult = xNum.CompareTo(yNum); - } - catch + // Extract numeric values from "#1", "#10", etc. + // "-" represents unranked and should go to the end + int rankX = int.MaxValue; // Default for unranked (-) + int rankY = int.MaxValue; + + if (textX.StartsWith("#") && int.TryParse(textX.Substring(1), out int parsedX)) { - // Fallback to string comparison if parsing fails - compareResult = ObjectCompare.Compare(listviewX.SubItems[SortColumn].Text, listviewY.SubItems[SortColumn].Text); + rankX = parsedX; } + + if (textY.StartsWith("#") && int.TryParse(textY.Substring(1), out int parsedY)) + { + rankY = parsedY; + } + + // Compare the numeric ranks + compareResult = rankX.CompareTo(rankY); + } + // Special handling for column 5 (Size) + else if (SortColumn == 5) + { + string textX = listviewX.SubItems[SortColumn].Text; + string textY = listviewY.SubItems[SortColumn].Text; + + double sizeX = ParseSize(textX); + double sizeY = ParseSize(textY); + + // Compare the numeric sizes + compareResult = sizeX.CompareTo(sizeY); } else { @@ -91,6 +110,49 @@ public class ListViewColumnSorter : IComparer return int.TryParse(text, out int result) ? result : 0; } + /// + /// Parses size strings with units (GB/MB) and converts them to MB for comparison. + /// + /// Size string (e.g., "1.23 GB", "123 MB") + /// Size in MB as a double + private double ParseSize(string sizeStr) + { + if (string.IsNullOrEmpty(sizeStr)) + return 0; + + // Remove whitespace + sizeStr = sizeStr.Trim(); + + // Handle new format: "1.23 GB" or "123 MB" + if (sizeStr.EndsWith(" GB", StringComparison.OrdinalIgnoreCase)) + { + string numPart = sizeStr.Substring(0, sizeStr.Length - 3).Trim(); + if (double.TryParse(numPart, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out double gb)) + { + return gb * 1024.0; // Convert GB to MB for consistent sorting + } + } + else if (sizeStr.EndsWith(" MB", StringComparison.OrdinalIgnoreCase)) + { + string numPart = sizeStr.Substring(0, sizeStr.Length - 3).Trim(); + if (double.TryParse(numPart, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out double mb)) + { + return mb; + } + } + + // Fallback: try parsing as raw number + if (double.TryParse(sizeStr, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out double rawMb)) + { + return rawMb; + } + + return 0; + } + /// /// Gets or sets the index of the column to be sorted (default is '0'). /// diff --git a/GalleryView.cs b/GalleryView.cs index a029266..b78b154 100644 --- a/GalleryView.cs +++ b/GalleryView.cs @@ -6,7 +6,9 @@ using System.Drawing; using System.Drawing.Drawing2D; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Windows.Forms; +using static System.Windows.Forms.AxHost; public enum SortField { Name, LastUpdated, Size, Popularity } public enum SortDirection { Ascending, Descending } @@ -90,6 +92,24 @@ public class FastGalleryPanel : Control public event EventHandler TileRightClicked; public event EventHandler SortChanged; + [DllImport("dwmapi.dll")] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize); + + [DllImport("uxtheme.dll", CharSet = CharSet.Unicode)] + private static extern int SetWindowTheme(IntPtr hwnd, string pszSubAppName, string pszSubIdList); + + private void ApplyModernScrollbars() + { + if (_scrollBar == null || !_scrollBar.IsHandleCreated) return; + + int dark = 1; + int hr = DwmSetWindowAttribute(_scrollBar.Handle, 20, ref dark, sizeof(int)); + if (hr != 0) + DwmSetWindowAttribute(_scrollBar.Handle, 19, ref dark, sizeof(int)); + + if (SetWindowTheme(_scrollBar.Handle, "DarkMode_Explorer", null) != 0) + SetWindowTheme(_scrollBar.Handle, "Explorer", null); + } private class TileAnimationState { public float Scale = 1.0f; @@ -155,6 +175,8 @@ public class FastGalleryPanel : Control _isScrolling = false; Invalidate(); }; + + _scrollBar.HandleCreated += (s, e) => ApplyModernScrollbars(); Controls.Add(_scrollBar); // Animation timer (~120fps) @@ -353,10 +375,10 @@ public class FastGalleryPanel : Control case SortField.Popularity: if (_currentSortDirection == SortDirection.Ascending) - _items = _items.OrderBy(i => ParsePopularity(i.SubItems.Count > 6 ? i.SubItems[6].Text : "0")) + _items = _items.OrderByDescending(i => ParsePopularity(i.SubItems.Count > 6 ? i.SubItems[6].Text : "-")) .ThenBy(i => i.Text, new GameNameComparer()).ToList(); else - _items = _items.OrderByDescending(i => ParsePopularity(i.SubItems.Count > 6 ? i.SubItems[6].Text : "0")) + _items = _items.OrderBy(i => ParsePopularity(i.SubItems.Count > 6 ? i.SubItems[6].Text : "-")) .ThenBy(i => i.Text, new GameNameComparer()).ToList(); break; } @@ -378,12 +400,35 @@ public class FastGalleryPanel : Control Invalidate(); } - private double ParsePopularity(string popStr) + private int ParsePopularity(string popStr) { - if (double.TryParse(popStr?.Trim(), System.Globalization.NumberStyles.Any, - System.Globalization.CultureInfo.InvariantCulture, out double pop)) - return pop; - return 0; + if (string.IsNullOrEmpty(popStr)) + return int.MaxValue; // Unranked goes to end + + popStr = popStr.Trim(); + + // Handle new format: "#123" or "-" + if (popStr == "-") + { + return int.MaxValue; // Unranked items sort to the end + } + + if (popStr.StartsWith("#")) + { + string numPart = popStr.Substring(1); + if (int.TryParse(numPart, out int rank)) + { + return rank; + } + } + + // Fallback: try parsing as raw number + if (int.TryParse(popStr, out int rawNum)) + { + return rawNum; + } + + return int.MaxValue; // Unparseable goes to end } // Custom sort to match list sort behaviour: '_' before digits, digits before letters (case-insensitive) @@ -445,9 +490,39 @@ public class FastGalleryPanel : Control private double ParseSize(string sizeStr) { - if (double.TryParse(sizeStr?.Trim(), System.Globalization.NumberStyles.Any, - System.Globalization.CultureInfo.InvariantCulture, out double mb)) - return mb; + if (string.IsNullOrEmpty(sizeStr)) + return 0; + + // Remove whitespace + sizeStr = sizeStr.Trim(); + + // Handle new format: "1.23 GB" or "123 MB" + if (sizeStr.EndsWith(" GB", StringComparison.OrdinalIgnoreCase)) + { + string numPart = sizeStr.Substring(0, sizeStr.Length - 3).Trim(); + if (double.TryParse(numPart, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out double gb)) + { + return gb * 1024.0; // Convert GB to MB for consistent sorting + } + } + else if (sizeStr.EndsWith(" MB", StringComparison.OrdinalIgnoreCase)) + { + string numPart = sizeStr.Substring(0, sizeStr.Length - 3).Trim(); + if (double.TryParse(numPart, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out double mb)) + { + return mb; + } + } + + // Fallback: try parsing as raw number + if (double.TryParse(sizeStr, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out double rawMb)) + { + return rawMb; + } + return 0; } @@ -899,7 +974,7 @@ public class FastGalleryPanel : Control // Size badge (top right) - always visible if (item.SubItems.Count > 5) { - string sizeText = FormatSize(item.SubItems[5].Text); + string sizeText = item.SubItems[5].Text; if (!string.IsNullOrEmpty(sizeText)) { DrawRightAlignedBadge(g, sizeText, x + scaledW - thumbPadding - 4, rightBadgeY, 1.0f); diff --git a/MainForm.Designer.cs b/MainForm.Designer.cs index d42ea2d..8ec8f9c 100644 --- a/MainForm.Designer.cs +++ b/MainForm.Designer.cs @@ -91,6 +91,7 @@ namespace AndroidSideloader this.speedLabel_Tooltip = new System.Windows.Forms.ToolTip(this.components); this.etaLabel_Tooltip = new System.Windows.Forms.ToolTip(this.components); this.progressDLbtnContainer = new System.Windows.Forms.Panel(); + this.progressBar = new AndroidSideloader.ModernProgressBar(); this.diskLabel = new System.Windows.Forms.Label(); this.questStorageProgressBar = new System.Windows.Forms.Panel(); this.batteryLevImg = new System.Windows.Forms.PictureBox(); @@ -119,14 +120,9 @@ namespace AndroidSideloader this.deviceIdLabel = new System.Windows.Forms.Label(); this.rookieStatusLabel = new System.Windows.Forms.Label(); this.sidebarMediaPanel = new System.Windows.Forms.Panel(); + this.downloadInstallGameButton = new AndroidSideloader.RoundButton(); this.selectedGameLabel = new System.Windows.Forms.Label(); this.tableLayoutPanel1 = new System.Windows.Forms.TableLayoutPanel(); - this.webView21 = new Microsoft.Web.WebView2.WinForms.WebView2(); - this.favoriteGame = new System.Windows.Forms.ContextMenuStrip(this.components); - this.favoriteButton = new System.Windows.Forms.ToolStripMenuItem(); - this.gamesGalleryView = new System.Windows.Forms.FlowLayoutPanel(); - this.btnViewToggle_Tooltip = new System.Windows.Forms.ToolTip(this.components); - this.webViewPlaceholderPanel = new System.Windows.Forms.Panel(); this.searchPanel = new AndroidSideloader.RoundButton(); this.searchIconPictureBox = new System.Windows.Forms.PictureBox(); this.searchTextBox = new System.Windows.Forms.TextBox(); @@ -135,8 +131,12 @@ namespace AndroidSideloader this.btnInstalled = new AndroidSideloader.RoundButton(); this.btnUpdateAvailable = new AndroidSideloader.RoundButton(); this.btnNewerThanList = new AndroidSideloader.RoundButton(); - this.progressBar = new AndroidSideloader.ModernProgressBar(); - this.downloadInstallGameButton = new AndroidSideloader.RoundButton(); + this.webView21 = new Microsoft.Web.WebView2.WinForms.WebView2(); + this.favoriteGame = new System.Windows.Forms.ContextMenuStrip(this.components); + this.favoriteButton = new System.Windows.Forms.ToolStripMenuItem(); + this.gamesGalleryView = new System.Windows.Forms.FlowLayoutPanel(); + this.btnViewToggle_Tooltip = new System.Windows.Forms.ToolTip(this.components); + this.webViewPlaceholderPanel = new System.Windows.Forms.Panel(); ((System.ComponentModel.ISupportInitialize)(this.gamesPictureBox)).BeginInit(); this.gamesPictureBox.SuspendLayout(); this.progressDLbtnContainer.SuspendLayout(); @@ -152,10 +152,10 @@ namespace AndroidSideloader this.statusInfoPanel.SuspendLayout(); this.sidebarMediaPanel.SuspendLayout(); this.tableLayoutPanel1.SuspendLayout(); - ((System.ComponentModel.ISupportInitialize)(this.webView21)).BeginInit(); - this.favoriteGame.SuspendLayout(); this.searchPanel.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)(this.searchIconPictureBox)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.webView21)).BeginInit(); + this.favoriteGame.SuspendLayout(); this.SuspendLayout(); // // m_combo @@ -166,7 +166,7 @@ namespace AndroidSideloader this.m_combo.ForeColor = global::AndroidSideloader.Properties.Settings.Default.FontColor; this.m_combo.Location = new System.Drawing.Point(253, 9); this.m_combo.Name = "m_combo"; - this.m_combo.Size = new System.Drawing.Size(374, 24); + this.m_combo.Size = new System.Drawing.Size(374, 25); this.m_combo.TabIndex = 0; this.m_combo.Text = "Select an Installed App..."; this.m_combo.Visible = false; @@ -230,7 +230,7 @@ namespace AndroidSideloader this.devicesComboBox.Location = new System.Drawing.Point(253, 39); this.devicesComboBox.Margin = new System.Windows.Forms.Padding(2); this.devicesComboBox.Name = "devicesComboBox"; - this.devicesComboBox.Size = new System.Drawing.Size(164, 24); + this.devicesComboBox.Size = new System.Drawing.Size(164, 25); this.devicesComboBox.TabIndex = 1; this.devicesComboBox.Text = "Select your device"; this.devicesComboBox.Visible = false; @@ -246,7 +246,7 @@ namespace AndroidSideloader this.remotesList.Location = new System.Drawing.Point(567, 40); this.remotesList.Margin = new System.Windows.Forms.Padding(2); this.remotesList.Name = "remotesList"; - this.remotesList.Size = new System.Drawing.Size(67, 24); + this.remotesList.Size = new System.Drawing.Size(67, 25); this.remotesList.TabIndex = 3; this.remotesList.Visible = false; this.remotesList.SelectedIndexChanged += new System.EventHandler(this.remotesList_SelectedIndexChanged); @@ -268,10 +268,12 @@ namespace AndroidSideloader this.DownloadsIndex}); this.gamesListView.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); this.gamesListView.ForeColor = System.Drawing.Color.White; + this.gamesListView.FullRowSelect = true; this.gamesListView.HideSelection = false; this.gamesListView.ImeMode = System.Windows.Forms.ImeMode.Off; this.gamesListView.Location = new System.Drawing.Point(258, 44); this.gamesListView.Name = "gamesListView"; + this.gamesListView.OwnerDraw = true; this.gamesListView.ShowGroups = false; this.gamesListView.Size = new System.Drawing.Size(984, 409); this.gamesListView.TabIndex = 6; @@ -287,7 +289,7 @@ namespace AndroidSideloader // GameNameIndex // this.GameNameIndex.Text = "Game Name"; - this.GameNameIndex.Width = 158; + this.GameNameIndex.Width = 160; // // ReleaseNameIndex // @@ -297,29 +299,31 @@ namespace AndroidSideloader // PackageNameIndex // this.PackageNameIndex.Text = "Package Name"; - this.PackageNameIndex.Width = 140; + this.PackageNameIndex.Width = 120; // // VersionCodeIndex // - this.VersionCodeIndex.Text = "Version (vs Installed)"; - this.VersionCodeIndex.Width = 140; + this.VersionCodeIndex.Text = "Version (Rookie/Local)"; + this.VersionCodeIndex.TextAlign = System.Windows.Forms.HorizontalAlignment.Center; + this.VersionCodeIndex.Width = 164; // // ReleaseAPKPathIndex // this.ReleaseAPKPathIndex.Text = "Last Updated"; - this.ReleaseAPKPathIndex.Width = 120; + this.ReleaseAPKPathIndex.TextAlign = System.Windows.Forms.HorizontalAlignment.Center; + this.ReleaseAPKPathIndex.Width = 135; // // VersionNameIndex // - this.VersionNameIndex.Text = "Size (MB)"; - this.VersionNameIndex.TextAlign = System.Windows.Forms.HorizontalAlignment.Right; - this.VersionNameIndex.Width = 80; + this.VersionNameIndex.Text = "Size"; + this.VersionNameIndex.TextAlign = System.Windows.Forms.HorizontalAlignment.Center; + this.VersionNameIndex.Width = 85; // // DownloadsIndex // this.DownloadsIndex.Text = "Popularity"; - this.DownloadsIndex.TextAlign = System.Windows.Forms.HorizontalAlignment.Right; - this.DownloadsIndex.Width = 80; + this.DownloadsIndex.TextAlign = System.Windows.Forms.HorizontalAlignment.Center; + this.DownloadsIndex.Width = 100; // // gamesQueueLabel // @@ -773,6 +777,31 @@ namespace AndroidSideloader this.progressDLbtnContainer.Size = new System.Drawing.Size(984, 40); this.progressDLbtnContainer.TabIndex = 96; // + // progressBar + // + this.progressBar.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.progressBar.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(32)))), ((int)(((byte)(35)))), ((int)(((byte)(45))))); + this.progressBar.BackgroundColor = System.Drawing.Color.FromArgb(((int)(((byte)(28)))), ((int)(((byte)(32)))), ((int)(((byte)(38))))); + this.progressBar.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold); + this.progressBar.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(93)))), ((int)(((byte)(203)))), ((int)(((byte)(173))))); + this.progressBar.IndeterminateColor = System.Drawing.Color.FromArgb(((int)(((byte)(93)))), ((int)(((byte)(203)))), ((int)(((byte)(173))))); + this.progressBar.IsIndeterminate = false; + this.progressBar.Location = new System.Drawing.Point(1, 23); + this.progressBar.Maximum = 100F; + this.progressBar.Minimum = 0F; + this.progressBar.MinimumSize = new System.Drawing.Size(200, 13); + this.progressBar.Name = "progressBar"; + this.progressBar.OperationType = ""; + this.progressBar.ProgressEndColor = System.Drawing.Color.FromArgb(((int)(((byte)(50)))), ((int)(((byte)(160)))), ((int)(((byte)(130))))); + this.progressBar.ProgressStartColor = System.Drawing.Color.FromArgb(((int)(((byte)(120)))), ((int)(((byte)(220)))), ((int)(((byte)(190))))); + this.progressBar.Radius = 6; + this.progressBar.Size = new System.Drawing.Size(983, 13); + this.progressBar.StatusText = ""; + this.progressBar.TabIndex = 7; + this.progressBar.TextColor = System.Drawing.Color.FromArgb(((int)(((byte)(230)))), ((int)(((byte)(230)))), ((int)(((byte)(230))))); + this.progressBar.Value = 0F; + // // diskLabel // this.diskLabel.BackColor = System.Drawing.Color.Transparent; @@ -1238,6 +1267,35 @@ namespace AndroidSideloader this.sidebarMediaPanel.Size = new System.Drawing.Size(233, 214); this.sidebarMediaPanel.TabIndex = 101; // + // downloadInstallGameButton + // + this.downloadInstallGameButton.Active1 = System.Drawing.Color.FromArgb(((int)(((byte)(110)))), ((int)(((byte)(215)))), ((int)(((byte)(190))))); + this.downloadInstallGameButton.Active2 = System.Drawing.Color.FromArgb(((int)(((byte)(110)))), ((int)(((byte)(215)))), ((int)(((byte)(190))))); + this.downloadInstallGameButton.BackColor = System.Drawing.Color.Transparent; + this.downloadInstallGameButton.Cursor = System.Windows.Forms.Cursors.Hand; + this.downloadInstallGameButton.DialogResult = System.Windows.Forms.DialogResult.OK; + this.downloadInstallGameButton.Disabled1 = System.Drawing.Color.FromArgb(((int)(((byte)(16)))), ((int)(((byte)(18)))), ((int)(((byte)(22))))); + this.downloadInstallGameButton.Disabled2 = System.Drawing.Color.FromArgb(((int)(((byte)(16)))), ((int)(((byte)(18)))), ((int)(((byte)(22))))); + this.downloadInstallGameButton.DisabledStrokeColor = System.Drawing.Color.FromArgb(((int)(((byte)(50)))), ((int)(((byte)(55)))), ((int)(((byte)(65))))); + this.downloadInstallGameButton.Enabled = false; + this.downloadInstallGameButton.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold); + this.downloadInstallGameButton.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(59)))), ((int)(((byte)(67)))), ((int)(((byte)(82))))); + this.downloadInstallGameButton.Inactive1 = System.Drawing.Color.FromArgb(((int)(((byte)(93)))), ((int)(((byte)(203)))), ((int)(((byte)(173))))); + this.downloadInstallGameButton.Inactive2 = System.Drawing.Color.FromArgb(((int)(((byte)(93)))), ((int)(((byte)(203)))), ((int)(((byte)(173))))); + this.downloadInstallGameButton.Location = new System.Drawing.Point(6, 177); + this.downloadInstallGameButton.Margin = new System.Windows.Forms.Padding(0); + this.downloadInstallGameButton.Name = "downloadInstallGameButton"; + this.downloadInstallGameButton.Radius = 4; + this.downloadInstallGameButton.Size = new System.Drawing.Size(238, 30); + this.downloadInstallGameButton.Stroke = true; + this.downloadInstallGameButton.StrokeColor = System.Drawing.Color.FromArgb(((int)(((byte)(93)))), ((int)(((byte)(203)))), ((int)(((byte)(173))))); + this.downloadInstallGameButton.TabIndex = 94; + this.downloadInstallGameButton.Text = "DOWNLOAD AND INSTALL"; + this.downloadInstallGameButton.Transparency = false; + this.downloadInstallGameButton.Click += new System.EventHandler(this.downloadInstallGameButton_Click); + this.downloadInstallGameButton.DragDrop += new System.Windows.Forms.DragEventHandler(this.Form1_DragDrop); + this.downloadInstallGameButton.DragEnter += new System.Windows.Forms.DragEventHandler(this.Form1_DragEnter); + // // selectedGameLabel // this.selectedGameLabel.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(20)))), ((int)(((byte)(24)))), ((int)(((byte)(29))))); @@ -1276,58 +1334,6 @@ namespace AndroidSideloader this.tableLayoutPanel1.Size = new System.Drawing.Size(984, 34); this.tableLayoutPanel1.TabIndex = 97; // - // webView21 - // - this.webView21.AllowExternalDrop = true; - this.webView21.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); - this.webView21.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(24)))), ((int)(((byte)(26)))), ((int)(((byte)(30))))); - this.webView21.CreationProperties = null; - this.webView21.DefaultBackgroundColor = System.Drawing.Color.FromArgb(((int)(((byte)(24)))), ((int)(((byte)(26)))), ((int)(((byte)(30))))); - this.webView21.Location = new System.Drawing.Point(259, 496); - this.webView21.Name = "webView21"; - this.webView21.Size = new System.Drawing.Size(384, 216); - this.webView21.TabIndex = 98; - this.webView21.ZoomFactor = 1D; - // - // favoriteGame - // - this.favoriteGame.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(40)))), ((int)(((byte)(42)))), ((int)(((byte)(48))))); - this.favoriteGame.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.favoriteButton}); - this.favoriteGame.Name = "favoriteGame"; - this.favoriteGame.ShowImageMargin = false; - this.favoriteGame.Size = new System.Drawing.Size(149, 26); - // - // favoriteButton - // - this.favoriteButton.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(40)))), ((int)(((byte)(42)))), ((int)(((byte)(48))))); - this.favoriteButton.ForeColor = System.Drawing.Color.White; - this.favoriteButton.Name = "favoriteButton"; - this.favoriteButton.Size = new System.Drawing.Size(148, 22); - this.favoriteButton.Text = "★ Add to Favorites"; - this.favoriteButton.Click += new System.EventHandler(this.favoriteButton_Click); - // - // gamesGalleryView - // - this.gamesGalleryView.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) - | System.Windows.Forms.AnchorStyles.Left) - | System.Windows.Forms.AnchorStyles.Right))); - this.gamesGalleryView.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(15)))), ((int)(((byte)(15)))), ((int)(((byte)(15))))); - this.gamesGalleryView.Location = new System.Drawing.Point(258, 44); - this.gamesGalleryView.Name = "gamesGalleryView"; - this.gamesGalleryView.Size = new System.Drawing.Size(984, 409); - this.gamesGalleryView.TabIndex = 102; - // - // webViewPlaceholderPanel - // - this.webViewPlaceholderPanel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); - this.webViewPlaceholderPanel.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(24)))), ((int)(((byte)(26)))), ((int)(((byte)(30))))); - this.webViewPlaceholderPanel.Location = new System.Drawing.Point(259, 496); - this.webViewPlaceholderPanel.Name = "webViewPlaceholderPanel"; - this.webViewPlaceholderPanel.Size = new System.Drawing.Size(384, 217); - this.webViewPlaceholderPanel.TabIndex = 103; - this.webViewPlaceholderPanel.Paint += new System.Windows.Forms.PaintEventHandler(this.webViewPlaceholderPanel_Paint); - // // searchPanel // this.searchPanel.Active1 = System.Drawing.Color.FromArgb(((int)(((byte)(51)))), ((int)(((byte)(56)))), ((int)(((byte)(70))))); @@ -1511,59 +1517,57 @@ namespace AndroidSideloader this.btnNewerThanList.Transparency = false; this.btnNewerThanList.Click += new System.EventHandler(this.btnNewerThanList_Click); // - // progressBar + // webView21 // - this.progressBar.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + this.webView21.AllowExternalDrop = true; + this.webView21.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.webView21.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(24)))), ((int)(((byte)(26)))), ((int)(((byte)(30))))); + this.webView21.CreationProperties = null; + this.webView21.DefaultBackgroundColor = System.Drawing.Color.FromArgb(((int)(((byte)(24)))), ((int)(((byte)(26)))), ((int)(((byte)(30))))); + this.webView21.Location = new System.Drawing.Point(259, 496); + this.webView21.Name = "webView21"; + this.webView21.Size = new System.Drawing.Size(384, 216); + this.webView21.TabIndex = 98; + this.webView21.ZoomFactor = 1D; + // + // favoriteGame + // + this.favoriteGame.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(40)))), ((int)(((byte)(42)))), ((int)(((byte)(48))))); + this.favoriteGame.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.favoriteButton}); + this.favoriteGame.Name = "favoriteGame"; + this.favoriteGame.ShowImageMargin = false; + this.favoriteGame.Size = new System.Drawing.Size(149, 26); + // + // favoriteButton + // + this.favoriteButton.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(40)))), ((int)(((byte)(42)))), ((int)(((byte)(48))))); + this.favoriteButton.ForeColor = System.Drawing.Color.White; + this.favoriteButton.Name = "favoriteButton"; + this.favoriteButton.Size = new System.Drawing.Size(148, 22); + this.favoriteButton.Text = "★ Add to Favorites"; + this.favoriteButton.Click += new System.EventHandler(this.favoriteButton_Click); + // + // gamesGalleryView + // + this.gamesGalleryView.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); - this.progressBar.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(32)))), ((int)(((byte)(35)))), ((int)(((byte)(45))))); - this.progressBar.BackgroundColor = System.Drawing.Color.FromArgb(((int)(((byte)(28)))), ((int)(((byte)(32)))), ((int)(((byte)(38))))); - this.progressBar.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold); - this.progressBar.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(93)))), ((int)(((byte)(203)))), ((int)(((byte)(173))))); - this.progressBar.IndeterminateColor = System.Drawing.Color.FromArgb(((int)(((byte)(93)))), ((int)(((byte)(203)))), ((int)(((byte)(173))))); - this.progressBar.IsIndeterminate = false; - this.progressBar.Location = new System.Drawing.Point(1, 23); - this.progressBar.Maximum = 100F; - this.progressBar.Minimum = 0F; - this.progressBar.MinimumSize = new System.Drawing.Size(200, 13); - this.progressBar.Name = "progressBar"; - this.progressBar.OperationType = ""; - this.progressBar.ProgressEndColor = System.Drawing.Color.FromArgb(((int)(((byte)(50)))), ((int)(((byte)(160)))), ((int)(((byte)(130))))); - this.progressBar.ProgressStartColor = System.Drawing.Color.FromArgb(((int)(((byte)(120)))), ((int)(((byte)(220)))), ((int)(((byte)(190))))); - this.progressBar.Radius = 6; - this.progressBar.Size = new System.Drawing.Size(983, 13); - this.progressBar.StatusText = ""; - this.progressBar.TabIndex = 7; - this.progressBar.TextColor = System.Drawing.Color.FromArgb(((int)(((byte)(230)))), ((int)(((byte)(230)))), ((int)(((byte)(230))))); - this.progressBar.Value = 0F; + this.gamesGalleryView.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(15)))), ((int)(((byte)(15)))), ((int)(((byte)(15))))); + this.gamesGalleryView.Location = new System.Drawing.Point(258, 44); + this.gamesGalleryView.Name = "gamesGalleryView"; + this.gamesGalleryView.Size = new System.Drawing.Size(984, 409); + this.gamesGalleryView.TabIndex = 102; // - // downloadInstallGameButton + // webViewPlaceholderPanel // - this.downloadInstallGameButton.Active1 = System.Drawing.Color.FromArgb(((int)(((byte)(110)))), ((int)(((byte)(215)))), ((int)(((byte)(190))))); - this.downloadInstallGameButton.Active2 = System.Drawing.Color.FromArgb(((int)(((byte)(110)))), ((int)(((byte)(215)))), ((int)(((byte)(190))))); - this.downloadInstallGameButton.BackColor = System.Drawing.Color.Transparent; - this.downloadInstallGameButton.Cursor = System.Windows.Forms.Cursors.Hand; - this.downloadInstallGameButton.DialogResult = System.Windows.Forms.DialogResult.OK; - this.downloadInstallGameButton.Disabled1 = System.Drawing.Color.FromArgb(((int)(((byte)(16)))), ((int)(((byte)(18)))), ((int)(((byte)(22))))); - this.downloadInstallGameButton.Disabled2 = System.Drawing.Color.FromArgb(((int)(((byte)(16)))), ((int)(((byte)(18)))), ((int)(((byte)(22))))); - this.downloadInstallGameButton.DisabledStrokeColor = System.Drawing.Color.FromArgb(((int)(((byte)(50)))), ((int)(((byte)(55)))), ((int)(((byte)(65))))); - this.downloadInstallGameButton.Enabled = false; - this.downloadInstallGameButton.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold); - this.downloadInstallGameButton.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(59)))), ((int)(((byte)(67)))), ((int)(((byte)(82))))); - this.downloadInstallGameButton.Inactive1 = System.Drawing.Color.FromArgb(((int)(((byte)(93)))), ((int)(((byte)(203)))), ((int)(((byte)(173))))); - this.downloadInstallGameButton.Inactive2 = System.Drawing.Color.FromArgb(((int)(((byte)(93)))), ((int)(((byte)(203)))), ((int)(((byte)(173))))); - this.downloadInstallGameButton.Location = new System.Drawing.Point(6, 177); - this.downloadInstallGameButton.Margin = new System.Windows.Forms.Padding(0); - this.downloadInstallGameButton.Name = "downloadInstallGameButton"; - this.downloadInstallGameButton.Radius = 4; - this.downloadInstallGameButton.Size = new System.Drawing.Size(238, 30); - this.downloadInstallGameButton.Stroke = true; - this.downloadInstallGameButton.StrokeColor = System.Drawing.Color.FromArgb(((int)(((byte)(93)))), ((int)(((byte)(203)))), ((int)(((byte)(173))))); - this.downloadInstallGameButton.TabIndex = 94; - this.downloadInstallGameButton.Text = "DOWNLOAD AND INSTALL"; - this.downloadInstallGameButton.Transparency = false; - this.downloadInstallGameButton.Click += new System.EventHandler(this.downloadInstallGameButton_Click); - this.downloadInstallGameButton.DragDrop += new System.Windows.Forms.DragEventHandler(this.Form1_DragDrop); - this.downloadInstallGameButton.DragEnter += new System.Windows.Forms.DragEventHandler(this.Form1_DragEnter); + this.webViewPlaceholderPanel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.webViewPlaceholderPanel.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(24)))), ((int)(((byte)(26)))), ((int)(((byte)(30))))); + this.webViewPlaceholderPanel.Location = new System.Drawing.Point(259, 496); + this.webViewPlaceholderPanel.Name = "webViewPlaceholderPanel"; + this.webViewPlaceholderPanel.Size = new System.Drawing.Size(384, 217); + this.webViewPlaceholderPanel.TabIndex = 103; + this.webViewPlaceholderPanel.Paint += new System.Windows.Forms.PaintEventHandler(this.webViewPlaceholderPanel_Paint); // // MainForm // @@ -1615,11 +1619,11 @@ namespace AndroidSideloader this.statusInfoPanel.ResumeLayout(false); this.sidebarMediaPanel.ResumeLayout(false); this.tableLayoutPanel1.ResumeLayout(false); - ((System.ComponentModel.ISupportInitialize)(this.webView21)).EndInit(); - this.favoriteGame.ResumeLayout(false); this.searchPanel.ResumeLayout(false); this.searchPanel.PerformLayout(); ((System.ComponentModel.ISupportInitialize)(this.searchIconPictureBox)).EndInit(); + ((System.ComponentModel.ISupportInitialize)(this.webView21)).EndInit(); + this.favoriteGame.ResumeLayout(false); this.ResumeLayout(false); this.PerformLayout(); @@ -1732,5 +1736,6 @@ namespace AndroidSideloader private Label activeMirrorLabel; private Label sideloadingStatusLabel; private Label rookieStatusLabel; + private ModernListView _listViewRenderer; } } \ No newline at end of file diff --git a/MainForm.cs b/MainForm.cs index 68dcc72..c1bdda3 100755 --- a/MainForm.cs +++ b/MainForm.cs @@ -127,9 +127,18 @@ namespace AndroidSideloader gamesQueListBox.DataSource = gamesQueueList; SetCurrentLogPath(); StartTimers(); + lvwColumnSorter = new ListViewColumnSorter(); gamesListView.ListViewItemSorter = lvwColumnSorter; + // Initialize modern ListView renderer + _listViewRenderer = new ModernListView(gamesListView, lvwColumnSorter); + + // Set a larger item height for increased spacing between rows + ImageList rowSpacingImageList = new ImageList(); + rowSpacingImageList.ImageSize = new Size(1, 28); + gamesListView.SmallImageList = rowSpacingImageList; + SubscribeToHoverEvents(questInfoPanel); this.Resize += MainForm_Resize; @@ -2163,6 +2172,63 @@ namespace AndroidSideloader Logger.Log($"Cloud versions precomputed in {sw.ElapsedMilliseconds}ms"); sw.Restart(); + // Calculate popularity rankings + var popularityScores = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (string[] release in SideloaderRCLONE.games) + { + string packagename = release[SideloaderRCLONE.PackageNameIndex]; + + // Parse popularity score from column 6 + if (release.Length > 6 && double.TryParse(release[6], out double score)) + { + // Track the highest score per package + if (popularityScores.TryGetValue(packagename, out var existing)) + { + if (score > existing) + popularityScores[packagename] = score; + } + else + { + popularityScores[packagename] = score; + } + } + } + + // Sort packages by popularity (descending) and assign rankings + var rankedPackages = popularityScores + .Where(kvp => kvp.Value > 0) // Exclude 0.00 scores + .OrderByDescending(kvp => kvp.Value) + .Select((kvp, index) => new { Package = kvp.Key, Rank = index + 1 }) + .ToDictionary(x => x.Package, x => x.Rank, StringComparer.OrdinalIgnoreCase); + + Logger.Log($"Popularity rankings calculated for {rankedPackages.Count} games in {sw.ElapsedMilliseconds}ms"); + sw.Restart(); + + // Build MR-Fix lookup. map base game name to whether an MR-Fix exists + var mrFixGames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (string[] release in SideloaderRCLONE.games) + { + string gameName = release[SideloaderRCLONE.GameNameIndex]; + // Check if game name contains "(MR-Fix)" using IndexOf for case-insensitive search + if (gameName.IndexOf("(MR-Fix)", StringComparison.OrdinalIgnoreCase) >= 0) + { + // Extract base game name by removing "(MR-Fix)" suffix + int mrFixIndex = gameName.IndexOf("(MR-Fix)", StringComparison.OrdinalIgnoreCase); + string baseGameName = gameName.Substring(0, mrFixIndex).Trim(); + if (gameName.Length > mrFixIndex + 8) + { + baseGameName += gameName.Substring(mrFixIndex + 8); + } + baseGameName = baseGameName.Trim(); + mrFixGames.Add(baseGameName); + } + } + + Logger.Log($"MR-Fix lookup built with {mrFixGames.Count} games in {sw.ElapsedMilliseconds}ms"); + sw.Restart(); + // Process games on background thread await Task.Run(() => { @@ -2179,7 +2245,28 @@ namespace AndroidSideloader var item = new ListViewItem(release); - // Add installed version as additional column + // Check if this is a 0 MB entry that should be excluded + bool shouldSkip = false; + if (release.Length > 5 && double.TryParse(release[5], out double sizeInMB)) + { + // If size is 0 MB and this is not already an MR-Fix version + if (sizeInMB == 0 && gameName.IndexOf("(MR-Fix)", StringComparison.OrdinalIgnoreCase) < 0) + { + // Check if there's an MR-Fix version of this game + if (mrFixGames.Contains(gameName)) + { + shouldSkip = true; + //Logger.Log($"Skipping 0 MB entry for '{gameName}' - MR-Fix version exists"); + } + } + } + + if (shouldSkip) + { + continue; // Skip this entry + } + + // Show the installed version ulong installedVersion = 0; if (installedVersions.TryGetValue(packagename, out ulong installedVersionInt)) @@ -2231,10 +2318,43 @@ namespace AndroidSideloader } } - // Add the installed version to the ListView item if (installedVersion != 0) { - item.SubItems[3].Text = $"{item.SubItems[3].Text} ({installedVersion})"; + // Show the installed version and attach 'v' to both versions + item.SubItems[3].Text = $"v{item.SubItems[3].Text} / v{installedVersion}"; + } + else + { + // Attach 'v' to remote version + item.SubItems[3].Text = $"v{item.SubItems[3].Text}"; + } + + // Remove ' UTC' from last updated + item.SubItems[4].Text = item.SubItems[4].Text.Replace(" UTC", ""); + + // Convert size to GB or MB + if (double.TryParse(item.SubItems[5].Text, out double itemSizeInMB)) + { + if (itemSizeInMB >= 1024) + { + double sizeInGB = itemSizeInMB / 1024; + item.SubItems[5].Text = $"{sizeInGB:F2} GB"; + } + else + { + item.SubItems[5].Text = $"{itemSizeInMB:F0} MB"; + } + } + + // STEP 4: Replace popularity score with ranking + if (rankedPackages.TryGetValue(packagename, out int rank)) + { + item.SubItems[6].Text = $"#{rank}"; + } + else + { + // Unranked (0.00 popularity or not found) + item.SubItems[6].Text = "-"; } if (favoriteView) @@ -4382,19 +4502,34 @@ If the problem persists, visit our Telegram (https://t.me/VRPirates) or Discord private void listView1_ColumnClick(object sender, ColumnClickEventArgs e) { - // Determine if clicked column is already the column that is being sorted. + // Determine sort order if (e.Column == lvwColumnSorter.SortColumn) { - // Reverse the current sort direction for this column. lvwColumnSorter.Order = lvwColumnSorter.Order == SortOrder.Ascending ? SortOrder.Descending : SortOrder.Ascending; } else { lvwColumnSorter.SortColumn = e.Column; - lvwColumnSorter.Order = e.Column == 4 ? SortOrder.Descending : SortOrder.Ascending; + lvwColumnSorter.Order = + (e.Column == 4 || e.Column == 5) ? SortOrder.Descending : + (e.Column == 6) ? SortOrder.Ascending : + SortOrder.Ascending; } - // Perform the sort with these new sort options. - gamesListView.Sort(); + + // Suspend drawing during sort + gamesListView.BeginUpdate(); + try + { + gamesListView.Sort(); + } + finally + { + gamesListView.EndUpdate(); + } + + // Invalidate header to update sort indicators + gamesListView.Invalidate(new Rectangle(0, 0, gamesListView.ClientSize.Width, + gamesListView.Font.Height + 8)); } private void CheckEnter(object sender, System.Windows.Forms.KeyPressEventArgs e) @@ -7253,5 +7388,12 @@ function onYouTubeIframeAPIReady() { action.Invoke(); } } + + public static void SetStyle(this Control control, ControlStyles styles, bool value) + { + typeof(Control).GetMethod("SetStyle", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + ?.Invoke(control, new object[] { styles, value }); + } } } diff --git a/ModernListView.cs b/ModernListView.cs new file mode 100644 index 0000000..522e66e --- /dev/null +++ b/ModernListView.cs @@ -0,0 +1,1232 @@ +using System; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Text; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace AndroidSideloader +{ + public class ModernListView : NativeWindow + { + private const int WM_PAINT = 0x000F; + private const int WM_ERASEBKGND = 0x0014; + private const int WM_VSCROLL = 0x0115; + private const int WM_HSCROLL = 0x0114; + private const int WM_MOUSEWHEEL = 0x020A; + private const int WM_KEYDOWN = 0x0100; + private const int WM_SETCURSOR = 0x0020; + private const int WM_SETFONT = 0x0030; + private const int WM_PRINTCLIENT = 0x0318; + + private const int LVM_GETHEADER = 0x101F; + private const int LVM_SETEXTENDEDLISTVIEWSTYLE = 0x1036; + private const int LVS_EX_DOUBLEBUFFER = 0x00010000; + + private const int HDM_HITTEST = 0x1206; + private const int HDM_LAYOUT = 0x1205; + private const int HHT_ONDIVIDER = 0x00000004; + private const int HHT_ONDIVOPEN = 0x00000008; + + private const int CellPaddingX = 10; + private const int CellPaddingTotalX = CellPaddingX * 2; + private const int CUSTOM_HEADER_HEIGHT = 32; + + private static readonly Color HeaderBg = Color.FromArgb(28, 32, 38); + private static readonly Color HeaderBorder = Color.FromArgb(50, 55, 65); + private static readonly Color HeaderText = Color.FromArgb(140, 145, 150); + private static readonly Color HeaderTextSorted = Color.FromArgb(93, 203, 173); + + private static readonly Color RowAlt = Color.FromArgb(30, 32, 38); + private static readonly Color RowNormal = Color.FromArgb(24, 26, 30); + private static readonly Color RowHover = Color.FromArgb(42, 48, 58); + + private static readonly Color RowSelectedActive = Color.FromArgb(50, 65, 85); + private static readonly Color RowSelectedActiveBorder = Color.FromArgb(93, 203, 173); + + private static readonly Color RowSelectedInactive = Color.FromArgb(64, 66, 80); + private static readonly Color RowSelectedInactiveBorder = Color.FromArgb(180, 185, 190); + + private static readonly Font HeaderFont = new Font("Segoe UI", 9f, FontStyle.Bold); + private static readonly Font ItemFont = new Font("Segoe UI", 9.75f, FontStyle.Regular); + private static readonly Font ItemFontBold = new Font("Segoe UI", 9.75f, FontStyle.Bold); + + private static readonly SolidBrush HeaderBgBrush = new SolidBrush(HeaderBg); + private static readonly SolidBrush RowAltBrush = new SolidBrush(RowAlt); + private static readonly SolidBrush RowNormalBrush = new SolidBrush(RowNormal); + private static readonly SolidBrush RowHoverBrush = new SolidBrush(RowHover); + private static readonly SolidBrush RowSelectedActiveBrush = new SolidBrush(RowSelectedActive); + private static readonly SolidBrush RowSelectedInactiveBrush = new SolidBrush(RowSelectedInactive); + + private static readonly Pen HeaderBorderPen = new Pen(HeaderBorder, 1); + private static readonly Pen HeaderSeparatorPen = new Pen(Color.FromArgb(55, 60, 70), 1); + private static readonly Pen SelectedActiveBorderPen = new Pen(RowSelectedActiveBorder, 4); + private static readonly Pen SelectedInactiveBorderPen = new Pen(RowSelectedInactiveBorder, 4); + private static readonly SolidBrush SortArrowBrush = new SolidBrush(HeaderTextSorted); + + private readonly ListView _listView; + private readonly ListViewColumnSorter _columnSorter; + private readonly HeaderCursorWindow _headerCursor; + private readonly Timer _columnResizeDebounce = new Timer { Interval = 150 }; + + public int DefaultSortColumn { get; set; } = 0; + public SortOrder DefaultSortOrder { get; set; } = SortOrder.Ascending; + + public enum ColumnFillMode { StretchLastColumn, Proportional } + private ColumnFillMode _fillMode = ColumnFillMode.Proportional; + public ColumnFillMode FillMode + { + get => _fillMode; + set { _fillMode = value; AutoFitColumnsToWidth(); RecalcMarqueeForSelection(); } + } + + public bool MarqueeEnabled { get; set; } = true; + public bool MarqueeOnlyWhenFocused { get; set; } = false; + public int MarqueeStartDelayMs { get; set; } = 250; + public int MarqueePauseMs { get; set; } = 500; + public float MarqueeSpeedPxPerSecond { get; set; } = 32f; + public bool MarqueeFadeEdges { get; set; } = true; + public int MarqueeFadeWidthPx { get; set; } = 8; + public int MarqueeOvershootPx { get; set; } = 2; + public int MinOverflowForMarqueePx { get; set; } = 2; + public float MarqueeMinProgressPerSecond { get; set; } = 0.20f; + + private readonly Timer _marqueeTimer = new Timer { Interval = 8 }; // ~120 FPS + private readonly Stopwatch _marqueeSw = Stopwatch.StartNew(); + + private float[] _marqueeOffsets = new float[0]; + private float[] _marqueeMax = new float[0]; + private float[] _marqueeProgress = new float[0]; + private int[] _marqueeDirs = new int[0]; + private int[] _marqueeHoldMs = new int[0]; + + private int _marqueeSelectedIndex = -1; + private long _marqueeLastTickMs; + private long _marqueeStartAtMs; + + private int _hoveredItemIndex = -1; + private Rectangle _lastIconBounds = Rectangle.Empty; + private bool _inPostPaintOverlay; + private bool _inAutoFit; + private bool _userIsResizingColumns; + private float[] _columnRatios = new float[0]; + + private IntPtr _headerHfont = IntPtr.Zero; + + public int HoveredItemIndex => _hoveredItemIndex; + public Rectangle LastIconBounds + { + get => _lastIconBounds; + set + { + if (_lastIconBounds != value) + { + if (!_lastIconBounds.IsEmpty) + _listView.Invalidate(Rectangle.Inflate(_lastIconBounds, 2, 2)); + _lastIconBounds = value; + } + } + } + + [DllImport("uxtheme.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern int SetWindowTheme(IntPtr hWnd, string pszSubAppName, string pszSubIdList); + + [DllImport("dwmapi.dll", PreserveSig = true)] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int dwAttribute, ref int pvAttribute, int cbAttribute); + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern bool GetCursorPos(out Point lpPoint); + + [DllImport("user32.dll")] + private static extern bool ScreenToClient(IntPtr hWnd, ref Point lpPoint); + + [DllImport("user32.dll")] + private static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect); + + [DllImport("gdi32.dll")] + private static extern bool DeleteObject(IntPtr hObject); + + [StructLayout(LayoutKind.Sequential)] + private struct HDHITTESTINFO + { + public Point pt; + public uint flags; + public int iItem; + } + + [StructLayout(LayoutKind.Sequential)] + private struct HDLAYOUT + { + public IntPtr prc; + public IntPtr pwpos; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WINDOWPOS + { + public IntPtr hwnd; + public IntPtr hwndInsertAfter; + public int x; + public int y; + public int cx; + public int cy; + public uint flags; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int left; + public int top; + public int right; + public int bottom; + } + + private sealed class HeaderCursorWindow : NativeWindow + { + private readonly ModernListView _owner; + + public HeaderCursorWindow(ModernListView owner) + { + _owner = owner; + } + + public void Attach(IntPtr headerHwnd) + { + if (headerHwnd != IntPtr.Zero) + AssignHandle(headerHwnd); + } + + protected override void WndProc(ref Message m) + { + if (m.Msg == WM_ERASEBKGND) + { + m.Result = (IntPtr)1; + return; + } + + if (m.Msg == WM_SETCURSOR) + { + if (IsOnDivider()) + { + base.WndProc(ref m); + return; + } + + Cursor.Current = Cursors.Hand; + m.Result = (IntPtr)1; + return; + } + + if (m.Msg == HDM_LAYOUT) + { + base.WndProc(ref m); + + try + { + HDLAYOUT hdl = Marshal.PtrToStructure(m.LParam); + WINDOWPOS wpos = Marshal.PtrToStructure(hdl.pwpos); + RECT rc = Marshal.PtrToStructure(hdl.prc); + + wpos.cy = CUSTOM_HEADER_HEIGHT; + rc.top = CUSTOM_HEADER_HEIGHT; + + Marshal.StructureToPtr(wpos, hdl.pwpos, false); + Marshal.StructureToPtr(rc, hdl.prc, false); + } + catch { } + + m.Result = (IntPtr)1; + return; + } + + base.WndProc(ref m); + + if (m.Msg == WM_PAINT || m.Msg == WM_PRINTCLIENT) + _owner.PaintHeaderRightGap(Handle); + } + + private bool IsOnDivider() + { + if (Handle == IntPtr.Zero || !GetCursorPos(out var screenPt)) return false; + + var pt = screenPt; + if (!ScreenToClient(Handle, ref pt)) return false; + + var hti = new HDHITTESTINFO { pt = pt }; + IntPtr mem = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(HDHITTESTINFO))); + try + { + Marshal.StructureToPtr(hti, mem, false); + SendMessage(Handle, HDM_HITTEST, IntPtr.Zero, mem); + hti = Marshal.PtrToStructure(mem); + return (hti.flags & (HHT_ONDIVIDER | HHT_ONDIVOPEN)) != 0; + } + finally + { + Marshal.FreeHGlobal(mem); + } + } + } + + public ModernListView(ListView listView, ListViewColumnSorter columnSorter) + { + _listView = listView; + _columnSorter = columnSorter; + _headerCursor = new HeaderCursorWindow(this); + + _listView.OwnerDraw = true; + _listView.FullRowSelect = true; + _listView.View = View.Details; + _listView.HotTracking = false; + _listView.HoverSelection = false; + _listView.GridLines = false; + + EnableManagedDoubleBuffering(); + + if (_listView.IsHandleCreated) + AssignHandle(_listView.Handle); + + _listView.DrawColumnHeader += DrawColumnHeader; + _listView.DrawItem += DrawItem; + _listView.DrawSubItem += DrawSubItem; + _listView.MouseMove += OnMouseMove; + _listView.MouseLeave += OnMouseLeave; + _listView.Paint += OnPaint; + _listView.Resize += OnResize; + _listView.ColumnWidthChanging += OnColumnWidthChanging; + _listView.ColumnWidthChanged += OnColumnWidthChanged; + _listView.HandleCreated += OnHandleCreated; + _listView.HandleDestroyed += OnHandleDestroyed; + + _listView.SelectedIndexChanged += (s, e) => RecalcMarqueeForSelection(); + _listView.ItemSelectionChanged += (s, e) => RecalcMarqueeForSelection(); + _listView.GotFocus += (s, e) => UpdateMarqueeTimerState(); + _listView.LostFocus += (s, e) => UpdateMarqueeTimerState(); + + _columnResizeDebounce.Tick += (s, e) => + { + _columnResizeDebounce.Stop(); + _userIsResizingColumns = false; + CaptureColumnRatios(); + AutoFitColumnsToWidth(); + RecalcMarqueeForSelection(); + _listView.Invalidate(); + }; + + _marqueeTimer.Tick += (s, e) => UpdateMarquee(); + + InitializeColumnSizing(); + RecalcMarqueeForSelection(); + } + + protected override void WndProc(ref Message m) + { + if (m.Msg == WM_ERASEBKGND) + { + m.Result = (IntPtr)1; + return; + } + + base.WndProc(ref m); + + if (m.Msg == WM_PAINT && !_inPostPaintOverlay) + { + try + { + _inPostPaintOverlay = true; + PaintEmptyAreaOverlay(); + } + finally + { + _inPostPaintOverlay = false; + } + } + + switch (m.Msg) + { + case WM_VSCROLL: + case WM_HSCROLL: + case WM_MOUSEWHEEL: + OnScrollDetected(); + break; + case WM_KEYDOWN: + int key = m.WParam.ToInt32(); + if (key == 0x21 || key == 0x22 || key == 0x26 || key == 0x28) + OnScrollDetected(); + break; + } + } + + private void OnScrollDetected() + { + _lastIconBounds = Rectangle.Empty; + _listView.Invalidate(); + } + + private void OnHandleCreated(object sender, EventArgs e) + { + AssignHandle(_listView.Handle); + ApplyModernScrollbars(); + EnableNativeDoubleBuffering(); + + _listView.BeginInvoke(new Action(() => + { + if (!_listView.IsHandleCreated) return; + + IntPtr header = SendMessage(_listView.Handle, LVM_GETHEADER, IntPtr.Zero, IntPtr.Zero); + _headerCursor.Attach(header); + + ApplyHeaderFont(header); + PaintHeaderRightGap(header); + })); + + InitializeColumnSizing(); + RecalcMarqueeForSelection(); + } + + private void OnHandleDestroyed(object sender, EventArgs e) + { + _columnResizeDebounce.Stop(); + _marqueeTimer.Stop(); + _headerCursor.ReleaseHandle(); + ReleaseHandle(); + + if (_headerHfont != IntPtr.Zero) + { + DeleteObject(_headerHfont); + _headerHfont = IntPtr.Zero; + } + } + + private void ApplyHeaderFont(IntPtr headerHandle) + { + if (headerHandle == IntPtr.Zero) return; + + if (_headerHfont == IntPtr.Zero) + _headerHfont = HeaderFont.ToHfont(); + + SendMessage(headerHandle, WM_SETFONT, _headerHfont, (IntPtr)1); + _listView.Invalidate(); + } + + private void EnableManagedDoubleBuffering() + { + typeof(Control).GetProperty("DoubleBuffered", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + ?.SetValue(_listView, true, null); + } + + private void EnableNativeDoubleBuffering() + { + if (!_listView.IsHandleCreated) return; + SendMessage(_listView.Handle, LVM_SETEXTENDEDLISTVIEWSTYLE, + (IntPtr)LVS_EX_DOUBLEBUFFER, (IntPtr)LVS_EX_DOUBLEBUFFER); + } + + private void ApplyModernScrollbars() + { + if (!_listView.IsHandleCreated) return; + + int dark = 1; + int hr = DwmSetWindowAttribute(_listView.Handle, 20, ref dark, sizeof(int)); + if (hr != 0) + DwmSetWindowAttribute(_listView.Handle, 19, ref dark, sizeof(int)); + + if (SetWindowTheme(_listView.Handle, "DarkMode_Explorer", null) != 0) + SetWindowTheme(_listView.Handle, "Explorer", null); + } + + private void InitializeColumnSizing() + { + if (!_listView.IsHandleCreated || _listView.Columns.Count == 0) return; + CaptureColumnRatios(); + AutoFitColumnsToWidth(); + } + + private void OnResize(object sender, EventArgs e) + { + AutoFitColumnsToWidth(); + RecalcMarqueeForSelection(); + _listView.Invalidate(); + } + + private void OnColumnWidthChanging(object sender, ColumnWidthChangingEventArgs e) + { + _userIsResizingColumns = true; + _columnResizeDebounce.Stop(); + } + + private void OnColumnWidthChanged(object sender, ColumnWidthChangedEventArgs e) + { + if (_inAutoFit) return; + + if (_userIsResizingColumns) + { + _columnResizeDebounce.Stop(); + _columnResizeDebounce.Start(); + return; + } + + RecalcMarqueeForSelection(); + _listView.Invalidate(); + } + + private void CaptureColumnRatios() + { + int count = _listView.Columns.Count; + if (count == 0) { _columnRatios = new float[0]; return; } + + float total = 0f; + for (int i = 0; i < count; i++) + total += Math.Max(1, _listView.Columns[i].Width); + + if (total <= 0.01f) total = 1f; + + _columnRatios = new float[count]; + for (int i = 0; i < count; i++) + _columnRatios[i] = Math.Max(1, _listView.Columns[i].Width) / total; + } + + private void AutoFitColumnsToWidth() + { + if (_inAutoFit || _userIsResizingColumns || !_listView.IsHandleCreated || _listView.Columns.Count == 0) + return; + + try + { + _inAutoFit = true; + _listView.BeginUpdate(); + + int clientWidth = Math.Max(0, _listView.ClientSize.Width); + if (clientWidth <= 0) return; + + if (_fillMode == ColumnFillMode.StretchLastColumn) + StretchLastColumn(clientWidth); + else + ProportionalFill(clientWidth); + } + finally + { + _listView.EndUpdate(); + _inAutoFit = false; + } + } + + private void StretchLastColumn(int clientWidth) + { + int count = _listView.Columns.Count; + if (count == 1) + { + _listView.Columns[0].Width = clientWidth; + return; + } + + int otherTotal = 0; + for (int i = 0; i < count - 1; i++) + otherTotal += _listView.Columns[i].Width; + + _listView.Columns[count - 1].Width = Math.Max(60, clientWidth - otherTotal); + } + + private void ProportionalFill(int clientWidth) + { + int count = _listView.Columns.Count; + if (_columnRatios.Length != count) + CaptureColumnRatios(); + + int used = 0; + for (int i = 0; i < count - 1; i++) + { + int w = Math.Max(30, (int)Math.Round(clientWidth * _columnRatios[i])); + _listView.Columns[i].Width = w; + used += w; + } + + _listView.Columns[count - 1].Width = Math.Max(60, clientWidth - used); + } + + private int GetHeaderHeight() + { + if (_listView.Items.Count > 0 && _listView.Items[0].Bounds.Top > 0) + return _listView.Items[0].Bounds.Top; + + return CUSTOM_HEADER_HEIGHT; + } + + private bool IsPointInHeader(Point pt) => pt.Y >= 0 && pt.Y < GetHeaderHeight(); + + private SolidBrush GetRowBrush(int itemIndex, bool isSelected, bool isHovered) + { + if (isSelected) + return _listView.Focused ? RowSelectedActiveBrush : RowSelectedInactiveBrush; + if (isHovered) + return RowHoverBrush; + return (itemIndex % 2 == 1) ? RowAltBrush : RowNormalBrush; + } + + private Color GetRowColor(int itemIndex, bool isSelected, bool isHovered) + { + if (isSelected) return _listView.Focused ? RowSelectedActive : RowSelectedInactive; + if (isHovered) return RowHover; + return (itemIndex % 2 == 1) ? RowAlt : RowNormal; + } + + private int GetEffectiveOvershootPx() + { + int o = Math.Max(0, MarqueeOvershootPx); + if (MarqueeFadeEdges) + o = Math.Max(o, Math.Max(0, MarqueeFadeWidthPx)); + return o; + } + + private void PaintHeaderRightGap(IntPtr headerHandle) + { + if (headerHandle == IntPtr.Zero || !_listView.IsHandleCreated) return; + + RECT rc; + if (!GetClientRect(headerHandle, out rc)) return; + + int width = rc.right - rc.left; + int height = rc.bottom - rc.top; + if (width <= 0 || height <= 0) return; + + int total = 0; + foreach (ColumnHeader col in _listView.Columns) + total += col.Width; + + if (total >= width) return; + + using (var g = Graphics.FromHwnd(headerHandle)) + { + g.FillRectangle(HeaderBgBrush, new Rectangle(total, 0, width - total + 1, height)); + g.DrawLine(HeaderBorderPen, total, height - 1, width, height - 1); + } + } + + private void DrawColumnHeader(object sender, DrawListViewColumnHeaderEventArgs e) + { + var g = e.Graphics; + int listViewWidth = _listView.ClientSize.Width; + + g.FillRectangle(HeaderBgBrush, e.Bounds); + + if (e.ColumnIndex == _listView.Columns.Count - 1) + { + int rightEdge = e.Bounds.Right; + if (rightEdge < listViewWidth) + { + var extended = new Rectangle(rightEdge, e.Bounds.Top, listViewWidth - rightEdge + 1, e.Bounds.Height); + g.FillRectangle(HeaderBgBrush, extended); + g.DrawLine(HeaderBorderPen, rightEdge, e.Bounds.Bottom - 1, listViewWidth, e.Bounds.Bottom - 1); + } + } + + g.DrawLine(HeaderBorderPen, e.Bounds.Left, e.Bounds.Bottom - 1, e.Bounds.Right, e.Bounds.Bottom - 1); + + if (e.ColumnIndex < _listView.Columns.Count - 1) + { + int separatorX = e.Bounds.Right - 1; + g.DrawLine(HeaderSeparatorPen, separatorX, e.Bounds.Top + 4, separatorX, e.Bounds.Bottom - 4); + } + + var textBounds = new Rectangle(e.Bounds.X + CellPaddingX, e.Bounds.Y, e.Bounds.Width - CellPaddingTotalX, e.Bounds.Height); + + var flags = TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis | TextFormatFlags.SingleLine; + if (e.Header.TextAlign == HorizontalAlignment.Center) + flags |= TextFormatFlags.HorizontalCenter; + else if (e.Header.TextAlign == HorizontalAlignment.Right) + flags |= TextFormatFlags.Right; + else + flags |= TextFormatFlags.Left; + + bool hasActiveSort = _columnSorter != null && _columnSorter.Order != SortOrder.None; + bool isActiveSortedColumn = hasActiveSort && _columnSorter.SortColumn == e.ColumnIndex; + + bool showDefaultSort = !hasActiveSort && DefaultSortOrder != SortOrder.None && e.ColumnIndex == DefaultSortColumn; + bool tint = isActiveSortedColumn || showDefaultSort; + + Color headerTextColor = tint ? HeaderTextSorted : HeaderText; + TextRenderer.DrawText(g, e.Header.Text, HeaderFont, textBounds, headerTextColor, flags); + + SortOrder drawOrder = SortOrder.None; + if (isActiveSortedColumn) drawOrder = _columnSorter.Order; + else if (showDefaultSort) drawOrder = DefaultSortOrder; + + if (e.ColumnIndex == 6 && drawOrder != SortOrder.None) + drawOrder = (drawOrder == SortOrder.Ascending) ? SortOrder.Descending : SortOrder.Ascending; + + if (drawOrder != SortOrder.None) + DrawSortArrow(g, e.Bounds, drawOrder); + + e.DrawDefault = false; + } + + private static void DrawSortArrow(Graphics g, Rectangle bounds, SortOrder order) + { + int centerX = bounds.Right - 12; + int centerY = bounds.Top + (bounds.Height / 2) - 1; + + int halfWidth = 4; + int halfHeight = 3; + + Point[] arrow = (order == SortOrder.Ascending) + ? new[] + { + new Point(centerX, centerY - halfHeight), + new Point(centerX - halfWidth, centerY + halfHeight), + new Point(centerX + halfWidth, centerY + halfHeight), + } + : new[] + { + new Point(centerX, centerY + halfHeight), + new Point(centerX - halfWidth, centerY - halfHeight), + new Point(centerX + halfWidth, centerY - halfHeight), + }; + + g.SmoothingMode = SmoothingMode.AntiAlias; + g.FillPolygon(SortArrowBrush, arrow); + g.SmoothingMode = SmoothingMode.None; + } + + private void DrawItem(object sender, DrawListViewItemEventArgs e) + { + e.DrawDefault = false; + } + + private void DrawSubItem(object sender, DrawListViewSubItemEventArgs e) + { + var g = e.Graphics; + + bool isSelected = e.Item.Selected; + bool isHovered = (e.ItemIndex == _hoveredItemIndex) && !isSelected; + + if (e.ColumnIndex == 0) + DrawRowBackground(g, e.Item, e.ItemIndex, isSelected, isHovered); + + string text = e.SubItem?.Text ?? ""; + var font = isSelected ? ItemFontBold : ItemFont; + + var textBounds = new Rectangle(e.Bounds.X + CellPaddingX, e.Bounds.Y, e.Bounds.Width - CellPaddingTotalX, e.Bounds.Height); + Color textColor = GetTextColor(e.Item.ForeColor, isSelected, isHovered); + + if (ShouldDrawMarquee(e.ItemIndex, e.ColumnIndex, isSelected, textBounds, text)) + { + DrawMarqueeText(g, textBounds, e.Bounds, text, font, textColor, _marqueeOffsets[e.ColumnIndex]); + + if (MarqueeFadeEdges) + DrawFadeEdgesOverlay(g, textBounds, GetRowColor(e.ItemIndex, isSelected, isHovered), MarqueeFadeWidthPx); + + e.DrawDefault = false; + return; + } + + var flags = TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis | TextFormatFlags.SingleLine; + flags |= (e.ColumnIndex > 2) ? TextFormatFlags.HorizontalCenter : TextFormatFlags.Left; + TextRenderer.DrawText(g, text, font, textBounds, textColor, flags); + + e.DrawDefault = false; + } + + private void DrawRowBackground(Graphics g, ListViewItem item, int itemIndex, bool isSelected, bool isHovered) + { + int listViewWidth = _listView.ClientSize.Width; + var bgBrush = GetRowBrush(itemIndex, isSelected, isHovered); + var fullRowRect = new Rectangle(0, item.Bounds.Top, listViewWidth, item.Bounds.Height); + g.FillRectangle(bgBrush, fullRowRect); + + if (isSelected) + { + Pen pen = _listView.Focused ? SelectedActiveBorderPen : SelectedInactiveBorderPen; + g.DrawLine(pen, 1, item.Bounds.Top + 1, 1, item.Bounds.Bottom - 1); + } + } + + private static Color GetTextColor(Color baseColor, bool isSelected, bool isHovered) + { + Color c = baseColor; + if (c == SystemColors.WindowText || c == Color.Empty || c == Color.Black) + c = Color.White; + + if (isSelected || isHovered) + { + int boost = isSelected ? 40 : 20; + c = Color.FromArgb( + Math.Min(255, c.R + boost), + Math.Min(255, c.G + boost), + Math.Min(255, c.B + boost)); + } + + return c; + } + + private bool ShouldDrawMarquee(int itemIndex, int columnIndex, bool isSelected, Rectangle textBounds, string text) + { + if (!MarqueeEnabled) return false; + if (!isSelected) return false; + + if (MarqueeOnlyWhenFocused && !_listView.Focused) + return false; + + if (itemIndex != _marqueeSelectedIndex) return false; + if (string.IsNullOrEmpty(text)) return false; + if (textBounds.Width <= 8) return false; + + if (columnIndex < 0 || columnIndex >= _marqueeMax.Length) return false; + return _marqueeMax[columnIndex] > 1f; + } + + private static void DrawMarqueeText(Graphics g, Rectangle clipBounds, Rectangle itemBounds, string text, Font font, Color textColor, float offset) + { + float x = clipBounds.X - offset; + float y = itemBounds.Y + (itemBounds.Height - font.Height) / 2f; + + var state = g.Save(); + try + { + g.SetClip(clipBounds); + g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit; + + using (var b = new SolidBrush(textColor)) + using (var sf = StringFormat.GenericTypographic) + { + sf.FormatFlags |= StringFormatFlags.NoWrap; + sf.Trimming = StringTrimming.None; + g.DrawString(text, font, b, new PointF(x, y), sf); + } + } + finally + { + g.Restore(state); + } + } + + private static void DrawFadeEdgesOverlay(Graphics g, Rectangle bounds, Color bg, int fadeWidthPx) + { + int w = Math.Max(0, Math.Min(fadeWidthPx, bounds.Width / 2)); + if (w <= 0) return; + + const int overlap = 2; + + var leftRect = new Rectangle(bounds.Left - overlap, bounds.Top, w + overlap, bounds.Height); + using (var lb = new LinearGradientBrush(leftRect, Color.FromArgb(255, bg), Color.FromArgb(0, bg), LinearGradientMode.Horizontal)) + g.FillRectangle(lb, leftRect); + + var rightRect = new Rectangle(bounds.Right - w, bounds.Top, w + overlap, bounds.Height); + using (var rb = new LinearGradientBrush(rightRect, Color.FromArgb(0, bg), Color.FromArgb(255, bg), LinearGradientMode.Horizontal)) + g.FillRectangle(rb, rightRect); + } + + private void OnMouseMove(object sender, MouseEventArgs e) + { + int newHoveredIndex = -1; + + if (!IsPointInHeader(e.Location)) + { + var hitTest = _listView.HitTest(e.Location); + newHoveredIndex = hitTest.Item?.Index ?? -1; + } + + if (newHoveredIndex == _hoveredItemIndex) + return; + + int oldIndex = _hoveredItemIndex; + _hoveredItemIndex = newHoveredIndex; + + if (oldIndex >= 0 && oldIndex < _listView.Items.Count) + _listView.RedrawItems(oldIndex, oldIndex, true); + + if (newHoveredIndex >= 0 && newHoveredIndex < _listView.Items.Count) + _listView.RedrawItems(newHoveredIndex, newHoveredIndex, true); + } + + private void OnMouseLeave(object sender, EventArgs e) + { + if (_hoveredItemIndex < 0 || _hoveredItemIndex >= _listView.Items.Count) + return; + + int oldIndex = _hoveredItemIndex; + _hoveredItemIndex = -1; + _listView.RedrawItems(oldIndex, oldIndex, true); + } + + private void OnPaint(object sender, PaintEventArgs e) + { + var g = e.Graphics; + int listViewWidth = _listView.ClientSize.Width; + + int totalColumnWidth = 0; + foreach (ColumnHeader col in _listView.Columns) + totalColumnWidth += col.Width; + + int headerHeight = GetHeaderHeight(); + + if (totalColumnWidth < listViewWidth) + { + var emptyHeaderRect = new Rectangle(totalColumnWidth, 0, listViewWidth - totalColumnWidth + 1, headerHeight); + g.FillRectangle(HeaderBgBrush, emptyHeaderRect); + g.DrawLine(HeaderBorderPen, totalColumnWidth, headerHeight - 1, listViewWidth, headerHeight - 1); + } + + FillEmptyBottomArea(g, headerHeight, listViewWidth); + } + + private void FillEmptyBottomArea(Graphics g, int headerHeight, int listViewWidth) + { + if (_listView.Items.Count == 0) + { + var emptyRect = new Rectangle(0, headerHeight, listViewWidth, _listView.ClientSize.Height - headerHeight); + g.FillRectangle(RowNormalBrush, emptyRect); + return; + } + + int firstVisibleIndex = _listView.TopItem != null ? _listView.TopItem.Index : 0; + int itemHeight = _listView.Items[0].Bounds.Height; + int visibleCount = (_listView.ClientSize.Height - headerHeight) / Math.Max(1, itemHeight) + 2; + int lastVisibleIndex = Math.Min(firstVisibleIndex + visibleCount, _listView.Items.Count - 1); + + var lastItem = _listView.Items[lastVisibleIndex]; + int bottomY = lastItem.Bounds.Bottom; + + if (bottomY < _listView.ClientSize.Height) + { + var emptyBottomRect = new Rectangle(0, bottomY, listViewWidth, _listView.ClientSize.Height - bottomY); + g.FillRectangle(RowNormalBrush, emptyBottomRect); + } + } + + private void PaintEmptyAreaOverlay() + { + int w = _listView.ClientSize.Width; + int h = _listView.ClientSize.Height; + if (w <= 0 || h <= 0) return; + + int headerHeight = GetHeaderHeight(); + int fillTop = headerHeight; + + if (_listView.Items.Count > 0) + { + int firstVisibleIndex = _listView.TopItem != null ? _listView.TopItem.Index : 0; + int itemHeight = _listView.Items[0].Bounds.Height; + int visibleCount = (h - headerHeight) / Math.Max(1, itemHeight) + 2; + int lastVisibleIndex = Math.Min(firstVisibleIndex + visibleCount, _listView.Items.Count - 1); + + fillTop = Math.Max(fillTop, _listView.Items[lastVisibleIndex].Bounds.Bottom); + } + + if (fillTop < h) + { + int overlayTop = (fillTop == headerHeight) ? Math.Max(0, fillTop - 10) : fillTop; + using (var g = Graphics.FromHwnd(_listView.Handle)) + { + g.FillRectangle(RowNormalBrush, new Rectangle(0, overlayTop, w, h - overlayTop)); + } + } + } + + private ListViewItem GetActiveSelectedItem() + { + var focused = _listView.FocusedItem; + if (focused != null && focused.Selected) + return focused; + + if (_listView.SelectedItems.Count > 0) + return _listView.SelectedItems[0]; + + return null; + } + + private void EnsureMarqueeArrays() + { + int count = _listView.Columns.Count; + if (count < 0) count = 0; + + if (_marqueeOffsets.Length == count && + _marqueeMax.Length == count && + _marqueeProgress.Length == count && + _marqueeDirs.Length == count && + _marqueeHoldMs.Length == count) + return; + + _marqueeOffsets = new float[count]; + _marqueeMax = new float[count]; + _marqueeProgress = new float[count]; + _marqueeDirs = new int[count]; + _marqueeHoldMs = new int[count]; + + for (int i = 0; i < count; i++) + _marqueeDirs[i] = 1; + } + + private void RecalcMarqueeForSelection() + { + if (!_listView.IsHandleCreated) return; + + var item = GetActiveSelectedItem(); + int idx = item != null ? item.Index : -1; + + if (idx != _marqueeSelectedIndex) + { + _marqueeSelectedIndex = idx; + ResetMarqueeState(); + return; + } + + ResetMarqueeExtentsOnly(); + } + + private void ResetMarqueeState() + { + EnsureMarqueeArrays(); + + Array.Clear(_marqueeOffsets, 0, _marqueeOffsets.Length); + Array.Clear(_marqueeMax, 0, _marqueeMax.Length); + Array.Clear(_marqueeProgress, 0, _marqueeProgress.Length); + Array.Clear(_marqueeHoldMs, 0, _marqueeHoldMs.Length); + + for (int i = 0; i < _marqueeDirs.Length; i++) + _marqueeDirs[i] = 1; + + _marqueeLastTickMs = _marqueeSw.ElapsedMilliseconds; + _marqueeStartAtMs = _marqueeLastTickMs + Math.Max(0, MarqueeStartDelayMs); + + ComputeMarqueeMaxForSelectedRow(); + UpdateMarqueeTimerState(); + + if (_marqueeSelectedIndex >= 0 && _marqueeSelectedIndex < _listView.Items.Count) + _listView.RedrawItems(_marqueeSelectedIndex, _marqueeSelectedIndex, true); + else + _listView.Invalidate(); + } + + private void ResetMarqueeExtentsOnly() + { + EnsureMarqueeArrays(); + + float[] oldMax = (float[])_marqueeMax.Clone(); + ComputeMarqueeMaxForSelectedRow(); + + int overshoot = GetEffectiveOvershootPx(); + + for (int i = 0; i < _marqueeOffsets.Length; i++) + { + if (_marqueeMax[i] <= 0f) + { + _marqueeOffsets[i] = 0f; + _marqueeProgress[i] = 0f; + _marqueeHoldMs[i] = 0; + _marqueeDirs[i] = 1; + continue; + } + + float overflow = _marqueeMax[i]; + float travel = overflow + (2f * overshoot); + float p = Clamp01(_marqueeProgress[i]); + _marqueeProgress[i] = p; + _marqueeOffsets[i] = (Ease(p) * travel) - overshoot; + } + + UpdateMarqueeTimerState(); + + bool changed = false; + for (int i = 0; i < oldMax.Length && i < _marqueeMax.Length; i++) + { + if (Math.Abs(oldMax[i] - _marqueeMax[i]) > 0.5f) { changed = true; break; } + } + + if (changed && _marqueeSelectedIndex >= 0 && _marqueeSelectedIndex < _listView.Items.Count) + _listView.RedrawItems(_marqueeSelectedIndex, _marqueeSelectedIndex, true); + } + + private void ComputeMarqueeMaxForSelectedRow() + { + if (_marqueeSelectedIndex < 0 || _marqueeSelectedIndex >= _listView.Items.Count) + { + for (int i = 0; i < _marqueeMax.Length; i++) _marqueeMax[i] = 0f; + return; + } + + var item = _listView.Items[_marqueeSelectedIndex]; + int colCount = _listView.Columns.Count; + int minOverflow = Math.Max(0, MinOverflowForMarqueePx); + + for (int col = 0; col < colCount; col++) + { + string text = (col == 0) + ? (item.Text ?? "") + : (col < item.SubItems.Count ? (item.SubItems[col]?.Text ?? "") : ""); + + if (string.IsNullOrEmpty(text)) + { + _marqueeMax[col] = 0f; + continue; + } + + int available = Math.Max(0, _listView.Columns[col].Width - CellPaddingTotalX); + if (available <= 6) + { + _marqueeMax[col] = 0f; + continue; + } + + Size measured = TextRenderer.MeasureText( + text, + ItemFontBold, + new Size(int.MaxValue, ItemFontBold.Height), + TextFormatFlags.SingleLine | TextFormatFlags.NoPadding); + + float overflow = measured.Width - available; + _marqueeMax[col] = (overflow > minOverflow) ? overflow : 0f; + } + } + + private void UpdateMarqueeTimerState() + { + if (!MarqueeEnabled) + { + if (_marqueeTimer.Enabled) _marqueeTimer.Stop(); + return; + } + + if (MarqueeOnlyWhenFocused && !_listView.Focused) + { + if (_marqueeTimer.Enabled) _marqueeTimer.Stop(); + return; + } + + bool any = false; + for (int i = 0; i < _marqueeMax.Length; i++) + { + if (_marqueeMax[i] > 1f) { any = true; break; } + } + + if (any && _marqueeSelectedIndex >= 0) + { + if (!_marqueeTimer.Enabled) + { + _marqueeLastTickMs = _marqueeSw.ElapsedMilliseconds; + _marqueeTimer.Start(); + } + } + else + { + if (_marqueeTimer.Enabled) + _marqueeTimer.Stop(); + } + } + + private void UpdateMarquee() + { + if (!MarqueeEnabled) { _marqueeTimer.Stop(); return; } + if (MarqueeOnlyWhenFocused && !_listView.Focused) { _marqueeTimer.Stop(); return; } + + var active = GetActiveSelectedItem(); + int idx = active != null ? active.Index : -1; + if (idx != _marqueeSelectedIndex) + { + _marqueeSelectedIndex = idx; + ResetMarqueeState(); + return; + } + + if (_marqueeSelectedIndex < 0 || _marqueeSelectedIndex >= _listView.Items.Count) + { + _marqueeTimer.Stop(); + return; + } + + long nowMs = _marqueeSw.ElapsedMilliseconds; + float dt = (nowMs - _marqueeLastTickMs) / 1000f; + _marqueeLastTickMs = nowMs; + + if (dt <= 0) return; + if (dt > 0.08f) dt = 0.08f; + + if (nowMs < _marqueeStartAtMs) return; + + bool changed = false; + int pauseMs = Math.Max(0, MarqueePauseMs); + float speed = Math.Max(1f, MarqueeSpeedPxPerSecond); + + int overshoot = GetEffectiveOvershootPx(); + + for (int col = 0; col < _marqueeOffsets.Length; col++) + { + float overflow = _marqueeMax[col]; + if (overflow <= 1f) + { + if (_marqueeOffsets[col] != 0f || _marqueeProgress[col] != 0f) + { + _marqueeOffsets[col] = 0f; + _marqueeProgress[col] = 0f; + _marqueeHoldMs[col] = 0; + _marqueeDirs[col] = 1; + changed = true; + } + continue; + } + + if (_marqueeHoldMs[col] > 0) + { + _marqueeHoldMs[col] -= (int)Math.Round(dt * 1000); + if (_marqueeHoldMs[col] < 0) _marqueeHoldMs[col] = 0; + continue; + } + + int dir = _marqueeDirs[col]; + if (dir == 0) dir = 1; + + float travel = overflow + (2f * overshoot); + + float pSpeed = speed / (1.5f * travel); + pSpeed = Math.Max(pSpeed, MarqueeMinProgressPerSecond); + + float p = _marqueeProgress[col] + dir * dt * pSpeed; + + if (p <= 0f) + { + p = 0f; + _marqueeDirs[col] = 1; + _marqueeHoldMs[col] = pauseMs; + } + else if (p >= 1f) + { + p = 1f; + _marqueeDirs[col] = -1; + _marqueeHoldMs[col] = pauseMs; + } + + float newOffset = (Ease(p) * travel) - overshoot; + + if (Math.Abs(newOffset - _marqueeOffsets[col]) > 0.02f || Math.Abs(p - _marqueeProgress[col]) > 0.0005f) + { + _marqueeProgress[col] = p; + _marqueeOffsets[col] = newOffset; + changed = true; + } + } + + if (changed) + _listView.RedrawItems(_marqueeSelectedIndex, _marqueeSelectedIndex, true); + } + + private static float Ease(float p) + { + p = Clamp01(p); + float t = 0.5f - 0.5f * (float)Math.Cos(Math.PI * p); + const float gamma = 0.65f; + return (float)Math.Pow(t, gamma); + } + + private static float Clamp01(float v) + { + if (v < 0f) return 0f; + if (v > 1f) return 1f; + return v; + } + } +} \ No newline at end of file