diff --git a/WheelWizard/Services/ModManager.cs b/WheelWizard/Services/ModManager.cs
index 98e36123..3cce2a06 100644
--- a/WheelWizard/Services/ModManager.cs
+++ b/WheelWizard/Services/ModManager.cs
@@ -31,6 +31,7 @@ private set
}
private bool _isProcessing;
+ private bool _isBatchUpdating;
private ModManager()
{
@@ -103,6 +104,9 @@ public void RemoveMod(Mod mod)
private void Mod_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
+ if (_isBatchUpdating)
+ return;
+
if (
e.PropertyName != nameof(Mod.IsEnabled)
&& e.PropertyName != nameof(Mod.Title)
@@ -490,4 +494,48 @@ public void IncreasePriority(Mod mod)
public int GetLowestActivePriority() => Mods.Min(m => m.Priority);
public int GetHighestActivePriority() => Mods.Max(m => m.Priority);
+
+ ///
+ /// Moves a mod to a new position in the list using gap-based indexing.
+ /// Gap 0 = before first item, gap Count = after last item.
+ ///
+ public void MoveModToIndex(Mod mod, int gapIndex)
+ {
+ var sortedMods = Mods.OrderBy(m => m.Priority).ToList();
+ var currentIndex = sortedMods.IndexOf(mod);
+
+ if (currentIndex == -1)
+ return;
+
+ // Convert gap index to target index after removal
+ int targetIndex;
+ if (gapIndex <= currentIndex)
+ targetIndex = gapIndex;
+ else if (gapIndex > currentIndex + 1)
+ targetIndex = gapIndex - 1;
+ else
+ return; // No change needed (dropped in same position)
+
+ targetIndex = Math.Clamp(targetIndex, 0, sortedMods.Count - 1);
+
+ if (currentIndex == targetIndex)
+ return;
+
+ _isBatchUpdating = true;
+ try
+ {
+ sortedMods.RemoveAt(currentIndex);
+ sortedMods.Insert(targetIndex, mod);
+
+ for (var i = 0; i < sortedMods.Count; i++)
+ sortedMods[i].Priority = i;
+ }
+ finally
+ {
+ _isBatchUpdating = false;
+ }
+
+ SortModsByPriority();
+ SaveModsAsync();
+ }
}
diff --git a/WheelWizard/Views/Components/WhWzLibrary/GridModPanel.axaml b/WheelWizard/Views/Components/WhWzLibrary/GridModPanel.axaml
new file mode 100644
index 00000000..da766fd9
--- /dev/null
+++ b/WheelWizard/Views/Components/WhWzLibrary/GridModPanel.axaml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/WheelWizard/Views/Components/WhWzLibrary/GridModPanel.axaml.cs b/WheelWizard/Views/Components/WhWzLibrary/GridModPanel.axaml.cs
new file mode 100644
index 00000000..250be010
--- /dev/null
+++ b/WheelWizard/Views/Components/WhWzLibrary/GridModPanel.axaml.cs
@@ -0,0 +1,93 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media.Imaging;
+using Microsoft.Extensions.DependencyInjection;
+using WheelWizard.GameBanana;
+using WheelWizard.Views.Pages;
+
+namespace WheelWizard.Views.Components;
+
+public partial class GridModPanel : UserControl
+{
+ public GridModPanel()
+ {
+ InitializeComponent();
+ DataContextChanged += OnDataContextChanged;
+ }
+
+ private void OnDataContextChanged(object? sender, EventArgs e)
+ {
+ // Reset image state when DataContext changes
+ ModImage.Source = null;
+ PlaceholderIcon.IsVisible = true;
+ LoadModImageAsync();
+ }
+
+ private async void LoadModImageAsync()
+ {
+ if (DataContext is not ModListItem item || item.Mod.ModID <= 0)
+ return;
+
+ try
+ {
+ var gameBananaService = App.Services.GetService();
+ if (gameBananaService == null)
+ return;
+
+ var result = await gameBananaService.GetModDetails(item.Mod.ModID);
+ if (!result.IsSuccess || result.Value.PreviewMedia?.Images == null || result.Value.PreviewMedia.Images.Count == 0)
+ return;
+
+ var image = result.Value.PreviewMedia.Images[0];
+ // Prefer smaller 220px thumbnail for grid cards, fall back to full size
+ var imageUrl = image.File220 != null ? $"{image.BaseUrl}/{image.File220}" : $"{image.BaseUrl}/{image.File}";
+
+ using var httpClient = new HttpClient();
+ var response = await httpClient.GetAsync(imageUrl);
+ response.EnsureSuccessStatusCode();
+
+ await using var stream = await response.Content.ReadAsStreamAsync();
+ var memoryStream = new MemoryStream();
+ await stream.CopyToAsync(memoryStream);
+ memoryStream.Position = 0;
+ ModImage.Source = new Bitmap(memoryStream);
+ PlaceholderIcon.IsVisible = false;
+ }
+ catch
+ {
+ // Ignore - just show placeholder icon
+ }
+ }
+
+ private void PriorityText_OnLostFocus(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not ModListItem item || e.Source is not TextBox textBox)
+ return;
+
+ textBox.Classes.Remove("error");
+ if (int.TryParse(textBox.Text, out var newPriority))
+ item.Mod.Priority = newPriority;
+ else
+ textBox.Text = item.Mod.Priority.ToString();
+ }
+
+ private void PriorityText_OnTextChanged(object? sender, TextChangedEventArgs e)
+ {
+ if (e.Source is not TextBox textBox)
+ return;
+
+ if (int.TryParse(textBox.Text, out _))
+ textBox.Classes.Remove("error");
+ else if (!textBox.Classes.Contains("error"))
+ textBox.Classes.Add("error");
+ }
+
+ private void PriorityText_OnKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key != Key.Enter || sender is not TextBox)
+ return;
+
+ this.Focus();
+ }
+}
diff --git a/WheelWizard/Views/Pages/ModsPage.axaml b/WheelWizard/Views/Pages/ModsPage.axaml
index 040b6b4e..a7c7ff0a 100644
--- a/WheelWizard/Views/Pages/ModsPage.axaml
+++ b/WheelWizard/Views/Pages/ModsPage.axaml
@@ -93,28 +93,36 @@
VerticalAlignment="Bottom" />
-
+
+
-
+
+
+
+
+
+ Grid.Column="2" VerticalAlignment="Center" Margin="10,0,0,0" />
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/WheelWizard/Views/Pages/ModsPage.axaml.cs b/WheelWizard/Views/Pages/ModsPage.axaml.cs
index beca7905..e7fe00cd 100644
--- a/WheelWizard/Views/Pages/ModsPage.axaml.cs
+++ b/WheelWizard/Views/Pages/ModsPage.axaml.cs
@@ -1,8 +1,12 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
+using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.VisualTree;
using WheelWizard.Models.Mods;
using WheelWizard.Services;
using WheelWizard.Settings;
@@ -46,6 +50,20 @@ public bool HasMods
}
}
+ // Drag-and-drop state
+ private bool _isDragPending;
+ private bool _isDragging;
+ private Point _dragStartPoint;
+ private ModListItem? _draggedItem;
+ private int _dragStartIndex;
+ private int _currentDropIndex;
+ private ListBoxItem? _draggedListBoxItem;
+ private double _dragOffsetY;
+ private Border? _dragAdorner;
+ private Border? _dropIndicatorLine;
+ private IPointer? _capturedPointer;
+ private const double DragThreshold = 5.0;
+
public ModsPage()
{
InitializeComponent();
@@ -53,6 +71,11 @@ public ModsPage()
ModManager.PropertyChanged += OnModsChanged;
ModManager.ReloadAsync();
SetModsViewVariant();
+
+ // Wire up drag-and-drop pointer tracking
+ PointerMoved += OnDragPointerMoved;
+ PointerReleased += OnDragPointerReleased;
+ PointerCaptureLost += OnDragPointerCaptureLost;
}
private void OnModsChanged(object? sender, PropertyChangedEventArgs e)
@@ -63,6 +86,9 @@ private void OnModsChanged(object? sender, PropertyChangedEventArgs e)
private void OnModsChanged()
{
+ if (_isDragging)
+ return; // Suppress UI updates during drag to prevent stale container references
+
ListItemCount.Text = ModManager.Mods.Count.ToString();
OnPropertyChanged(nameof(Mods));
HasMods = Mods.Count > 0;
@@ -82,7 +108,8 @@ private void ImportMod_Click(object sender, RoutedEventArgs e)
private void RenameMod_Click(object sender, RoutedEventArgs e)
{
- if (ModsListBox.SelectedItem is not ModListItem selectedMod)
+ var selectedMod = GetContextModListItem(sender);
+ if (selectedMod == null)
return;
ModManager.RenameMod(selectedMod.Mod);
@@ -90,7 +117,8 @@ private void RenameMod_Click(object sender, RoutedEventArgs e)
private void DeleteMod_Click(object sender, RoutedEventArgs e)
{
- if (ModsListBox.SelectedItem is not ModListItem selectedMod)
+ var selectedMod = GetContextModListItem(sender);
+ if (selectedMod == null)
return;
ModManager.DeleteMod(selectedMod.Mod);
@@ -98,7 +126,8 @@ private void DeleteMod_Click(object sender, RoutedEventArgs e)
private void OpenFolder_Click(object sender, RoutedEventArgs e)
{
- if (ModsListBox.SelectedItem is not ModListItem selectedMod)
+ var selectedMod = GetContextModListItem(sender);
+ if (selectedMod == null)
return;
ModManager.OpenModFolder(selectedMod.Mod);
@@ -106,7 +135,8 @@ private void OpenFolder_Click(object sender, RoutedEventArgs e)
private void ViewMod_Click(object sender, RoutedEventArgs e)
{
- if (ModsListBox.SelectedItem is not ModListItem selectedMod)
+ var selectedMod = GetContextModListItem(sender);
+ if (selectedMod == null)
{
// You actually never see this error, however, if for some unknown reason it happens, we don't want to disregard it
MessageTranslationHelper.ShowMessage(MessageTranslation.Warning_CantViewMod_SomethingWrong);
@@ -124,6 +154,16 @@ private void ViewMod_Click(object sender, RoutedEventArgs e)
modPopup.ShowDialog();
}
+ ///
+ /// Resolves the ModListItem from either a grid context menu (DataContext) or ListBox selection.
+ ///
+ private ModListItem? GetContextModListItem(object? sender)
+ {
+ if (sender is MenuItem { DataContext: ModListItem item })
+ return item;
+ return ModsListBox.SelectedItem as ModListItem;
+ }
+
private void ToggleButton_OnIsCheckedChanged(object? sender, RoutedEventArgs e)
{
ModManager.ToggleAllMods(EnableAllCheckbox.IsChecked == true);
@@ -205,6 +245,10 @@ private void SetModsViewVariant()
else
elementToSwapClass.Classes.Remove("Rows");
}
+
+ // Toggle between list view (Blocks/arrows mode) and grid view (Rows/priority text mode)
+ ModsListBox.IsVisible = !asRows;
+ ModsGridView.IsVisible = asRows;
}
private void PriorityText_OnKeyDown(object? sender, KeyEventArgs e)
@@ -214,6 +258,350 @@ private void PriorityText_OnKeyDown(object? sender, KeyEventArgs e)
ViewUtils.FindParent(e.Source)?.Focus();
}
+ #region Drag and Drop
+
+ private void DragHandle_PointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ return;
+
+ var listBoxItem = ViewUtils.FindParent(sender);
+ if (listBoxItem?.Content is not ModListItem modItem)
+ return;
+
+ if (Mods.Count <= 1)
+ return;
+
+ ModsListBox.SelectedItem = modItem;
+
+ _isDragPending = true;
+ _isDragging = false;
+ _dragStartPoint = e.GetPosition(this);
+ _draggedItem = modItem;
+ _draggedListBoxItem = listBoxItem;
+ _dragOffsetY = e.GetPosition(listBoxItem).Y;
+
+ _capturedPointer = e.Pointer;
+ e.Pointer.Capture(this);
+ e.Handled = true;
+ }
+
+ private void OnDragPointerMoved(object? sender, PointerEventArgs e)
+ {
+ if (!_isDragPending && !_isDragging)
+ return;
+
+ var currentPos = e.GetPosition(this);
+
+ if (_isDragPending && !_isDragging)
+ {
+ var delta = currentPos - _dragStartPoint;
+ if (Math.Abs(delta.Y) < DragThreshold && Math.Abs(delta.X) < DragThreshold)
+ return;
+
+ StartDrag(currentPos);
+ }
+
+ if (_isDragging)
+ UpdateDrag(e, currentPos);
+ }
+
+ private void OnDragPointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (_isDragging)
+ {
+ EndDrag(commit: true);
+ e.Handled = true;
+ }
+ else if (_isDragPending)
+ {
+ CancelDrag();
+ }
+ }
+
+ private void OnDragPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
+ {
+ if (_isDragging || _isDragPending)
+ CancelDrag();
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Key == Key.Escape && (_isDragging || _isDragPending))
+ {
+ CancelDrag();
+ e.Handled = true;
+ }
+ }
+
+ private void StartDrag(Point currentPos)
+ {
+ _isDragPending = false;
+ _isDragging = true;
+
+ _dragStartIndex = GetModIndex(_draggedItem!);
+ _currentDropIndex = _dragStartIndex;
+
+ if (_draggedListBoxItem != null)
+ _draggedListBoxItem.Opacity = 0.3;
+
+ CreateDragAdorner(currentPos);
+ CreateDropIndicator();
+ }
+
+ private void CreateDragAdorner(Point pos)
+ {
+ var title = _draggedItem?.Mod.Title ?? "Mod";
+
+ var content = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ VerticalAlignment = VerticalAlignment.Center,
+ Spacing = 10,
+ };
+
+ try
+ {
+ if (this.FindResource("Grip") is Geometry gripData)
+ {
+ content.Children.Add(
+ new PathIcon
+ {
+ Data = gripData,
+ Width = 12,
+ Height = 12,
+ Foreground = new SolidColorBrush(Color.Parse("#6C7389")),
+ }
+ );
+ }
+ }
+ catch
+ {
+ // Resource not found, skip grip icon
+ }
+
+ content.Children.Add(
+ new TextBlock
+ {
+ Text = title,
+ Foreground = new SolidColorBrush(Colors.White),
+ FontSize = 14,
+ VerticalAlignment = VerticalAlignment.Center,
+ }
+ );
+
+ _dragAdorner = new Border
+ {
+ Background = new SolidColorBrush(Color.Parse("#474B5D")),
+ CornerRadius = new CornerRadius(6),
+ Padding = new Thickness(15, 0),
+ Height = 50,
+ MinWidth = 250,
+ RenderTransform = new RotateTransform(1.5),
+ Opacity = 0.95,
+ BoxShadow = new BoxShadows(
+ new BoxShadow
+ {
+ Blur = 15,
+ Color = Color.Parse("#80000000"),
+ OffsetX = 0,
+ OffsetY = 4,
+ }
+ ),
+ Child = content,
+ };
+
+ Canvas.SetLeft(_dragAdorner, pos.X - 30);
+ Canvas.SetTop(_dragAdorner, pos.Y - _dragOffsetY);
+
+ DragCanvas.Children.Add(_dragAdorner);
+ DragCanvas.IsVisible = true;
+ }
+
+ private void CreateDropIndicator()
+ {
+ _dropIndicatorLine = new Border
+ {
+ Height = 3,
+ Background = new SolidColorBrush(Color.Parse("#34EAC5")),
+ CornerRadius = new CornerRadius(2),
+ IsVisible = false,
+ };
+
+ DragCanvas.Children.Add(_dropIndicatorLine);
+ }
+
+ private void UpdateDrag(PointerEventArgs e, Point currentPos)
+ {
+ if (_dragAdorner != null)
+ {
+ Canvas.SetLeft(_dragAdorner, currentPos.X - 30);
+ Canvas.SetTop(_dragAdorner, currentPos.Y - _dragOffsetY);
+ }
+
+ var dropIndex = CalculateDropIndex(e);
+ _currentDropIndex = dropIndex;
+ UpdateDropIndicator(dropIndex);
+ HandleAutoScroll(e);
+ }
+
+ private int CalculateDropIndex(PointerEventArgs e)
+ {
+ var posInListBox = e.GetPosition(ModsListBox);
+ var items = GetListBoxItems();
+
+ if (items.Count == 0)
+ return 0;
+
+ for (var i = 0; i < items.Count; i++)
+ {
+ var itemPos = items[i].TranslatePoint(new Point(0, 0), ModsListBox);
+ if (itemPos == null)
+ continue;
+
+ var midY = itemPos.Value.Y + items[i].Bounds.Height / 2;
+ if (posInListBox.Y < midY)
+ return i;
+ }
+
+ return items.Count;
+ }
+
+ private void UpdateDropIndicator(int gapIndex)
+ {
+ if (_dropIndicatorLine == null)
+ return;
+
+ var items = GetListBoxItems();
+ if (items.Count == 0)
+ return;
+
+ // Hide indicator when hovering over the no-op zone (same position)
+ if (gapIndex == _dragStartIndex || gapIndex == _dragStartIndex + 1)
+ {
+ _dropIndicatorLine.IsVisible = false;
+ return;
+ }
+
+ _dropIndicatorLine.IsVisible = true;
+
+ if (gapIndex < items.Count)
+ {
+ var item = items[gapIndex];
+ var itemPos = item.TranslatePoint(new Point(0, -2), DragCanvas);
+ if (itemPos != null)
+ {
+ Canvas.SetLeft(_dropIndicatorLine, itemPos.Value.X);
+ Canvas.SetTop(_dropIndicatorLine, itemPos.Value.Y);
+ _dropIndicatorLine.Width = item.Bounds.Width;
+ }
+ }
+ else
+ {
+ var lastItem = items[^1];
+ var itemPos = lastItem.TranslatePoint(new Point(0, lastItem.Bounds.Height + 2), DragCanvas);
+ if (itemPos != null)
+ {
+ Canvas.SetLeft(_dropIndicatorLine, itemPos.Value.X);
+ Canvas.SetTop(_dropIndicatorLine, itemPos.Value.Y);
+ _dropIndicatorLine.Width = lastItem.Bounds.Width;
+ }
+ }
+ }
+
+ private void HandleAutoScroll(PointerEventArgs e)
+ {
+ var scrollViewer = ModsListBox.GetVisualDescendants().OfType().FirstOrDefault();
+ if (scrollViewer == null)
+ return;
+
+ var pos = e.GetPosition(scrollViewer);
+ const double scrollZone = 40.0;
+ const double scrollSpeed = 8.0;
+
+ if (pos.Y < scrollZone && scrollViewer.Offset.Y > 0)
+ {
+ var factor = 1.0 - pos.Y / scrollZone;
+ scrollViewer.Offset = new Vector(scrollViewer.Offset.X, Math.Max(0, scrollViewer.Offset.Y - scrollSpeed * factor));
+ }
+ else if (pos.Y > scrollViewer.Viewport.Height - scrollZone)
+ {
+ var maxScroll = scrollViewer.Extent.Height - scrollViewer.Viewport.Height;
+ if (scrollViewer.Offset.Y < maxScroll)
+ {
+ var factor = 1.0 - (scrollViewer.Viewport.Height - pos.Y) / scrollZone;
+ scrollViewer.Offset = new Vector(scrollViewer.Offset.X, Math.Min(maxScroll, scrollViewer.Offset.Y + scrollSpeed * factor));
+ }
+ }
+ }
+
+ private List GetListBoxItems()
+ {
+ var items = new List();
+ for (var i = 0; i < Mods.Count; i++)
+ {
+ if (ModsListBox.ContainerFromIndex(i) is ListBoxItem lbi)
+ items.Add(lbi);
+ }
+ return items;
+ }
+
+ private int GetModIndex(ModListItem modItem)
+ {
+ for (var i = 0; i < Mods.Count; i++)
+ {
+ if (Mods[i].Mod == modItem.Mod)
+ return i;
+ }
+ return -1;
+ }
+
+ private void EndDrag(bool commit)
+ {
+ if (_draggedListBoxItem != null)
+ _draggedListBoxItem.Opacity = 1.0;
+
+ var modToMove = _draggedItem?.Mod;
+ var targetGapIndex = _currentDropIndex;
+ var sourceIndex = _dragStartIndex;
+ var shouldCommit = commit && modToMove != null && targetGapIndex != sourceIndex && targetGapIndex != sourceIndex + 1;
+
+ CleanupDrag();
+
+ if (shouldCommit)
+ ModManager.MoveModToIndex(modToMove!, targetGapIndex);
+ }
+
+ private void CancelDrag()
+ {
+ if (!_isDragging && !_isDragPending)
+ return;
+
+ if (_draggedListBoxItem != null)
+ _draggedListBoxItem.Opacity = 1.0;
+
+ CleanupDrag();
+ }
+
+ private void CleanupDrag()
+ {
+ _isDragging = false;
+ _isDragPending = false;
+ _draggedItem = null;
+ _draggedListBoxItem = null;
+ _dragAdorner = null;
+ _dropIndicatorLine = null;
+
+ DragCanvas.Children.Clear();
+ DragCanvas.IsVisible = false;
+
+ _capturedPointer?.Capture(null);
+ _capturedPointer = null;
+ }
+
+ #endregion
+
#region PropertyChanged
public new event PropertyChangedEventHandler? PropertyChanged;