Files
rookie/GalleryView.cs
jp64k 84ddce1423 Synchronized sorting states between list view and gallery view
Introduced shared sorting state logic to maintain consistent sorting order when switching between gallery and list views (game name, last updated, size, popularity, asc and desc)
2025-12-30 22:12:29 +01:00

1469 lines
55 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;
// 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 bool _isHoveringDeleteButton = false;
// Context Menu & Favorites
private ContextMenuStrip _contextMenu;
private int _rightClickedIndex = -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.07f;
private const float ANIMATION_SPEED = 0.25f;
private const float SCROLL_SMOOTHING = 0.3f;
private const int DELETE_BUTTON_SIZE = 26;
private const int DELETE_BUTTON_MARGIN = 6;
// 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);
public event EventHandler<int> TileClicked;
public event EventHandler<int> TileDoubleClicked;
public event EventHandler<int> TileDeleteClicked;
public event EventHandler<int> TileRightClicked;
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 FastGalleryPanel(List<ListViewItem> items, int tileWidth, int tileHeight, int spacing, int initialWidth, int initialHeight)
{
_originalItems = items ?? new List<ListViewItem>();
_items = new List<ListViewItem>(_originalItems);
_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);
// Initialize animation states
for (int i = 0; i < _items.Count; i++)
_tileStates[i] = new TileAnimationState();
// 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 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;
}
// Add sort status label to the right of buttons
_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;
// Hover colors will be set dynamically in UpdateSortButtonStyles
btn.FlatAppearance.MouseOverBackColor = SortButtonHoverBg;
btn.FlatAppearance.MouseDownBackColor = SortButtonActiveBg;
btn.Click += (s, e) => OnSortButtonClick(field);
return btn;
}
private string GetSortButtonText(string baseText)
{
string arrow = _currentSortDirection == SortDirection.Ascending ? " ▲" : " ▼";
return baseText + arrow;
}
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;
btn.BackColor = isActive ? SortButtonActiveBg : SortButtonBg;
btn.ForeColor = isActive ? Color.FromArgb(24, 26, 30) : Color.White;
// Set appropriate hover color based on active state
if (isActive)
{
// Active button: use a slightly lighter teal on hover
btn.FlatAppearance.MouseOverBackColor = Color.FromArgb(110, 215, 190);
btn.FlatAppearance.MouseDownBackColor = Color.FromArgb(80, 180, 155);
}
else
{
// Inactive button: use grey hover
btn.FlatAppearance.MouseOverBackColor = SortButtonHoverBg;
btn.FlatAppearance.MouseDownBackColor = SortButtonActiveBg;
}
}
// Update the sort status label
if (_sortStatusLabel != null)
{
_sortStatusLabel.Text = GetSortStatusText();
}
}
private void ApplySort()
{
// Reset to original order first
_items = new List<ListViewItem>(_originalItems);
// Apply sorting
switch (_currentSortField)
{
case SortField.Name:
// Custom sort to match list sort behaviour: '_' before digits, digits before letters (case-insensitive)
if (_currentSortDirection == SortDirection.Ascending)
_items = _items.OrderBy(i => i.Text, new GameNameComparer()).ToList();
else
_items = _items.OrderByDescending(i => i.Text, new GameNameComparer()).ToList();
break;
case SortField.LastUpdated:
_items = _currentSortDirection == SortDirection.Ascending
? _items.OrderBy(i => ParseDate(i.SubItems.Count > 4 ? i.SubItems[4].Text : "")).ToList()
: _items.OrderByDescending(i => ParseDate(i.SubItems.Count > 4 ? i.SubItems[4].Text : "")).ToList();
break;
case SortField.Size:
_items = _currentSortDirection == SortDirection.Ascending
? _items.OrderBy(i => ParseSize(i.SubItems.Count > 5 ? i.SubItems[5].Text : "0")).ToList()
: _items.OrderByDescending(i => ParseSize(i.SubItems.Count > 5 ? i.SubItems[5].Text : "0")).ToList();
break;
case SortField.Popularity:
if (_currentSortDirection == SortDirection.Ascending)
_items = _items.OrderByDescending(i => ParsePopularity(i.SubItems.Count > 6 ? i.SubItems[6].Text : "-"))
.ThenBy(i => i.Text, new GameNameComparer()).ToList();
else
_items = _items.OrderBy(i => ParsePopularity(i.SubItems.Count > 6 ? i.SubItems[6].Text : "-"))
.ThenBy(i => i.Text, new GameNameComparer()).ToList();
break;
}
// Reset selection and hover
_hoveredIndex = -1;
_selectedIndex = -1;
// Rebuild animation states
_tileStates.Clear();
for (int i = 0; i < _items.Count; i++)
_tileStates[i] = new TileAnimationState();
// Reset scroll position
_scrollY = 0;
_targetScrollY = 0;
RecalculateLayout();
Invalidate();
}
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();
// 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)
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++)
{
char cx = x[i];
char cy = y[i];
int orderX = GetCharOrder(cx);
int orderY = GetCharOrder(cy);
if (orderX != orderY)
return orderX.CompareTo(orderY);
// Same category, compare case-insensitively
int cmp = char.ToLowerInvariant(cx).CompareTo(char.ToLowerInvariant(cy));
if (cmp != 0)
return cmp;
}
// Shorter string comes first
return x.Length.CompareTo(y.Length);
}
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;
// 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;
}
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;
_isHoveringDeleteButton = false;
// Rebuild animation states for new item count
_tileStates.Clear();
for (int i = 0; i < _items.Count; i++)
_tileStates[i] = new TileAnimationState();
// 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 (index >= 0 && index < _items.Count)
return _items[index];
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 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;
// Calculate thumbnail area
int thumbPadding = 2;
int thumbHeight = scaledH - (thumbPadding * 2);
// Position delete button in bottom-right corner of thumbnail
int btnX = x + scaledW - DELETE_BUTTON_SIZE - thumbPadding - DELETE_BUTTON_MARGIN;
int btnY = y + thumbPadding + thumbHeight - 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;
}
}
// 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 >= _items.Count) break;
if (!_tileStates.TryGetValue(index, out var state))
{
state = new TileAnimationState();
_tileStates[index] = state;
}
bool isHovered = index == _hoveredIndex;
bool isSelected = index == _selectedIndex;
bool isInstalled = IsItemInstalled(_items[index]);
string pkgName = _items[index].SubItems.Count > 2 ? _items[index].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;
if (Math.Abs(state.Scale - state.TargetScale) > 0.001f)
{
state.Scale += (state.TargetScale - state.Scale) * ANIMATION_SPEED;
needsRedraw = true;
}
else state.Scale = state.TargetScale;
if (Math.Abs(state.BorderOpacity - state.TargetBorderOpacity) > 0.01f)
{
state.BorderOpacity += (state.TargetBorderOpacity - state.BorderOpacity) * ANIMATION_SPEED;
needsRedraw = true;
}
else state.BorderOpacity = state.TargetBorderOpacity;
if (Math.Abs(state.BackgroundBrightness - state.TargetBackgroundBrightness) > 0.5f)
{
state.BackgroundBrightness += (state.TargetBackgroundBrightness - state.BackgroundBrightness) * ANIMATION_SPEED;
needsRedraw = true;
}
else state.BackgroundBrightness = state.TargetBackgroundBrightness;
if (Math.Abs(state.SelectionOpacity - state.TargetSelectionOpacity) > 0.01f)
{
state.SelectionOpacity += (state.TargetSelectionOpacity - state.SelectionOpacity) * ANIMATION_SPEED;
needsRedraw = true;
}
else state.SelectionOpacity = state.TargetSelectionOpacity;
if (Math.Abs(state.TooltipOpacity - state.TargetTooltipOpacity) > 0.01f)
{
state.TooltipOpacity += (state.TargetTooltipOpacity - state.TooltipOpacity) * 0.35f;
needsRedraw = true;
}
else state.TooltipOpacity = state.TargetTooltipOpacity;
if (Math.Abs(state.DeleteButtonOpacity - state.TargetDeleteButtonOpacity) > 0.01f)
{
state.DeleteButtonOpacity += (state.TargetDeleteButtonOpacity - state.DeleteButtonOpacity) * 0.35f;
needsRedraw = true;
}
else state.DeleteButtonOpacity = state.TargetDeleteButtonOpacity;
if (Math.Abs(state.FavoriteOpacity - state.TargetFavoriteOpacity) > 0.01f)
{ state.FavoriteOpacity += (state.TargetFavoriteOpacity - state.FavoriteOpacity) * 0.35f; needsRedraw = true; }
else state.FavoriteOpacity = state.TargetFavoriteOpacity;
}
}
if (needsRedraw) Invalidate();
}
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)_items.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);
if (_isScrolling)
{
g.SmoothingMode = SmoothingMode.HighSpeed;
g.InterpolationMode = InterpolationMode.Low;
}
else
{
g.SmoothingMode = SmoothingMode.AntiAlias;
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
}
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 >= _items.Count) break;
if (index != _hoveredIndex && index != _selectedIndex)
DrawTile(g, index, row, col, scrollYInt);
}
}
// Draw selected tile
if (_selectedIndex >= 0 && _selectedIndex < _items.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 < _items.Count)
{
int hoveredRow = _hoveredIndex / _columns;
int hoveredCol = _hoveredIndex % _columns;
if (hoveredRow >= startRow && hoveredRow <= endRow)
DrawTile(g, _hoveredIndex, hoveredRow, hoveredCol, scrollYInt);
}
g.ResetClip();
}
e.Graphics.DrawImageUnscaled(_backBuffer, 0, 0);
}
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 item = _items[index];
var state = _tileStates.ContainsKey(index) ? _tileStates[index] : 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;
var tileRect = new Rectangle(x, y, scaledW, scaledH);
// Tile background
using (var tilePath = CreateRoundedRectangle(tileRect, CORNER_RADIUS))
{
int brightness = (int)state.BackgroundBrightness;
using (var bgBrush = new SolidBrush(Color.FromArgb(255, brightness, brightness, brightness + 2)))
g.FillPath(bgBrush, tilePath);
if (state.SelectionOpacity > 0.01f)
{
using (var selectionPen = new Pen(Color.FromArgb((int)(255 * state.SelectionOpacity), TileBorderSelected), 3f))
g.DrawPath(selectionPen, tilePath);
}
if (state.BorderOpacity > 0.01f)
{
using (var borderPen = new Pen(Color.FromArgb((int)(200 * state.BorderOpacity), TileBorderHover), 2f))
g.DrawPath(borderPen, tilePath);
}
// Favorite border (golden)
if (state.FavoriteOpacity > 0.5f)
{
using (var favPen = new Pen(Color.FromArgb((int)(180 * state.FavoriteOpacity), TileBorderFavorite), 1.0f))
g.DrawPath(favPen, tilePath);
}
}
// Thumbnail
int thumbPadding = 2;
int thumbHeight = scaledH - (thumbPadding * 2);
var thumbRect = new Rectangle(
x + thumbPadding,
y + thumbPadding,
scaledW - (thumbPadding * 2),
thumbHeight
);
// Base (non-scaled) thumbnail size for stable placeholder text layout
int baseThumbW = _tileWidth - (thumbPadding * 2);
int baseThumbH = _tileHeight - (thumbPadding * 2);
var baseThumbRect = new Rectangle(
thumbRect.X + (thumbRect.Width - baseThumbW) / 2,
thumbRect.Y + (thumbRect.Height - baseThumbH) / 2,
baseThumbW,
baseThumbH
);
string packageName = item.SubItems.Count > 2 ? item.SubItems[2].Text : "";
var thumbnail = GetCachedImage(packageName);
using (var thumbPath = CreateRoundedRectangle(thumbRect, THUMB_CORNER_RADIUS))
{
var oldClip = g.Clip;
g.SetClip(thumbPath, CombineMode.Replace);
if (thumbnail != null)
{
float imgRatio = (float)thumbnail.Width / thumbnail.Height;
float rectRatio = (float)thumbRect.Width / thumbRect.Height;
Rectangle drawRect = imgRatio > rectRatio
? new Rectangle(thumbRect.X - ((int)(thumbRect.Height * imgRatio) - thumbRect.Width) / 2, thumbRect.Y, (int)(thumbRect.Height * imgRatio), thumbRect.Height)
: new Rectangle(thumbRect.X, thumbRect.Y - ((int)(thumbRect.Width / imgRatio) - thumbRect.Height) / 2, thumbRect.Width, (int)(thumbRect.Width / imgRatio));
g.DrawImage(thumbnail, drawRect);
}
else
{
using (var brush = new SolidBrush(Color.FromArgb(35, 35, 40)))
g.FillPath(brush, thumbPath);
// Show game name when thumbnail is missing, centered
var nameRect = new Rectangle(baseThumbRect.X + 10, baseThumbRect.Y, baseThumbRect.Width - 20, baseThumbRect.Height);
using (var font = new Font("Segoe UI", 10f, FontStyle.Bold))
{
var sfName = new StringFormat
{
Alignment = StringAlignment.Center,
LineAlignment = StringAlignment.Center,
Trimming = StringTrimming.EllipsisCharacter
};
using (var text = new SolidBrush(Color.FromArgb(110, 110, 120)))
g.DrawString(item.Text, font, text, nameRect, sfName);
}
}
g.Clip = oldClip;
}
// Status badges (left side)
int badgeY = y + thumbPadding + 4;
// Favorite badge
if (state.FavoriteOpacity > 0.5f)
{
DrawBadge(g, "★", x + thumbPadding + 4, badgeY, BadgeFavoriteBg);
badgeY += 18;
}
bool hasUpdate = item.ForeColor.ToArgb() == MainForm.ColorUpdateAvailable.ToArgb();
bool installed = item.ForeColor.ToArgb() == MainForm.ColorInstalled.ToArgb();
bool canDonate = item.ForeColor.ToArgb() == MainForm.ColorDonateGame.ToArgb();
if (hasUpdate)
{
DrawBadge(g, "UPDATE AVAILABLE", x + thumbPadding + 4, badgeY, Color.FromArgb(180, MainForm.ColorUpdateAvailable.R, MainForm.ColorUpdateAvailable.G, MainForm.ColorUpdateAvailable.B));
badgeY += 18;
}
if (canDonate)
{
DrawBadge(g, "NEWER THAN LIST", x + thumbPadding + 4, badgeY, Color.FromArgb(180, MainForm.ColorDonateGame.R, MainForm.ColorDonateGame.G, MainForm.ColorDonateGame.B));
badgeY += 18;
}
if (installed || hasUpdate || canDonate)
DrawBadge(g, "INSTALLED", x + thumbPadding + 4, badgeY, BadgeInstalledBg);
// Right-side badges (top-right of thumbnail)
int rightBadgeY = y + thumbPadding + 4;
// Size badge (top right) - always visible
if (item.SubItems.Count > 5)
{
string sizeText = item.SubItems[5].Text;
if (!string.IsNullOrEmpty(sizeText))
{
DrawRightAlignedBadge(g, sizeText, x + scaledW - thumbPadding - 4, rightBadgeY, 1.0f);
rightBadgeY += 18;
}
}
// Last updated badge (below size, right aligned) - only on hover with fade
if (state.TooltipOpacity > 0.01f && item.SubItems.Count > 4)
{
string lastUpdated = item.SubItems[4].Text;
string formattedDate = FormatLastUpdated(lastUpdated);
if (!string.IsNullOrEmpty(formattedDate))
{
DrawRightAlignedBadge(g, formattedDate, x + scaledW - thumbPadding - 4, rightBadgeY, state.TooltipOpacity);
}
}
// Delete button (bottom-right of thumbnail) - for installed apps on hover
if (state.DeleteButtonOpacity > 0.01f)
{
DrawDeleteButton(g, x, y, scaledW, thumbHeight, thumbPadding, state.DeleteButtonOpacity, _isHoveringDeleteButton && index == _hoveredIndex);
}
// Game name
if (state.TooltipOpacity > 0.01f)
{
int overlayH = 20;
var overlayRect = new Rectangle(thumbRect.X, thumbRect.Bottom - overlayH, thumbRect.Width, overlayH);
// Clip to the exact rounded thumbnail so the overlay corners match perfectly
Region oldClip = g.Clip;
using (var clipPath = CreateRoundedRectangle(thumbRect, THUMB_CORNER_RADIUS))
{
g.SetClip(clipPath, CombineMode.Intersect);
// Slightly overdraw to avoid 1px seams from AA / integer rounding
var fillRect = new Rectangle(overlayRect.X - 1, overlayRect.Y, overlayRect.Width + 2, overlayRect.Height + 1);
using (var overlayBrush = new SolidBrush(Color.FromArgb((int)(180 * state.TooltipOpacity), 0, 0, 0)))
g.FillRectangle(overlayBrush, fillRect);
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.R, TextColor.G, TextColor.B)))
{
var sf = new StringFormat
{
Alignment = StringAlignment.Center,
LineAlignment = StringAlignment.Center,
Trimming = StringTrimming.EllipsisCharacter,
FormatFlags = StringFormatFlags.NoWrap
};
var textRect = new Rectangle(overlayRect.X, overlayRect.Y + 1, overlayRect.Width, overlayRect.Height);
g.DrawString(item.Text, font, brush, textRect, sf);
}
}
}
private void DrawDeleteButton(Graphics g, int tileX, int tileY, int tileWidth, int thumbHeight, int thumbPadding, float opacity, bool isHovering)
{
// Position in bottom-right corner of thumbnail
int btnX = tileX + tileWidth - DELETE_BUTTON_SIZE - thumbPadding - DELETE_BUTTON_MARGIN;
int btnY = tileY + thumbPadding + thumbHeight - DELETE_BUTTON_SIZE - DELETE_BUTTON_MARGIN - 20;
var btnRect = new Rectangle(btnX, btnY, DELETE_BUTTON_SIZE, DELETE_BUTTON_SIZE);
int bgAlpha = (int)(opacity * 255);
Color bgColor = isHovering ? DeleteButtonHoverBg : DeleteButtonBg;
using (var path = CreateRoundedRectangle(btnRect, 6))
using (var bgBrush = new SolidBrush(Color.FromArgb(bgAlpha, bgColor.R, bgColor.G, bgColor.B)))
{
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 = 1.0f)
{
using (var font = new Font("Segoe UI", 7f, FontStyle.Bold))
{
var sz = g.MeasureString(text, font);
int badgeWidth = (int)sz.Width + 8;
var rect = new Rectangle(rightX - badgeWidth, y, badgeWidth, 14);
int bgAlpha = (int)(180 * opacity);
int textAlpha = (int)(255 * opacity);
using (var path = CreateRoundedRectangle(rect, 4))
using (var bgBrush = new SolidBrush(Color.FromArgb(bgAlpha, 0, 0, 0)))
{
g.FillPath(bgBrush, path);
var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
using (var textBrush = new SolidBrush(Color.FromArgb(textAlpha, 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
string datePart = dateStr.Split(' ')[0];
if (DateTime.TryParse(datePart, out DateTime date))
{
// Format as "29 JUL 2025"
return date.ToString("dd MMM yyyy", System.Globalization.CultureInfo.InvariantCulture).ToUpperInvariant();
}
// Fallback: return original if parsing fails
return dateStr;
}
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 string FormatSize(string sizeStr)
{
if (double.TryParse(sizeStr?.Trim(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out double mb))
{
double gb = mb / 1024.0;
return gb >= 0.1 ? $"{gb:F2} GB" : $"{mb:F0} MB";
}
return "";
}
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 GetIndexAtPoint(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 < _items.Count ? index : -1;
}
return -1;
}
private bool IsPointOnDeleteButton(int x, int y, int index)
{
if (index < 0 || index >= _items.Count) return false;
if (!IsItemInstalled(_items[index])) return false;
int row = index / _columns;
int col = index % _columns;
var btnRect = GetDeleteButtonRect(index, row, col, (int)_scrollY);
return btnRect.Contains(x, y);
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
int newHover = GetIndexAtPoint(e.X, e.Y);
bool wasHoveringDelete = _isHoveringDeleteButton;
if (newHover != _hoveredIndex)
{
_hoveredIndex = newHover;
_isHoveringDeleteButton = false;
}
// Check if hovering over delete button
if (_hoveredIndex >= 0)
{
_isHoveringDeleteButton = IsPointOnDeleteButton(e.X, e.Y, _hoveredIndex);
}
else
{
_isHoveringDeleteButton = false;
}
// Update cursor
if (_isHoveringDeleteButton)
{
Cursor = Cursors.Hand;
}
else if (_hoveredIndex >= 0)
{
Cursor = Cursors.Hand;
}
else
{
Cursor = Cursors.Default;
}
// Redraw if delete button hover state changed
if (wasHoveringDelete != _isHoveringDeleteButton)
{
Invalidate();
}
}
protected override void OnMouseLeave(EventArgs e)
{
base.OnMouseLeave(e);
if (_hoveredIndex >= 0) _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)
{
int i = GetIndexAtPoint(e.X, e.Y);
if (i >= 0)
{
// 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;
TileClicked?.Invoke(this, i);
Invalidate();
// Then trigger delete
TileDeleteClicked?.Invoke(this, i);
return;
}
_selectedIndex = i;
Invalidate();
TileClicked?.Invoke(this, i);
}
}
else if (e.Button == MouseButtons.Right)
{
int i = GetIndexAtPoint(e.X, e.Y);
if (i >= 0)
{
_rightClickedIndex = i;
_selectedIndex = i;
Invalidate();
TileClicked?.Invoke(this, i);
TileRightClicked?.Invoke(this, i);
_contextMenu.Show(this, e.Location);
}
}
}
protected override void OnMouseDoubleClick(MouseEventArgs e)
{
base.OnMouseDoubleClick(e);
if (e.Button == MouseButtons.Left)
{
int i = GetIndexAtPoint(e.X, e.Y);
if (i >= 0)
{
// Don't trigger double-click if on delete button
if (IsPointOnDeleteButton(e.X, e.Y, i))
return;
TileDoubleClicked?.Invoke(this, i);
}
}
}
protected override void OnMouseWheel(MouseEventArgs e)
{
base.OnMouseWheel(e);
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 >= _items.Count) { e.Cancel = true; return; }
var item = _items[_rightClickedIndex];
string packageName = item.SubItems.Count > 2 ? item.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 >= _items.Count) return;
var item = _items[_rightClickedIndex];
string packageName = item.SubItems.Count > 1 ? item.SubItems[1].Text : "";
if (string.IsNullOrEmpty(packageName)) return;
var settings = SettingsManager.Instance;
if (_favoritesCache.Contains(packageName))
{
settings.RemoveFavoriteGame(packageName);
_favoritesCache.Remove(packageName);
}
else
{
settings.AddFavoriteGame(packageName);
_favoritesCache.Add(packageName);
}
Invalidate();
}
public void RefreshFavoritesCache()
{
_favoritesCache = new HashSet<string>(SettingsManager.Instance.FavoritedGames, StringComparer.OrdinalIgnoreCase);
}
public void ScrollToPackage(string releaseName)
{
if (string.IsNullOrEmpty(releaseName) || _items == null || _items.Count == 0)
return;
// Find the index of the item with the matching release name
for (int i = 0; i < _items.Count; i++)
{
var item = _items[i];
if (item.SubItems.Count > 1 &&
item.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 = this.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);
}
}