Files
rookie/GalleryView.cs
jp64k af84f2cf8c Improvd base game name derivation for grouped tiles
Refactored the logic to strip parentheses from all version names before selecting the shortest, instead of selecting the shortest name first and then stripping
2026-01-30 04:40:59 +01:00

1773 lines
74 KiB
C#

using AndroidSideloader;
using AndroidSideloader.Utilities;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Windows.Forms;
public enum SortField { Name, LastUpdated, Size, Popularity }
public enum SortDirection { Ascending, Descending }
public class FastGalleryPanel : Control
{
// Data
private List<ListViewItem> _items;
private List<ListViewItem> _originalItems; // Keep original for re-sorting
private readonly int _tileWidth;
private readonly int _tileHeight;
private readonly int _spacing;
// Grouping
private Dictionary<string, List<ListViewItem>> _groupedByPackage;
private List<GroupedTile> _displayTiles;
private int _expandedTileIndex = -1;
private float _expandOverlayOpacity = 0f;
private float _targetExpandOverlayOpacity = 0f;
private int _overlayHoveredVersion = -1;
private Rectangle _overlayRect;
private List<Rectangle> _versionRects;
private int _overlayScrollOffset = 0;
private int _overlayMaxScroll = 0;
private class GroupedTile
{
public string PackageName;
public string GameName;
public string BaseGameName; // Common name across all versions
public List<ListViewItem> Versions;
public ListViewItem Primary => Versions[0];
}
// Sorting
private SortField _currentSortField = SortField.Name;
private SortDirection _currentSortDirection = SortDirection.Ascending;
public SortField CurrentSortField => _currentSortField;
public SortDirection CurrentSortDirection => _currentSortDirection;
private readonly Panel _sortPanel;
private readonly List<Button> _sortButtons;
private Label _sortStatusLabel;
private const int SORT_PANEL_HEIGHT = 36;
// Layout
private int _columns;
private int _rows;
private int _contentHeight;
private int _leftPadding;
// Smooth scrolling
private float _scrollY;
private float _targetScrollY;
private bool _isScrolling;
private readonly VScrollBar _scrollBar;
// Animation
private readonly System.Windows.Forms.Timer _animationTimer;
private readonly Dictionary<int, TileAnimationState> _tileStates;
// Image cache (LRU)
private readonly Dictionary<string, Image> _imageCache;
private readonly Queue<string> _cacheOrder;
private const int MAX_CACHE_SIZE = 200;
// Interaction
private int _hoveredIndex = -1;
public int _selectedIndex = -1;
private ListViewItem _selectedItem = null;
private bool _isHoveringDeleteButton = false;
// Context Menu & Favorites
private ContextMenuStrip _contextMenu;
private int _rightClickedIndex = -1;
private int _rightClickedVersionIndex = -1;
private HashSet<string> _favoritesCache;
// Rendering
private Bitmap _backBuffer;
// Visual constants
private const int CORNER_RADIUS = 10;
private const int THUMB_CORNER_RADIUS = 8;
private const float HOVER_SCALE = 1.08f;
private const float ANIMATION_SPEED = 0.33f;
private const float SCROLL_SMOOTHING = 0.3f;
private const int DELETE_BUTTON_SIZE = 26;
private const int DELETE_BUTTON_MARGIN = 6;
private const int VERSION_ROW_HEIGHT = 44;
private const int OVERLAY_PADDING = 10;
private const int OVERLAY_MAX_HEIGHT = 320;
// Theme colors
private static readonly Color TileBorderHover = Color.FromArgb(93, 203, 173);
private static readonly Color TileBorderSelected = Color.FromArgb(200, 200, 200);
private static readonly Color TileBorderFavorite = Color.FromArgb(255, 215, 0);
private static readonly Color BadgeFavoriteBg = Color.FromArgb(200, 255, 180, 0);
private static readonly Color TextColor = Color.FromArgb(245, 255, 255, 255);
private static readonly Color BadgeInstalledBg = Color.FromArgb(180, 60, 145, 230);
private static readonly Color DeleteButtonBg = Color.FromArgb(200, 180, 50, 50);
private static readonly Color DeleteButtonHoverBg = Color.FromArgb(255, 220, 70, 70);
private static readonly Color SortButtonBg = Color.FromArgb(40, 42, 48);
private static readonly Color SortButtonActiveBg = Color.FromArgb(93, 203, 173);
private static readonly Color SortButtonHoverBg = Color.FromArgb(55, 58, 65);
private static readonly Color OverlayBgColor = Color.FromArgb(250, 28, 30, 36);
private static readonly Color VersionRowHoverBg = Color.FromArgb(255, 45, 48, 56);
private static readonly Color VersionBadgeColor = Color.FromArgb(200, 93, 203, 173);
public event EventHandler<int> TileClicked;
public event EventHandler<int> TileDoubleClicked;
public event EventHandler<int> TileDeleteClicked;
public event EventHandler<int> TileRightClicked;
public event EventHandler<string> TileHovered; // Update release notes for hovered grouped sub-item
public event EventHandler<SortField> 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;
public float TargetScale = 1.0f;
public float BorderOpacity = 0f;
public float TargetBorderOpacity = 0f;
public float BackgroundBrightness = 30f;
public float TargetBackgroundBrightness = 30f;
public float SelectionOpacity = 0f;
public float TargetSelectionOpacity = 0f;
public float TooltipOpacity = 0f;
public float TargetTooltipOpacity = 0f;
public float DeleteButtonOpacity = 0f;
public float TargetDeleteButtonOpacity = 0f;
public float FavoriteOpacity = 0f;
public float TargetFavoriteOpacity = 0f;
public float GroupBadgeOpacity = 0f;
public float TargetGroupBadgeOpacity = 0f;
}
public FastGalleryPanel(List<ListViewItem> items, int tileWidth, int tileHeight, int spacing, int initialWidth, int initialHeight)
{
_originalItems = items ?? new List<ListViewItem>();
_items = new List<ListViewItem>(_originalItems);
_displayTiles = new List<GroupedTile>();
_groupedByPackage = new Dictionary<string, List<ListViewItem>>(StringComparer.OrdinalIgnoreCase);
_versionRects = new List<Rectangle>();
_tileWidth = tileWidth;
_tileHeight = tileHeight;
_spacing = spacing;
_imageCache = new Dictionary<string, Image>(StringComparer.OrdinalIgnoreCase);
_cacheOrder = new Queue<string>();
_tileStates = new Dictionary<int, TileAnimationState>();
_sortButtons = new List<Button>();
RefreshFavoritesCache();
// Avoid any implicit padding from the control container
Padding = Padding.Empty;
Margin = Padding.Empty;
Size = new Size(initialWidth, initialHeight);
SetStyle(ControlStyles.AllPaintingInWmPaint |
ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer |
ControlStyles.Selectable |
ControlStyles.ResizeRedraw, true);
BackColor = Color.FromArgb(24, 26, 30);
// Create context menu
CreateContextMenu();
// Create sort panel
_sortPanel = CreateSortPanel();
Controls.Add(_sortPanel);
// Scrollbar - direct interaction jumps immediately (no smooth scroll)
_scrollBar = new VScrollBar { Minimum = 0, SmallChange = _tileHeight / 2, LargeChange = _tileHeight * 2 };
_scrollBar.Scroll += (s, e) =>
{
_scrollY = _scrollBar.Value;
_targetScrollY = _scrollBar.Value;
_isScrolling = false;
Invalidate();
};
_scrollBar.HandleCreated += (s, e) => ApplyModernScrollbars();
Controls.Add(_scrollBar);
// Animation timer (~120fps)
_animationTimer = new System.Windows.Forms.Timer { Interval = 8 };
_animationTimer.Tick += AnimationTimer_Tick;
_animationTimer.Start();
// Apply initial sort
ApplySort();
RecalculateLayout();
}
private string GetBaseGameName(List<ListViewItem> versions)
{
if (versions == null || versions.Count == 0) return "";
// If only one version, use actual name
if (versions.Count == 1) return versions[0].Text;
// Strip parentheses (...) from all names - except (MR-Fix), then use the shortest result
var strippedNames = versions.Select(v =>
{
string name = v.Text;
bool hasMrFix = name.IndexOf("(MR-Fix)", StringComparison.OrdinalIgnoreCase) >= 0;
name = System.Text.RegularExpressions.Regex.Replace(name, @"\s*\([^)]*\)", "").Trim();
return hasMrFix ? name + " (MR-Fix)" : name;
});
return strippedNames.OrderBy(n => n.Length).First();
}
private void BuildGroupedTiles()
{
_groupedByPackage.Clear();
_displayTiles.Clear();
foreach (var item in _items)
{
string packageName = item.SubItems.Count > 2 ? item.SubItems[2].Text : "";
if (string.IsNullOrEmpty(packageName)) packageName = item.Text;
if (!_groupedByPackage.ContainsKey(packageName))
_groupedByPackage[packageName] = new List<ListViewItem>();
_groupedByPackage[packageName].Add(item);
}
foreach (var kvp in _groupedByPackage)
{
kvp.Value.Sort((a, b) =>
{
var dateA = ParseDate(a.SubItems.Count > 4 ? a.SubItems[4].Text : "");
var dateB = ParseDate(b.SubItems.Count > 4 ? b.SubItems[4].Text : "");
return dateB.CompareTo(dateA);
});
_displayTiles.Add(new GroupedTile
{
PackageName = kvp.Key,
GameName = kvp.Value[0].Text,
BaseGameName = GetBaseGameName(kvp.Value),
Versions = kvp.Value
});
}
SortDisplayTiles();
_tileStates.Clear();
for (int i = 0; i < _displayTiles.Count; i++)
_tileStates[i] = new TileAnimationState();
}
private void SortDisplayTiles()
{
switch (_currentSortField)
{
case SortField.Name:
_displayTiles = _currentSortDirection == SortDirection.Ascending
? _displayTiles.OrderBy(t => t.BaseGameName, new GameNameComparer()).ToList()
: _displayTiles.OrderByDescending(t => t.BaseGameName, new GameNameComparer()).ToList();
break;
case SortField.LastUpdated:
_displayTiles = _currentSortDirection == SortDirection.Ascending
? _displayTiles.OrderBy(t => t.Versions.Max(v => ParseDate(v.SubItems.Count > 4 ? v.SubItems[4].Text : ""))).ToList()
: _displayTiles.OrderByDescending(t => t.Versions.Max(v => ParseDate(v.SubItems.Count > 4 ? v.SubItems[4].Text : ""))).ToList();
break;
case SortField.Size:
_displayTiles = _currentSortDirection == SortDirection.Ascending
? _displayTiles.OrderBy(t => t.Versions.Max(v => ParseSize(v.SubItems.Count > 5 ? v.SubItems[5].Text : "0"))).ToList()
: _displayTiles.OrderByDescending(t => t.Versions.Max(v => ParseSize(v.SubItems.Count > 5 ? v.SubItems[5].Text : "0"))).ToList();
break;
case SortField.Popularity:
if (_currentSortDirection == SortDirection.Ascending)
_displayTiles = _displayTiles.OrderByDescending(t => ParsePopularity(t.Primary.SubItems.Count > 6 ? t.Primary.SubItems[6].Text : "-"))
.ThenBy(t => t.BaseGameName, new GameNameComparer()).ToList();
else
_displayTiles = _displayTiles.OrderBy(t => ParsePopularity(t.Primary.SubItems.Count > 6 ? t.Primary.SubItems[6].Text : "-"))
.ThenBy(t => t.BaseGameName, new GameNameComparer()).ToList();
break;
}
}
private Panel CreateSortPanel()
{
var panel = new Panel
{
Height = SORT_PANEL_HEIGHT,
Dock = DockStyle.Top,
BackColor = Color.FromArgb(28, 30, 34),
Padding = new Padding(8, 4, 8, 4)
};
var label = new Label
{
Text = "Sort by:",
ForeColor = Color.FromArgb(180, 180, 180),
Font = new Font("Segoe UI", 9f),
AutoSize = true,
Location = new Point(10, 9)
};
panel.Controls.Add(label);
int buttonX = 70;
SortField[] fields = { SortField.Name, SortField.LastUpdated, SortField.Size, SortField.Popularity };
string[] texts = { "Name", "Updated", "Size", "Popularity" };
for (int i = 0; i < fields.Length; i++)
{
var btn = CreateSortButton(texts[i], fields[i], buttonX);
panel.Controls.Add(btn);
_sortButtons.Add(btn);
buttonX += btn.Width + 6;
}
_sortStatusLabel = new Label
{
Text = GetSortStatusText(),
ForeColor = Color.FromArgb(140, 140, 140),
Font = new Font("Segoe UI", 8.5f, FontStyle.Italic),
AutoSize = true,
Location = new Point(buttonX + 10, 9)
};
panel.Controls.Add(_sortStatusLabel);
UpdateSortButtonStyles();
return panel;
}
private string GetSortStatusText()
{
switch (_currentSortField)
{
case SortField.Name: return _currentSortDirection == SortDirection.Ascending ? "A → Z" : "Z → A";
case SortField.LastUpdated: return _currentSortDirection == SortDirection.Ascending ? "Oldest → Newest" : "Newest → Oldest";
case SortField.Size: return _currentSortDirection == SortDirection.Ascending ? "Smallest → Largest" : "Largest → Smallest";
case SortField.Popularity: return _currentSortDirection == SortDirection.Ascending ? "Least → Most Popular" : "Most → Least Popular";
default: return "";
}
}
private Button CreateSortButton(string text, SortField field, int x)
{
var btn = new Button
{
Text = field == _currentSortField ? GetSortButtonText(text) : text,
Tag = field,
FlatStyle = FlatStyle.Flat,
Font = new Font("Segoe UI", 8.5f),
ForeColor = Color.White,
BackColor = SortButtonBg,
Size = new Size(text == "Popularity" ? 90 : 75, 26),
Location = new Point(x, 5),
Cursor = Cursors.Hand
};
btn.FlatAppearance.BorderSize = 0;
btn.FlatAppearance.MouseOverBackColor = SortButtonHoverBg;
btn.FlatAppearance.MouseDownBackColor = SortButtonActiveBg;
btn.Click += (s, e) => OnSortButtonClick(field);
return btn;
}
private string GetSortButtonText(string baseText)
{
return baseText + (_currentSortDirection == SortDirection.Ascending ? " ▲" : " ▼");
}
private void OnSortButtonClick(SortField field)
{
if (_currentSortField == field)
// Toggle direction
_currentSortDirection = _currentSortDirection == SortDirection.Ascending ? SortDirection.Descending : SortDirection.Ascending;
else
{
_currentSortField = field;
// Popularity, LastUpdated, Size default to descending (most popular/newest/largest first)
// Name defaults to ascending (A-Z)
_currentSortDirection = field == SortField.Name ? SortDirection.Ascending : SortDirection.Descending;
}
UpdateSortButtonStyles();
ApplySort();
SortChanged?.Invoke(this, field);
}
private void UpdateSortButtonStyles()
{
foreach (var btn in _sortButtons)
{
var field = (SortField)btn.Tag;
bool isActive = field == _currentSortField;
string baseText = field == SortField.Name ? "Name" : field == SortField.LastUpdated ? "Updated" : field == SortField.Size ? "Size" : "Popularity";
btn.Text = isActive ? GetSortButtonText(baseText) : baseText;
// Set appropriate hover color based on active state
btn.BackColor = isActive ? SortButtonActiveBg : SortButtonBg;
btn.ForeColor = isActive ? Color.FromArgb(24, 26, 30) : Color.White;
btn.FlatAppearance.MouseOverBackColor = isActive ? Color.FromArgb(110, 215, 190) : SortButtonHoverBg;
btn.FlatAppearance.MouseDownBackColor = isActive ? Color.FromArgb(80, 180, 155) : SortButtonActiveBg;
}
// Update the sort status label
if (_sortStatusLabel != null) _sortStatusLabel.Text = GetSortStatusText();
}
private void ApplySort()
{
// Reset original order
_items = new List<ListViewItem>(_originalItems);
// Reset selection and hover
_hoveredIndex = -1;
_selectedIndex = -1;
_selectedItem = null;
CloseOverlay();
BuildGroupedTiles();
// Reset scroll position
_scrollY = 0;
_targetScrollY = 0;
RecalculateLayout();
Invalidate();
}
private void CloseOverlay()
{
_expandedTileIndex = -1;
_targetExpandOverlayOpacity = 0f;
_overlayHoveredVersion = -1;
_overlayScrollOffset = 0;
_rightClickedVersionIndex = -1;
}
public void SetSortState(SortField field, SortDirection direction)
{
_currentSortField = field;
_currentSortDirection = direction;
UpdateSortButtonStyles();
ApplySort();
}
private int ParsePopularity(string popStr)
{
if (string.IsNullOrEmpty(popStr)) return int.MaxValue; // Unranked goes to end
popStr = popStr.Trim();
if (popStr == "-") return int.MaxValue; // Unranked goes to end
if (popStr.StartsWith("#") && int.TryParse(popStr.Substring(1), out int rank)) return rank;
if (int.TryParse(popStr, out int rawNum)) return rawNum; // Fallback: try parsing as raw number
return int.MaxValue; // Unparseable goes to end
}
// Custom sort to match list sort behaviour: '_' before digits, digits before letters (case-insensitive)
private class GameNameComparer : IComparer<string>
{
public int Compare(string x, string y)
{
if (x == y) return 0;
if (x == null) return -1;
if (y == null) return 1;
int minLen = Math.Min(x.Length, y.Length);
for (int i = 0; i < minLen; i++)
{
int orderX = GetCharOrder(x[i]);
int orderY = GetCharOrder(y[i]);
if (orderX != orderY) return orderX.CompareTo(orderY);
// Same category, compare case-insensitively
int cmp = char.ToLowerInvariant(x[i]).CompareTo(char.ToLowerInvariant(y[i]));
if (cmp != 0) return cmp;
}
return x.Length.CompareTo(y.Length); // Shorter string comes first
}
private static int GetCharOrder(char c)
{
// Order: underscore (0), digits (1), letters (2), everything else (3)
if (c == '_') return 0;
if (char.IsDigit(c)) return 1;
if (char.IsLetter(c)) return 2;
return 3;
}
}
private DateTime ParseDate(string dateStr)
{
if (string.IsNullOrEmpty(dateStr)) return DateTime.MinValue;
string[] formats = { "yyyy-MM-dd HH:mm 'UTC'", "yyyy-MM-dd HH:mm" };
return DateTime.TryParseExact(dateStr, formats, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
out DateTime date) ? date : DateTime.MinValue;
}
private double ParseSize(string sizeStr)
{
if (string.IsNullOrEmpty(sizeStr)) return 0;
sizeStr = sizeStr.Trim(); // Remove whitespace
// Handle new format: "1.23 GB" or "123 MB"
if (sizeStr.EndsWith(" GB", StringComparison.OrdinalIgnoreCase))
{
if (double.TryParse(sizeStr.Substring(0, sizeStr.Length - 3).Trim(), 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))
{
if (double.TryParse(sizeStr.Substring(0, sizeStr.Length - 3).Trim(), 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 raw)) return raw;
return 0;
}
public void UpdateItems(List<ListViewItem> newItems)
{
if (newItems == null) newItems = new List<ListViewItem>();
_originalItems = new List<ListViewItem>(newItems);
_items = new List<ListViewItem>(newItems);
// Reset selection and hover states
_hoveredIndex = -1;
_selectedIndex = -1;
_selectedItem = null;
_isHoveringDeleteButton = false;
CloseOverlay();
// Reset scroll position for new results
_scrollY = 0;
_targetScrollY = 0;
_isScrolling = false;
// Refresh favorites cache and re-apply sort
RefreshFavoritesCache();
ApplySort();
}
public ListViewItem GetItemAtIndex(int index)
{
if (_selectedItem != null) return _selectedItem;
if (index >= 0 && index < _displayTiles.Count)
return _displayTiles[index].Primary;
return null;
}
private bool IsItemInstalled(ListViewItem item)
{
if (item == null) return false;
return item.ForeColor.ToArgb() == MainForm.ColorInstalled.ToArgb() ||
item.ForeColor.ToArgb() == MainForm.ColorUpdateAvailable.ToArgb() ||
item.ForeColor.ToArgb() == MainForm.ColorDonateGame.ToArgb();
}
private bool IsAnyVersionInstalled(GroupedTile tile)
{
return tile.Versions.Any(v => IsItemInstalled(v));
}
private Rectangle GetDeleteButtonRect(int index, int row, int col, int scrollY)
{
if (!_tileStates.TryGetValue(index, out var state)) state = new TileAnimationState();
int baseX = _leftPadding + col * (_tileWidth + _spacing);
int baseY = _spacing + SORT_PANEL_HEIGHT + row * (_tileHeight + _spacing) - scrollY;
float scale = state.Scale;
int scaledW = (int)(_tileWidth * scale);
int scaledH = (int)(_tileHeight * scale);
int x = baseX - (scaledW - _tileWidth) / 2;
int y = baseY - (scaledH - _tileHeight) / 2;
// Position delete button in bottom-right corner of thumbnail
int btnX = x + scaledW - DELETE_BUTTON_SIZE - 2 - DELETE_BUTTON_MARGIN;
int btnY = y + 2 + scaledH - DELETE_BUTTON_SIZE - DELETE_BUTTON_MARGIN - 20;
return new Rectangle(btnX, btnY, DELETE_BUTTON_SIZE, DELETE_BUTTON_SIZE);
}
private void AnimationTimer_Tick(object sender, EventArgs e)
{
bool needsRedraw = false;
// Smooth scrolling
if (_isScrolling)
{
float diff = _targetScrollY - _scrollY;
if (Math.Abs(diff) > 0.5f)
{
_scrollY += diff * SCROLL_SMOOTHING;
_scrollY = Math.Max(0, Math.Min(_scrollY, Math.Max(0, _contentHeight - (Height - SORT_PANEL_HEIGHT))));
if (_scrollBar.Visible && _scrollBar.Value != (int)_scrollY)
_scrollBar.Value = Math.Max(_scrollBar.Minimum, Math.Min(_scrollBar.Maximum - _scrollBar.LargeChange + 1, (int)_scrollY));
needsRedraw = true;
}
else { _scrollY = _targetScrollY; _isScrolling = false; }
}
if (Math.Abs(_expandOverlayOpacity - _targetExpandOverlayOpacity) > 0.01f)
{
_expandOverlayOpacity += (_targetExpandOverlayOpacity - _expandOverlayOpacity) * 0.4f;
needsRedraw = true;
}
else _expandOverlayOpacity = _targetExpandOverlayOpacity;
// Update overlay hover state based on current mouse position
if (_expandedTileIndex >= 0 && _expandOverlayOpacity > 0.5f && _versionRects.Count > 0)
{
var mousePos = PointToClient(Cursor.Position);
int newHover = GetOverlayVersionAtPoint(mousePos.X, mousePos.Y);
if (newHover != _overlayHoveredVersion)
{
_overlayHoveredVersion = newHover;
needsRedraw = true;
// Update release notes when hovering over a version
if (newHover >= 0 && newHover < _displayTiles[_expandedTileIndex].Versions.Count)
{
var hoveredVersion = _displayTiles[_expandedTileIndex].Versions[newHover];
string releaseName = hoveredVersion.SubItems.Count > 1 ? hoveredVersion.SubItems[1].Text : "";
TileHovered?.Invoke(this, releaseName);
}
}
}
// Tile animations - only process visible tiles for performance
int scrollYInt = (int)_scrollY;
int startRow = Math.Max(0, (scrollYInt - _spacing - _tileHeight) / (_tileHeight + _spacing));
int endRow = Math.Min(_rows - 1, (scrollYInt + Height + _tileHeight) / (_tileHeight + _spacing));
for (int row = startRow; row <= endRow; row++)
{
for (int col = 0; col < _columns; col++)
{
int index = row * _columns + col;
if (index >= _displayTiles.Count) break;
if (!_tileStates.TryGetValue(index, out var state))
{
state = new TileAnimationState();
_tileStates[index] = state;
}
var tile = _displayTiles[index];
bool isHovered = index == _hoveredIndex && _expandedTileIndex < 0;
bool isSelected = index == _selectedIndex;
bool isInstalled = IsAnyVersionInstalled(tile);
string pkgName = tile.Primary.SubItems.Count > 1 ? tile.Primary.SubItems[1].Text : "";
bool isFavorite = _favoritesCache.Contains(pkgName);
state.TargetFavoriteOpacity = isFavorite ? 1.0f : 0f;
state.TargetScale = isHovered ? HOVER_SCALE : 1.0f;
state.TargetBorderOpacity = isHovered ? 1.0f : 0f;
state.TargetBackgroundBrightness = isHovered ? 45f : (isSelected ? 38f : 30f);
state.TargetSelectionOpacity = isSelected ? 1.0f : 0f;
state.TargetTooltipOpacity = isHovered ? 1.0f : 0f;
state.TargetDeleteButtonOpacity = (isHovered && isInstalled) ? 1.0f : 0f;
state.TargetGroupBadgeOpacity = tile.Versions.Count > 1 ? 1.0f : 0f;
needsRedraw |= AnimateValue(ref state.Scale, state.TargetScale, ANIMATION_SPEED, 0.001f);
needsRedraw |= AnimateValue(ref state.BorderOpacity, state.TargetBorderOpacity, ANIMATION_SPEED, 0.01f);
needsRedraw |= AnimateValue(ref state.BackgroundBrightness, state.TargetBackgroundBrightness, ANIMATION_SPEED, 0.5f);
needsRedraw |= AnimateValue(ref state.SelectionOpacity, state.TargetSelectionOpacity, ANIMATION_SPEED, 0.01f);
needsRedraw |= AnimateValue(ref state.TooltipOpacity, state.TargetTooltipOpacity, 0.35f, 0.01f);
needsRedraw |= AnimateValue(ref state.DeleteButtonOpacity, state.TargetDeleteButtonOpacity, 0.35f, 0.01f);
needsRedraw |= AnimateValue(ref state.FavoriteOpacity, state.TargetFavoriteOpacity, 0.35f, 0.01f);
needsRedraw |= AnimateValue(ref state.GroupBadgeOpacity, state.TargetGroupBadgeOpacity, 0.35f, 0.01f);
}
}
if (needsRedraw) Invalidate();
}
private bool AnimateValue(ref float current, float target, float speed, float threshold)
{
if (Math.Abs(current - target) > threshold)
{
current += (target - current) * speed;
return true;
}
current = target;
return false;
}
protected override void SetBoundsCore(int x, int y, int width, int height, BoundsSpecified specified)
{
if (height <= 0 && Height > 0) height = Height;
if (width <= 0 && Width > 0) width = Width;
base.SetBoundsCore(x, y, width, height, specified);
}
protected override void OnResize(EventArgs e)
{
base.OnResize(e);
if (Width > 0 && Height > 0 && _scrollBar != null) { RecalculateLayout(); Refresh(); }
}
protected override void OnParentChanged(EventArgs e)
{
base.OnParentChanged(e);
if (Parent != null && !IsDisposed && !Disposing) RecalculateLayout();
}
private void RecalculateLayout()
{
if (IsDisposed || Disposing || _scrollBar == null || Width <= 0 || Height <= 0) return;
int availableHeight = Height - SORT_PANEL_HEIGHT;
_scrollBar.SetBounds(Width - _scrollBar.Width, SORT_PANEL_HEIGHT, _scrollBar.Width, availableHeight);
int availableWidth = Width - _scrollBar.Width - _spacing * 2;
_columns = Math.Max(1, (availableWidth + _spacing) / (_tileWidth + _spacing));
_rows = (int)Math.Ceiling((double)_displayTiles.Count / _columns);
_contentHeight = _rows * (_tileHeight + _spacing) + _spacing + 20;
int usedWidth = _columns * (_tileWidth + _spacing) - _spacing;
_leftPadding = Math.Max(_spacing, (availableWidth - usedWidth) / 2 + _spacing);
_scrollBar.Maximum = Math.Max(0, _contentHeight);
_scrollBar.LargeChange = Math.Max(1, availableHeight);
_scrollBar.Visible = _contentHeight > availableHeight;
_scrollY = Math.Max(0, Math.Min(_scrollY, Math.Max(0, _contentHeight - availableHeight)));
_targetScrollY = _scrollY;
if (_scrollBar.Visible) _scrollBar.Value = (int)_scrollY;
if (_backBuffer == null || _backBuffer.Width != Width || _backBuffer.Height != Height)
{
_backBuffer?.Dispose();
_backBuffer = new Bitmap(Math.Max(1, Width), Math.Max(1, Height));
}
}
protected override void OnPaint(PaintEventArgs e)
{
if (_backBuffer == null) return;
using (var g = Graphics.FromImage(_backBuffer))
{
g.Clear(BackColor);
// Fill sort panel area
g.FillRectangle(new SolidBrush(Color.FromArgb(28, 30, 34)), 0, 0, Width, SORT_PANEL_HEIGHT);
g.SmoothingMode = _isScrolling ? SmoothingMode.HighSpeed : SmoothingMode.AntiAlias;
g.InterpolationMode = _isScrolling ? InterpolationMode.Low : InterpolationMode.Bilinear;
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;
// Clip to tile area (below sort panel)
g.SetClip(new Rectangle(0, SORT_PANEL_HEIGHT, Width, Height - SORT_PANEL_HEIGHT));
int scrollYInt = (int)_scrollY;
int startRow = Math.Max(0, (scrollYInt - _spacing - _tileHeight) / (_tileHeight + _spacing));
int endRow = Math.Min(_rows - 1, (scrollYInt + Height + _tileHeight) / (_tileHeight + _spacing));
// Draw non-hovered, non-selected tiles first
for (int row = startRow; row <= endRow; row++)
{
for (int col = 0; col < _columns; col++)
{
int index = row * _columns + col;
if (index >= _displayTiles.Count) break;
if (index != _hoveredIndex && index != _selectedIndex)
DrawTile(g, index, row, col, scrollYInt);
}
}
// Draw selected tile
if (_selectedIndex >= 0 && _selectedIndex < _displayTiles.Count && _selectedIndex != _hoveredIndex)
{
int selectedRow = _selectedIndex / _columns;
int selectedCol = _selectedIndex % _columns;
if (selectedRow >= startRow && selectedRow <= endRow)
DrawTile(g, _selectedIndex, selectedRow, selectedCol, scrollYInt);
}
// Draw hovered tile last (on top)
if (_hoveredIndex >= 0 && _hoveredIndex < _displayTiles.Count)
{
int hoveredRow = _hoveredIndex / _columns;
int hoveredCol = _hoveredIndex % _columns;
if (hoveredRow >= startRow && hoveredRow <= endRow)
DrawTile(g, _hoveredIndex, hoveredRow, hoveredCol, scrollYInt);
}
if (_expandedTileIndex >= 0 && _expandOverlayOpacity > 0.01f)
DrawGroupOverlay(g, scrollYInt);
g.ResetClip();
}
e.Graphics.DrawImageUnscaled(_backBuffer, 0, 0);
}
private ulong ParseCloudVersionCode(ListViewItem item)
{
if (item == null || item.SubItems.Count <= 3) return 0;
string versionText = item.SubItems[3].Text;
int startIdx = versionText.IndexOf('v');
if (startIdx < 0) return 0;
int endIdx = versionText.IndexOf(' ', startIdx);
string cloudPart = endIdx > 0
? versionText.Substring(startIdx + 1, endIdx - startIdx - 1)
: versionText.Substring(startIdx + 1);
ulong.TryParse(StringUtilities.KeepOnlyNumbers(cloudPart), out ulong result);
return result;
}
private ulong ParseInstalledVersionCode(ListViewItem item)
{
if (item == null || item.SubItems.Count <= 3) return 0;
string versionText = item.SubItems[3].Text;
int dividerIdx = versionText.IndexOf(" / v");
if (dividerIdx < 0) return 0;
string installedPart = versionText.Substring(dividerIdx + 4);
ulong.TryParse(StringUtilities.KeepOnlyNumbers(installedPart), out ulong result);
return result;
}
private void DrawGroupOverlay(Graphics g, int scrollY)
{
if (_expandedTileIndex < 0 || _expandedTileIndex >= _displayTiles.Count) return;
var tile = _displayTiles[_expandedTileIndex];
if (tile.Versions.Count <= 1) return;
// Find installed version code from any version of this package
ulong installedVersionCode = 0;
foreach (var v in tile.Versions)
{
ulong installed = ParseInstalledVersionCode(v);
if (installed > 0) { installedVersionCode = installed; break; }
}
int row = _expandedTileIndex / _columns;
int col = _expandedTileIndex % _columns;
int baseX = _leftPadding + col * (_tileWidth + _spacing);
int baseY = _spacing + SORT_PANEL_HEIGHT + row * (_tileHeight + _spacing) - scrollY;
int tileCenterX = baseX + _tileWidth / 2;
int tileCenterY = baseY + _tileHeight / 2;
int overlayWidth = Math.Max(300, _tileWidth + 60);
int headerHeight = 28;
int contentHeight = tile.Versions.Count * VERSION_ROW_HEIGHT;
int maxContentArea = OVERLAY_MAX_HEIGHT - headerHeight - OVERLAY_PADDING;
bool needsScroll = contentHeight > maxContentArea;
int visibleContentHeight = needsScroll ? maxContentArea : contentHeight;
int overlayHeight = headerHeight + visibleContentHeight + OVERLAY_PADDING;
_overlayMaxScroll = needsScroll ? contentHeight - visibleContentHeight : 0;
_overlayScrollOffset = Math.Max(0, Math.Min(_overlayScrollOffset, _overlayMaxScroll));
int overlayX = tileCenterX - overlayWidth / 2;
int overlayY = tileCenterY - overlayHeight / 2;
overlayX = Math.Max(10, Math.Min(overlayX, Width - overlayWidth - _scrollBar.Width - 10));
overlayY = Math.Max(SORT_PANEL_HEIGHT + 10, Math.Min(overlayY, Height - overlayHeight - 10));
_overlayRect = new Rectangle(overlayX, overlayY, overlayWidth, overlayHeight);
int alpha = (int)(255 * _expandOverlayOpacity);
// Dim background
using (var dimBrush = new SolidBrush(Color.FromArgb((int)(120 * _expandOverlayOpacity), 0, 0, 0)))
g.FillRectangle(dimBrush, 0, SORT_PANEL_HEIGHT, Width, Height - SORT_PANEL_HEIGHT);
// Shadow
using (var shadowPath = CreateRoundedRectangle(new Rectangle(overlayX + 4, overlayY + 4, overlayWidth, overlayHeight), 12))
using (var shadowBrush = new SolidBrush(Color.FromArgb((int)(80 * _expandOverlayOpacity), 0, 0, 0)))
g.FillPath(shadowBrush, shadowPath);
// Main overlay
using (var overlayPath = CreateRoundedRectangle(_overlayRect, 12))
using (var bgBrush = new SolidBrush(Color.FromArgb(alpha, OverlayBgColor.R, OverlayBgColor.G, OverlayBgColor.B)))
using (var borderPen = new Pen(Color.FromArgb(alpha, TileBorderHover), 2f))
{
g.FillPath(bgBrush, overlayPath);
g.DrawPath(borderPen, overlayPath);
}
// Header
using (var headerFont = new Font("Segoe UI", 9f))
using (var headerBrush = new SolidBrush(Color.FromArgb((int)(alpha * 0.7), 200, 200, 200)))
{
g.DrawString($"Select a version ({tile.Versions.Count} available)", headerFont, headerBrush,
overlayX + OVERLAY_PADDING, overlayY + OVERLAY_PADDING);
}
// Clip for scrollable content
var contentRect = new Rectangle(overlayX, overlayY + headerHeight, overlayWidth, visibleContentHeight);
var oldClip = g.Clip;
g.SetClip(contentRect, CombineMode.Intersect);
// Version rows
_versionRects.Clear();
int yOffset = overlayY + headerHeight - _overlayScrollOffset;
using (var nameFont = new Font("Segoe UI Semibold", 9f))
using (var nameFontBold = new Font("Segoe UI", 9f, FontStyle.Bold))
using (var detailFont = new Font("Segoe UI", 8.5f))
using (var detailFontBold = new Font("Segoe UI", 8.5f, FontStyle.Bold))
{
for (int i = 0; i < tile.Versions.Count; i++)
{
var version = tile.Versions[i];
int rowWidth = needsScroll ? overlayWidth - 20 : overlayWidth - 12;
var rowRect = new Rectangle(overlayX + 6, yOffset, rowWidth, VERSION_ROW_HEIGHT - 4);
var clickRect = new Rectangle(rowRect.X, Math.Max(rowRect.Y, contentRect.Y),
rowRect.Width, Math.Min(rowRect.Bottom, contentRect.Bottom) - Math.Max(rowRect.Y, contentRect.Y));
_versionRects.Add(clickRect.Height > 0 ? clickRect : Rectangle.Empty);
bool isHovered = i == _overlayHoveredVersion;
// Determine version status by comparing version codes
ulong thisVersionCode = ParseCloudVersionCode(version);
bool isExactInstalled = installedVersionCode > 0 && thisVersionCode == installedVersionCode;
bool isNewerThanInstalled = installedVersionCode > 0 && thisVersionCode > installedVersionCode;
bool isOlderThanInstalled = installedVersionCode > 0 && thisVersionCode < installedVersionCode;
if (isHovered && rowRect.IntersectsWith(contentRect))
{
using (var hoverPath = CreateRoundedRectangle(rowRect, 6))
using (var hoverBrush = new SolidBrush(Color.FromArgb(alpha, VersionRowHoverBg)))
g.FillPath(hoverBrush, hoverPath);
}
int textX = rowRect.X + 8;
int textY = rowRect.Y + 4;
// Check if this version is a favorite
string versionPkgName = version.SubItems.Count > 1 ? version.SubItems[1].Text : "";
bool isFavorite = !string.IsNullOrEmpty(versionPkgName) && _favoritesCache.Contains(versionPkgName);
// Draw favorite star badge on the left
if (isFavorite)
{
int starSize = 8;
int starX = textX - 5 + starSize / 2;
int starY = rowRect.Y + rowRect.Height / 2;
using (var starPath = CreateStarPath(starX, starY, starSize / 2, starSize / 4, 5))
using (var starBrush = new SolidBrush(Color.FromArgb(alpha, TileBorderFavorite)))
g.FillPath(starBrush, starPath);
textX += 6; // Shift text right to accommodate star
}
// Determine badge width for name rect
int badgeWidth = isExactInstalled ? 68 : (isNewerThanInstalled ? 58 : (isOlderThanInstalled ? 52 : 8));
// Game name - bold font when hovered
using (var nameBrush = new SolidBrush(Color.FromArgb(alpha, 255, 255, 255)))
{
int extraPadding = isFavorite ? 3 : 0;
var nameRect = new Rectangle(textX, textY, rowRect.Width - badgeWidth - 8 - extraPadding, 18);
var sf = new StringFormat { Trimming = StringTrimming.EllipsisCharacter, FormatFlags = StringFormatFlags.NoWrap };
g.DrawString(version.Text, isHovered ? nameFontBold : nameFont, nameBrush, nameRect, sf);
}
// Size, date, version - bold font when hovered
string size = version.SubItems.Count > 5 ? version.SubItems[5].Text : "";
string date = FormatLastUpdated(version.SubItems.Count > 4 ? version.SubItems[4].Text : "");
ulong versionNum = ParseCloudVersionCode(version);
string versionCode = versionNum > 0 ? "v" + versionNum : "";
string details = string.Join(" • ", new[] { size, date, versionCode }.Where(s => !string.IsNullOrEmpty(s)));
Color detailColor = Color.FromArgb(180, 180, 180);
using (var detailBrush = new SolidBrush(Color.FromArgb((int)(alpha * (isHovered ? 1.0 : 0.6)), detailColor)))
g.DrawString(details, isHovered ? detailFontBold : detailFont, detailBrush, textX, textY + 18);
// Status badge
if (isExactInstalled)
{
var badgeRect = new Rectangle(rowRect.Right - 68, rowRect.Y + (rowRect.Height - 18) / 2, 60, 18);
using (var badgePath = CreateRoundedRectangle(badgeRect, 4))
using (var badgeBrush = new SolidBrush(Color.FromArgb(alpha, BadgeInstalledBg)))
{
g.FillPath(badgeBrush, badgePath);
var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
using (var textBrush = new SolidBrush(Color.FromArgb(alpha, 255, 255, 255)))
g.DrawString("INSTALLED", new Font("Segoe UI", 6.5f, FontStyle.Bold), textBrush, badgeRect, sf);
}
}
else if (isNewerThanInstalled)
{
var badgeRect = new Rectangle(rowRect.Right - 58, rowRect.Y + (rowRect.Height - 18) / 2, 50, 18);
using (var badgePath = CreateRoundedRectangle(badgeRect, 4))
using (var badgeBrush = new SolidBrush(Color.FromArgb(alpha, MainForm.ColorUpdateAvailable)))
{
g.FillPath(badgeBrush, badgePath);
var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
using (var textBrush = new SolidBrush(Color.FromArgb(alpha, 255, 255, 255)))
g.DrawString("NEWER", new Font("Segoe UI", 6.5f, FontStyle.Bold), textBrush, badgeRect, sf);
}
}
else if (isOlderThanInstalled)
{
var badgeRect = new Rectangle(rowRect.Right - 52, rowRect.Y + (rowRect.Height - 18) / 2, 44, 18);
using (var badgePath = CreateRoundedRectangle(badgeRect, 4))
using (var badgeBrush = new SolidBrush(Color.FromArgb((int)(alpha * 0.5), 100, 100, 100)))
{
g.FillPath(badgeBrush, badgePath);
var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
using (var textBrush = new SolidBrush(Color.FromArgb((int)(alpha * 0.7), 180, 180, 180)))
g.DrawString("OLDER", new Font("Segoe UI", 6.5f, FontStyle.Bold), textBrush, badgeRect, sf);
}
}
yOffset += VERSION_ROW_HEIGHT;
}
}
g.Clip = oldClip;
// Scroll indicator
if (needsScroll)
{
int scrollBarX = overlayX + overlayWidth - 10;
int scrollTrackY = overlayY + headerHeight + 4;
int scrollTrackHeight = visibleContentHeight - 8;
using (var trackBrush = new SolidBrush(Color.FromArgb((int)(40 * _expandOverlayOpacity), 255, 255, 255)))
g.FillRectangle(trackBrush, scrollBarX, scrollTrackY, 4, scrollTrackHeight);
float scrollRatio = (float)_overlayScrollOffset / _overlayMaxScroll;
float thumbRatio = (float)visibleContentHeight / contentHeight;
int thumbHeight = Math.Max(20, (int)(scrollTrackHeight * thumbRatio));
int thumbY = scrollTrackY + (int)((scrollTrackHeight - thumbHeight) * scrollRatio);
using (var thumbPath = CreateRoundedRectangle(new Rectangle(scrollBarX, thumbY, 4, thumbHeight), 2))
using (var thumbBrush = new SolidBrush(Color.FromArgb((int)(120 * _expandOverlayOpacity), TileBorderHover)))
g.FillPath(thumbBrush, thumbPath);
}
}
private GraphicsPath CreateStarPath(float cx, float cy, float outerRadius, float innerRadius, int points)
{
var path = new GraphicsPath();
var starPoints = new PointF[points * 2];
double angleStep = Math.PI / points;
double startAngle = -Math.PI / 2; // Start from top
for (int i = 0; i < points * 2; i++)
{
double angle = startAngle + i * angleStep;
float radius = (i % 2 == 0) ? outerRadius : innerRadius;
starPoints[i] = new PointF(
cx + (float)(radius * Math.Cos(angle)),
cy + (float)(radius * Math.Sin(angle))
);
}
path.AddPolygon(starPoints);
return path;
}
private GraphicsPath CreateRoundedRectangle(Rectangle rect, int radius)
{
var path = new GraphicsPath();
int d = radius * 2;
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;
}
private void DrawTile(Graphics g, int index, int row, int col, int scrollY)
{
var tile = _displayTiles[index];
var item = tile.Primary;
var state = _tileStates.ContainsKey(index) ? _tileStates[index] : new TileAnimationState();
bool isHovered = index == _hoveredIndex && _expandedTileIndex < 0;
int baseX = _leftPadding + col * (_tileWidth + _spacing);
int baseY = _spacing + SORT_PANEL_HEIGHT + row * (_tileHeight + _spacing) - scrollY;
float scale = state.Scale;
int scaledW = (int)(_tileWidth * scale);
int scaledH = (int)(_tileHeight * scale);
int x = baseX - (scaledW - _tileWidth) / 2;
int y = baseY - (scaledH - _tileHeight) / 2;
var tileRect = new Rectangle(x, y, scaledW, scaledH);
var thumbnail = GetCachedImage(tile.PackageName);
using (var tilePath = CreateRoundedRectangle(tileRect, THUMB_CORNER_RADIUS))
{
var oldClip = g.Clip;
g.SetClip(tilePath, CombineMode.Replace);
if (thumbnail != null)
{
if (isHovered) g.InterpolationMode = InterpolationMode.HighQualityBicubic;
float imgRatio = (float)thumbnail.Width / thumbnail.Height;
float rectRatio = (float)tileRect.Width / tileRect.Height;
Rectangle drawRect = imgRatio > rectRatio
? new Rectangle(x - ((int)(scaledH * imgRatio) - scaledW) / 2, y, (int)(scaledH * imgRatio), scaledH)
: new Rectangle(x, y - ((int)(scaledW / imgRatio) - scaledH) / 2, scaledW, (int)(scaledW / imgRatio));
g.DrawImage(thumbnail, drawRect);
if (isHovered) g.InterpolationMode = InterpolationMode.Bilinear;
}
else
{
using (var brush = new SolidBrush(Color.FromArgb(35, 35, 40)))
g.FillPath(brush, tilePath);
using (var font = new Font("Segoe UI", 10f, FontStyle.Bold))
using (var text = new SolidBrush(Color.FromArgb(110, 110, 120)))
{
var sfName = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center, Trimming = StringTrimming.EllipsisCharacter };
g.DrawString(tile.BaseGameName, font, text, new Rectangle(x + 10, y, scaledW - 20, scaledH), sfName);
}
}
g.Clip = oldClip;
}
// Left-side badges
int badgeY = y + 4;
if (state.FavoriteOpacity > 0.5f) { DrawBadge(g, "★", x + 4, badgeY, BadgeFavoriteBg); badgeY += 18; }
bool hasUpdate = tile.Versions.Any(v => v.ForeColor.ToArgb() == MainForm.ColorUpdateAvailable.ToArgb());
bool installed = IsAnyVersionInstalled(tile);
bool canDonate = tile.Versions.Any(v => v.ForeColor.ToArgb() == MainForm.ColorDonateGame.ToArgb());
if (hasUpdate) { DrawBadge(g, "UPDATE AVAILABLE", x + 4, badgeY, Color.FromArgb(180, MainForm.ColorUpdateAvailable)); badgeY += 18; }
if (canDonate) { DrawBadge(g, "NEWER THAN LIST", x + 4, badgeY, Color.FromArgb(180, MainForm.ColorDonateGame)); badgeY += 18; }
if (installed) DrawBadge(g, "INSTALLED", x + 4, badgeY, BadgeInstalledBg);
// Right-side badges
int rightBadgeY = y + 4;
// Version count badge
if (tile.Versions.Count > 1 && state.GroupBadgeOpacity > 0.01f)
{
string countText = tile.Versions.Count + " VERSIONS";
DrawRightAlignedBadge(g, countText, x + scaledW - 4, rightBadgeY, state.GroupBadgeOpacity);
rightBadgeY += 18;
}
// Size badge
string sizeText = item.SubItems.Count > 5 ? item.SubItems[5].Text : "";
if (!string.IsNullOrEmpty(sizeText))
{
DrawRightAlignedBadge(g, sizeText, x + scaledW - 4, rightBadgeY, 1.0f);
rightBadgeY += 18;
}
// Date badge
if (state.TooltipOpacity > 0.01f && item.SubItems.Count > 4)
{
string formattedDate = FormatLastUpdated(item.SubItems[4].Text);
if (!string.IsNullOrEmpty(formattedDate))
DrawRightAlignedBadge(g, formattedDate, x + scaledW - 4, rightBadgeY, state.TooltipOpacity);
}
// Delete button
if (state.DeleteButtonOpacity > 0.01f)
DrawDeleteButton(g, x, y, scaledW, scaledH, state.DeleteButtonOpacity, _isHoveringDeleteButton && index == _hoveredIndex);
// Game name overlay - use BaseGameName
if (state.TooltipOpacity > 0.01f)
{
int overlayH = 20;
var overlayRect = new Rectangle(x, y + scaledH - overlayH, scaledW, overlayH);
using (var clipPath = CreateRoundedRectangle(tileRect, THUMB_CORNER_RADIUS))
{
Region oldClip = g.Clip;
g.SetClip(clipPath, CombineMode.Intersect);
using (var overlayBrush = new SolidBrush(Color.FromArgb((int)(180 * state.TooltipOpacity), 0, 0, 0)))
g.FillRectangle(overlayBrush, overlayRect.X - 1, overlayRect.Y, overlayRect.Width + 2, overlayRect.Height + 1);
g.Clip = oldClip;
}
using (var font = new Font("Segoe UI", 8f, FontStyle.Bold))
using (var brush = new SolidBrush(Color.FromArgb((int)(TextColor.A * state.TooltipOpacity), TextColor)))
{
var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center, Trimming = StringTrimming.EllipsisCharacter, FormatFlags = StringFormatFlags.NoWrap };
g.DrawString(tile.BaseGameName, font, brush, new Rectangle(overlayRect.X, overlayRect.Y + 1, overlayRect.Width, overlayRect.Height), sf);
}
}
// Tile borders
using (var tilePath = CreateRoundedRectangle(tileRect, CORNER_RADIUS))
{
if (state.SelectionOpacity > 0.01f) // Selected border
using (var selectionPen = new Pen(Color.FromArgb((int)(255 * state.SelectionOpacity), TileBorderSelected), 3f))
g.DrawPath(selectionPen, tilePath);
if (state.BorderOpacity > 0.01f) // Hover border
using (var borderPen = new Pen(Color.FromArgb((int)(200 * state.BorderOpacity), TileBorderHover), 2f))
g.DrawPath(borderPen, tilePath);
if (state.FavoriteOpacity > 0.5f) // Favorite border
using (var favPen = new Pen(Color.FromArgb((int)(180 * state.FavoriteOpacity), TileBorderFavorite), 1f))
g.DrawPath(favPen, tilePath);
}
}
private void DrawDeleteButton(Graphics g, int tileX, int tileY, int tileWidth, int tileHeight, float opacity, bool isHovering)
{
// Position in bottom-right corner of thumbnail
int btnX = tileX + tileWidth - DELETE_BUTTON_SIZE - 2 - DELETE_BUTTON_MARGIN;
int btnY = tileY + 2 + tileHeight - DELETE_BUTTON_SIZE - DELETE_BUTTON_MARGIN - 20;
var btnRect = new Rectangle(btnX, btnY, DELETE_BUTTON_SIZE, DELETE_BUTTON_SIZE);
Color bgColor = isHovering ? DeleteButtonHoverBg : DeleteButtonBg;
using (var path = CreateRoundedRectangle(btnRect, 6))
using (var bgBrush = new SolidBrush(Color.FromArgb((int)(opacity * 255), bgColor)))
g.FillPath(bgBrush, path);
// Draw trash icon
int iconPadding = 5;
int iconX = btnX + iconPadding;
int iconY = btnY + iconPadding;
int iconSize = DELETE_BUTTON_SIZE - iconPadding * 2;
using (var pen = new Pen(Color.FromArgb((int)(opacity * 255), Color.White), 1.5f))
{
// Trash can body
int bodyTop = iconY + 4;
int bodyBottom = iconY + iconSize;
int bodyLeft = iconX + 2;
int bodyRight = iconX + iconSize - 2;
// Draw body outline (trapezoid-ish shape)
g.DrawLine(pen, bodyLeft, bodyTop, bodyLeft + 1, bodyBottom);
g.DrawLine(pen, bodyLeft + 1, bodyBottom, bodyRight - 1, bodyBottom);
g.DrawLine(pen, bodyRight - 1, bodyBottom, bodyRight, bodyTop);
// Draw lid
g.DrawLine(pen, iconX, bodyTop, iconX + iconSize, bodyTop);
// Draw handle on lid
int handleLeft = iconX + iconSize / 2 - 3;
int handleRight = iconX + iconSize / 2 + 3;
int handleTop = iconY + 1;
g.DrawLine(pen, handleLeft, bodyTop, handleLeft, handleTop);
g.DrawLine(pen, handleLeft, handleTop, handleRight, handleTop);
g.DrawLine(pen, handleRight, handleTop, handleRight, bodyTop);
// Draw vertical lines inside trash
int lineY1 = bodyTop + 3;
int lineY2 = bodyBottom - 3;
g.DrawLine(pen, iconX + iconSize / 2, lineY1, iconX + iconSize / 2, lineY2);
if (iconSize > 10)
{
g.DrawLine(pen, iconX + iconSize / 2 - 4, lineY1, iconX + iconSize / 2 - 4, lineY2);
g.DrawLine(pen, iconX + iconSize / 2 + 4, lineY1, iconX + iconSize / 2 + 4, lineY2);
}
}
}
private void DrawRightAlignedBadge(Graphics g, string text, int rightX, int y, float opacity)
{
using (var font = new Font("Segoe UI", 7f, FontStyle.Bold))
{
var sz = g.MeasureString(text, font);
var rect = new Rectangle(rightX - (int)sz.Width - 8, y, (int)sz.Width + 8, 14);
using (var path = CreateRoundedRectangle(rect, 4))
using (var bgBrush = new SolidBrush(Color.FromArgb((int)(180 * opacity), 0, 0, 0)))
{
g.FillPath(bgBrush, path);
var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
using (var textBrush = new SolidBrush(Color.FromArgb((int)(255 * opacity), 255, 255, 255)))
g.DrawString(text, font, textBrush, rect, sf);
}
}
}
private string FormatLastUpdated(string dateStr)
{
if (string.IsNullOrEmpty(dateStr)) return "";
// Extract just the date part before space
if (DateTime.TryParse(dateStr.Split(' ')[0], out DateTime date))
return date.ToString("dd MMM yyyy", System.Globalization.CultureInfo.InvariantCulture).ToUpperInvariant();
return dateStr; // Fallback: return original if parsing fails
}
private void DrawBadge(Graphics g, string text, int x, int y, Color bgColor)
{
using (var font = new Font("Segoe UI", 6.5f, FontStyle.Bold))
{
var sz = g.MeasureString(text, font);
var rect = new Rectangle(x, y, (int)sz.Width + 8, 14);
using (var path = CreateRoundedRectangle(rect, 4))
using (var brush = new SolidBrush(bgColor))
{
g.FillPath(brush, path);
var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
g.DrawString(text, font, Brushes.White, rect, sf);
}
}
}
private Image GetCachedImage(string packageName)
{
if (string.IsNullOrEmpty(packageName)) return null;
if (_imageCache.TryGetValue(packageName, out var cached)) return cached;
string basePath = SideloaderRCLONE.ThumbnailsFolder;
string path = new[] { ".jpg", ".png" }.Select(ext => Path.Combine(basePath, packageName + ext)).FirstOrDefault(File.Exists);
if (path == null) return null;
try
{
while (_imageCache.Count >= MAX_CACHE_SIZE && _cacheOrder.Count > 0)
{
string oldKey = _cacheOrder.Dequeue();
if (_imageCache.TryGetValue(oldKey, out var oldImg)) { oldImg.Dispose(); _imageCache.Remove(oldKey); }
}
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
{
var img = Image.FromStream(stream);
_imageCache[packageName] = img;
_cacheOrder.Enqueue(packageName);
return img;
}
}
catch { return null; }
}
private int GetTileIndexAtPoint(int x, int y)
{
// Account for sort panel offset
if (y < SORT_PANEL_HEIGHT) return -1;
int adjustedY = (y - SORT_PANEL_HEIGHT) + (int)_scrollY;
int col = (x - _leftPadding) / (_tileWidth + _spacing);
int row = (adjustedY - _spacing) / (_tileHeight + _spacing);
if (col < 0 || col >= _columns || row < 0) return -1;
int tileX = _leftPadding + col * (_tileWidth + _spacing);
int tileY = _spacing + row * (_tileHeight + _spacing);
if (x >= tileX && x < tileX + _tileWidth && adjustedY >= tileY && adjustedY < tileY + _tileHeight)
{
int index = row * _columns + col;
return index < _displayTiles.Count ? index : -1;
}
return -1;
}
private bool IsPointOnDeleteButton(int x, int y, int index)
{
if (index < 0 || index >= _displayTiles.Count) return false;
if (!IsAnyVersionInstalled(_displayTiles[index])) return false;
int row = index / _columns;
int col = index % _columns;
return GetDeleteButtonRect(index, row, col, (int)_scrollY).Contains(x, y);
}
private int GetOverlayVersionAtPoint(int x, int y)
{
if (_expandOverlayOpacity < 0.5f || _expandedTileIndex < 0) return -1;
for (int i = 0; i < _versionRects.Count; i++)
if (!_versionRects[i].IsEmpty && _versionRects[i].Contains(x, y)) return i;
return -1;
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
if (_expandedTileIndex >= 0 && _expandOverlayOpacity > 0.5f)
{
int versionIdx = GetOverlayVersionAtPoint(e.X, e.Y);
if (versionIdx != _overlayHoveredVersion)
{
_overlayHoveredVersion = versionIdx;
// Update release notes when hovering over a version
if (versionIdx >= 0 && versionIdx < _displayTiles[_expandedTileIndex].Versions.Count)
{
var hoveredVersion = _displayTiles[_expandedTileIndex].Versions[versionIdx];
string releaseName = hoveredVersion.SubItems.Count > 1 ? hoveredVersion.SubItems[1].Text : "";
TileHovered?.Invoke(this, releaseName);
}
Invalidate();
}
Cursor = versionIdx >= 0 ? Cursors.Hand : Cursors.Default;
return;
}
int newHover = GetTileIndexAtPoint(e.X, e.Y);
bool wasHoveringDelete = _isHoveringDeleteButton;
if (newHover != _hoveredIndex)
{
_hoveredIndex = newHover;
_isHoveringDeleteButton = false;
}
if (_hoveredIndex >= 0)
_isHoveringDeleteButton = IsPointOnDeleteButton(e.X, e.Y, _hoveredIndex);
else
_isHoveringDeleteButton = false;
// Update cursor
Cursor = _isHoveringDeleteButton || _hoveredIndex >= 0 ? Cursors.Hand : Cursors.Default;
if (wasHoveringDelete != _isHoveringDeleteButton) Invalidate(); // Redraw if delete button hover state changed
}
protected override void OnMouseLeave(EventArgs e)
{
base.OnMouseLeave(e);
_hoveredIndex = -1;
_isHoveringDeleteButton = false;
}
protected override void OnMouseClick(MouseEventArgs e)
{
base.OnMouseClick(e);
// Take focus to unfocus any other control (like search text box)
if (!Focused) Focus();
if (e.Button == MouseButtons.Left)
{
if (_expandedTileIndex >= 0 && _expandOverlayOpacity > 0.5f)
{
int versionIdx = GetOverlayVersionAtPoint(e.X, e.Y);
if (versionIdx >= 0)
{
var tile = _displayTiles[_expandedTileIndex];
var selectedVersion = tile.Versions[versionIdx];
_selectedItem = selectedVersion;
int actualIndex = _items.IndexOf(selectedVersion);
CloseOverlay();
TileClicked?.Invoke(this, actualIndex);
TileDoubleClicked?.Invoke(this, actualIndex);
Invalidate();
return;
}
if (!_overlayRect.Contains(e.X, e.Y))
{
CloseOverlay();
Invalidate();
return;
}
return;
}
int i = GetTileIndexAtPoint(e.X, e.Y);
if (i >= 0)
{
var tile = _displayTiles[i];
// Check if clicking on delete button
if (IsPointOnDeleteButton(e.X, e.Y, i))
{
// Select this item so the uninstall knows which app to remove
_selectedIndex = i;
_selectedItem = tile.Primary;
int actualIndex = _items.IndexOf(tile.Primary);
TileClicked?.Invoke(this, actualIndex);
Invalidate();
// Then trigger delete
TileDeleteClicked?.Invoke(this, actualIndex);
return;
}
if (tile.Versions.Count > 1)
{
_expandedTileIndex = i;
_targetExpandOverlayOpacity = 1.0f;
_overlayHoveredVersion = -1;
_overlayScrollOffset = 0;
// Pre-select the shortest-named version (base game) to show thumbnail/trailer
_selectedIndex = i;
var baseVersion = tile.Versions.OrderBy(v => v.Text.Length).First();
_selectedItem = baseVersion;
int baseIdx = _items.IndexOf(baseVersion);
TileClicked?.Invoke(this, baseIdx);
Invalidate();
return;
}
_selectedIndex = i;
_selectedItem = tile.Primary;
int idx = _items.IndexOf(tile.Primary);
Invalidate();
TileClicked?.Invoke(this, idx);
}
}
else if (e.Button == MouseButtons.Right)
{
// Right-click in overlay - context menu for specific version
if (_expandedTileIndex >= 0 && _expandOverlayOpacity > 0.5f)
{
int versionIdx = GetOverlayVersionAtPoint(e.X, e.Y);
if (versionIdx >= 0)
{
var tile = _displayTiles[_expandedTileIndex];
var version = tile.Versions[versionIdx];
_selectedItem = version;
_rightClickedIndex = _expandedTileIndex;
_rightClickedVersionIndex = versionIdx;
int actualIdx = _items.IndexOf(version);
TileClicked?.Invoke(this, actualIdx);
TileRightClicked?.Invoke(this, actualIdx);
_contextMenu.Show(this, e.Location);
Invalidate();
return;
}
if (!_overlayRect.Contains(e.X, e.Y))
{
CloseOverlay();
Invalidate();
}
return;
}
int i = GetTileIndexAtPoint(e.X, e.Y);
if (i >= 0)
{
_rightClickedIndex = i;
_rightClickedVersionIndex = -1;
_selectedIndex = i;
_selectedItem = _displayTiles[i].Primary;
int actualIdx = _items.IndexOf(_displayTiles[i].Primary);
Invalidate();
TileClicked?.Invoke(this, actualIdx);
TileRightClicked?.Invoke(this, actualIdx);
_contextMenu.Show(this, e.Location);
}
}
}
protected override void OnMouseDoubleClick(MouseEventArgs e)
{
base.OnMouseDoubleClick(e);
if (e.Button != MouseButtons.Left || _expandedTileIndex >= 0) return;
int i = GetTileIndexAtPoint(e.X, e.Y);
// Don't trigger double-click if on delete button
if (i >= 0 && !IsPointOnDeleteButton(e.X, e.Y, i))
{
var tile = _displayTiles[i];
if (tile.Versions.Count == 1)
{
int idx = _items.IndexOf(tile.Primary);
TileDoubleClicked?.Invoke(this, idx);
}
}
}
protected override void OnMouseWheel(MouseEventArgs e)
{
base.OnMouseWheel(e);
// Scroll overlay if open and has overflow
if (_expandedTileIndex >= 0 && _overlayMaxScroll > 0 && _overlayRect.Contains(e.X, e.Y))
{
_overlayScrollOffset -= e.Delta / 3;
_overlayScrollOffset = Math.Max(0, Math.Min(_overlayScrollOffset, _overlayMaxScroll));
Invalidate();
return;
}
if (_expandedTileIndex >= 0) return;
float scrollAmount = e.Delta * 1.2f;
int maxScroll = Math.Max(0, _contentHeight - (Height - SORT_PANEL_HEIGHT));
_targetScrollY = Math.Max(0, Math.Min(maxScroll, _targetScrollY - scrollAmount));
_isScrolling = true;
}
private void CreateContextMenu()
{
_contextMenu = new ContextMenuStrip();
_contextMenu.BackColor = Color.FromArgb(40, 42, 48);
_contextMenu.ForeColor = Color.White;
_contextMenu.ShowImageMargin = false;
_contextMenu.Renderer = new MainForm.CenteredMenuRenderer();
var favoriteItem = new ToolStripMenuItem("★ Add to Favorites");
favoriteItem.Click += ContextMenu_FavoriteClick;
_contextMenu.Items.Add(favoriteItem);
_contextMenu.Opening += ContextMenu_Opening;
}
private void ContextMenu_Opening(object sender, System.ComponentModel.CancelEventArgs e)
{
if (_rightClickedIndex < 0 || _rightClickedIndex >= _displayTiles.Count) { e.Cancel = true; return; }
var tile = _displayTiles[_rightClickedIndex];
ListViewItem targetItem;
// If right-clicked on a specific version in overlay, use that
if (_rightClickedVersionIndex >= 0 && _rightClickedVersionIndex < tile.Versions.Count)
targetItem = tile.Versions[_rightClickedVersionIndex];
else
targetItem = tile.Primary;
string packageName = targetItem.SubItems.Count > 1 ? targetItem.SubItems[1].Text : "";
if (string.IsNullOrEmpty(packageName)) { e.Cancel = true; return; }
bool isFavorite = _favoritesCache.Contains(packageName);
((ToolStripMenuItem)_contextMenu.Items[0]).Text = isFavorite ? "Remove from Favorites" : "★ Add to Favorites";
}
private void ContextMenu_FavoriteClick(object sender, EventArgs e)
{
if (_rightClickedIndex < 0 || _rightClickedIndex >= _displayTiles.Count) return;
var tile = _displayTiles[_rightClickedIndex];
ListViewItem targetItem;
// If right-clicked on a specific version in overlay, use that
if (_rightClickedVersionIndex >= 0 && _rightClickedVersionIndex < tile.Versions.Count)
targetItem = tile.Versions[_rightClickedVersionIndex];
else
targetItem = tile.Primary;
string packageName = targetItem.SubItems.Count > 1 ? targetItem.SubItems[1].Text : "";
if (string.IsNullOrEmpty(packageName)) return;
var settings = SettingsManager.Instance;
if (_favoritesCache.Contains(packageName))
{
settings.RemoveFavoriteGame(packageName);
_favoritesCache.Remove(packageName);
// Check if in favorites-only view (= this item is now the only non-favorite)
bool isFavoritesView = _items.All(item =>
item == targetItem || _favoritesCache.Contains(item.SubItems.Count > 1 ? item.SubItems[1].Text : ""));
if (isFavoritesView)
RemoveVersionFromDisplay(tile, targetItem, _rightClickedIndex);
}
else
{
settings.AddFavoriteGame(packageName);
_favoritesCache.Add(packageName);
}
Invalidate();
}
private void RemoveVersionFromDisplay(GroupedTile tile, ListViewItem item, int tileIndex)
{
_items.Remove(item);
_originalItems.Remove(item);
tile.Versions.Remove(item);
if (tile.Versions.Count == 0)
{
_displayTiles.RemoveAt(tileIndex);
CloseOverlay();
if (_selectedIndex == tileIndex) { _selectedIndex = -1; _selectedItem = null; }
else if (_selectedIndex > tileIndex) _selectedIndex--;
}
else
{
tile.BaseGameName = GetBaseGameName(tile.Versions);
tile.GameName = tile.Versions[0].Text;
if (tile.Versions.Count == 1)
CloseOverlay();
else if (_overlayHoveredVersion >= tile.Versions.Count)
_overlayHoveredVersion = tile.Versions.Count - 1;
}
// Rebuild tile states
_tileStates.Clear();
for (int i = 0; i < _displayTiles.Count; i++)
_tileStates[i] = new TileAnimationState();
RecalculateLayout();
}
public void RefreshFavoritesCache()
{
_favoritesCache = new HashSet<string>(SettingsManager.Instance.FavoritedGames, StringComparer.OrdinalIgnoreCase);
}
public void ScrollToPackage(string releaseName)
{
if (string.IsNullOrEmpty(releaseName) || _displayTiles == null || _displayTiles.Count == 0) return;
// Find the index of the item with the matching release name
for (int i = 0; i < _displayTiles.Count; i++)
{
var tile = _displayTiles[i];
if (tile.Primary.SubItems.Count > 1 && tile.Primary.SubItems[1].Text.Equals(releaseName, StringComparison.OrdinalIgnoreCase))
{
// Calculate the row this item is in
int row = i / _columns;
// Calculate the Y position to scroll to (center the row in view if possible)
int targetY = _spacing + SORT_PANEL_HEIGHT + row * (_tileHeight + _spacing);
int viewportHeight = Height - SORT_PANEL_HEIGHT;
int centeredY = targetY - (viewportHeight / 2) + (_tileHeight / 2);
// Clamp to valid scroll range
int maxScroll = Math.Max(0, _contentHeight - viewportHeight);
_scrollY = Math.Max(0, Math.Min(centeredY, maxScroll));
_targetScrollY = _scrollY;
// Update scrollbar and redraw
if (_scrollBar.Visible)
_scrollBar.Value = Math.Max(_scrollBar.Minimum, Math.Min(_scrollBar.Maximum - _scrollBar.LargeChange + 1, (int)_scrollY));
// Also select this item visually
_selectedIndex = i;
Invalidate();
break;
}
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_animationTimer?.Stop();
_animationTimer?.Dispose();
_contextMenu?.Dispose();
foreach (var img in _imageCache.Values) { try { img?.Dispose(); } catch { } }
_imageCache.Clear();
_cacheOrder.Clear();
_tileStates.Clear();
_backBuffer?.Dispose();
_backBuffer = null;
}
base.Dispose(disposing);
}
}