From 9ab49c920139ec3131fae00991613b7b1c1af84e Mon Sep 17 00:00:00 2001 From: Patchzy <64382339+patchzyy@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:21:10 +0100 Subject: [PATCH 1/3] Add drag-and-drop reordering for mods --- WheelWizard/Services/ModManager.cs | 48 +++ WheelWizard/Views/Pages/ModsPage.axaml | 18 +- WheelWizard/Views/Pages/ModsPage.axaml.cs | 370 ++++++++++++++++++++++ 3 files changed, 433 insertions(+), 3 deletions(-) diff --git a/WheelWizard/Services/ModManager.cs b/WheelWizard/Services/ModManager.cs index 534a8ac3..85d2ece8 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/Pages/ModsPage.axaml b/WheelWizard/Views/Pages/ModsPage.axaml index 040b6b4e..7ab7df22 100644 --- a/WheelWizard/Views/Pages/ModsPage.axaml +++ b/WheelWizard/Views/Pages/ModsPage.axaml @@ -100,21 +100,29 @@ + - + + + + + + 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 5301d174..a7f451bb 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.Settings; using WheelWizard.Services; using WheelWizard.Services.Settings; @@ -42,6 +46,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(); @@ -49,6 +67,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) @@ -59,6 +82,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; @@ -210,6 +236,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; From ad9b4a206725a6dc3bd0bfd4105fea9403fdfddf Mon Sep 17 00:00:00 2001 From: Patchzy <64382339+patchzyy@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:56:42 +0100 Subject: [PATCH 2/3] Start on gridmod --- .../Components/WhWzLibrary/GridModPanel.axaml | 87 +++++++++++++++++ .../WhWzLibrary/GridModPanel.axaml.cs | 93 +++++++++++++++++++ WheelWizard/Views/Pages/ModsPage.axaml | 30 +++++- WheelWizard/Views/Pages/ModsPage.axaml.cs | 26 +++++- 4 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 WheelWizard/Views/Components/WhWzLibrary/GridModPanel.axaml create mode 100644 WheelWizard/Views/Components/WhWzLibrary/GridModPanel.axaml.cs diff --git a/WheelWizard/Views/Components/WhWzLibrary/GridModPanel.axaml b/WheelWizard/Views/Components/WhWzLibrary/GridModPanel.axaml new file mode 100644 index 00000000..14fc10b9 --- /dev/null +++ b/WheelWizard/Views/Components/WhWzLibrary/GridModPanel.axaml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 7ab7df22..a7c7ff0a 100644 --- a/WheelWizard/Views/Pages/ModsPage.axaml +++ b/WheelWizard/Views/Pages/ModsPage.axaml @@ -93,7 +93,7 @@ VerticalAlignment="Bottom" /> - + @@ -195,6 +195,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Pages/ModsPage.axaml.cs b/WheelWizard/Views/Pages/ModsPage.axaml.cs index a7f451bb..1c58ecb7 100644 --- a/WheelWizard/Views/Pages/ModsPage.axaml.cs +++ b/WheelWizard/Views/Pages/ModsPage.axaml.cs @@ -104,7 +104,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); @@ -112,7 +113,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); @@ -120,7 +122,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); @@ -128,7 +131,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); @@ -146,6 +150,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); @@ -227,6 +241,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) From 01b2d2520e4d5ac954c1771db63c2fe3449225a0 Mon Sep 17 00:00:00 2001 From: Patchzy <64382339+patchzyy@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:13:25 +0100 Subject: [PATCH 3/3] Update GridModPanel.axaml --- .../Components/WhWzLibrary/GridModPanel.axaml | 100 +++++++++--------- 1 file changed, 52 insertions(+), 48 deletions(-) diff --git a/WheelWizard/Views/Components/WhWzLibrary/GridModPanel.axaml b/WheelWizard/Views/Components/WhWzLibrary/GridModPanel.axaml index 14fc10b9..da766fd9 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/GridModPanel.axaml +++ b/WheelWizard/Views/Components/WhWzLibrary/GridModPanel.axaml @@ -7,14 +7,12 @@ x:DataType="pages:ModListItem"> - + ClipToBounds="True" Margin="4" Height="160"> + - + - + - - + + - + + - + + + + + - + Margin="10,0,10,4" /> - - - - + + + + - - - - - - - - - - - + + + + + + + + + + + +