diff --git a/src/UniGetUI.Avalonia/App.axaml.cs b/src/UniGetUI.Avalonia/App.axaml.cs index 8c515cb688..1151dc43e2 100644 --- a/src/UniGetUI.Avalonia/App.axaml.cs +++ b/src/UniGetUI.Avalonia/App.axaml.cs @@ -14,6 +14,7 @@ using UniGetUI.Avalonia.Assets.Styles; using UniGetUI.Avalonia.Infrastructure; using UniGetUI.Avalonia.Views; +using UniGetUI.Avalonia.Views.Controls; using UniGetUI.Avalonia.Views.DialogPages; using UniGetUI.Core.Data; using UniGetUI.Core.Logging; @@ -29,6 +30,7 @@ public override void Initialize() AvaloniaXamlLoader.Load(this); ButtonActivationGuard.Install(); + SmoothScrollManager.Install(); // Windows 11 Mica look is opt-in per environment: only merge the translucent // surface overrides when Mica is actually usable (Win11 + transparency on). diff --git a/src/UniGetUI.Avalonia/Models/PackageCollections.cs b/src/UniGetUI.Avalonia/Models/PackageCollections.cs index f7449739bc..dc40bfe056 100644 --- a/src/UniGetUI.Avalonia/Models/PackageCollections.cs +++ b/src/UniGetUI.Avalonia/Models/PackageCollections.cs @@ -29,9 +29,12 @@ public sealed class PackageWrapper : INotifyPropertyChanged, IDisposable Timeout = TimeSpan.FromSeconds(8), }; private static readonly SemaphoreSlim _iconLoadSemaphore = new(8, 8); + private static readonly object _inflightIconLoadsLock = new(); + private static readonly Dictionary> _inflightIconLoads = new(); // Cap decoded icon size; the list shows icons at ≤64px (128 covers 2x DPI). private const int MaxIconSide = 128; + private const int MaxIconDownloadBytes = 2 * 1024 * 1024; // Bounded LRU by package hash. Evicted entries aren't disposed: a visible row may still // reference the bitmap, so dropping it here only makes it GC-eligible. @@ -163,18 +166,26 @@ public PackageWrapper(IPackage package, PackagesPageViewModel page) Package.PropertyChanged += Package_PropertyChanged; UpdateDisplayState(); - // Icons load lazily per visible row (see EnsureIconLoaded), not eagerly for every result. + // Icons are normally preloaded after the result set completes. The visible-row hook remains + // as a fallback while results are still arriving. MaybeStartInstallerHostCheck(); } - private int _iconLoadStarted; + private readonly object _iconLoadLock = new(); + private Task? _iconLoadTask; - /// Loads this row's icon at most once; called when the row becomes visible. + /// Loads this row's icon at most once; also called when the row becomes visible. public void EnsureIconLoaded() { - if (Settings.Get(Settings.K.DisableIconsOnPackageLists)) return; - if (Interlocked.Exchange(ref _iconLoadStarted, 1) != 0) return; - _ = LoadIconAsync(); + _ = EnsureIconLoadedAsync(); + } + + /// Loads this row's icon at most once and returns the shared load operation. + public Task EnsureIconLoadedAsync() + { + if (Settings.Get(Settings.K.DisableIconsOnPackageLists)) return Task.CompletedTask; + lock (_iconLoadLock) + return _iconLoadTask ??= LoadIconAsync(); } /// @@ -263,49 +274,97 @@ private async Task LoadIconAsync() try { - await _iconLoadSemaphore.WaitAsync(token).ConfigureAwait(false); - Bitmap bitmap; - try + Bitmap? bitmap = await GetSharedIconLoad(hash, Package).WaitAsync(token).ConfigureAwait(false); + if (bitmap is null) return; + + if (token.IsCancellationRequested) return; + await Dispatcher.UIThread.InvokeAsync(() => { - var uri = await Task.Run(Package.GetIconUrlIfAny, token).ConfigureAwait(false); - if (uri is null) { CacheIcon(hash, null); return; } + if (!token.IsCancellationRequested) IconBitmap = bitmap; + }); + } + catch (OperationCanceledException) { /* row discarded before its icon finished loading */ } + catch { CacheIcon(hash, null); } + } - Bitmap? decoded; - if (uri.IsFile) + private static Task GetSharedIconLoad(long hash, IPackage package) + { + lock (_inflightIconLoadsLock) + { + if (TryGetCachedIcon(hash, out Bitmap? cached)) + return Task.FromResult(cached); + if (_inflightIconLoads.TryGetValue(hash, out Task? existing)) + return existing; + + Task task = LoadAndCacheIconAsync(hash, package); + _inflightIconLoads[hash] = task; + _ = RemoveInflightIconLoadAsync(hash, task); + return task; + } + } + + private static async Task RemoveInflightIconLoadAsync(long hash, Task task) + { + try { await task.ConfigureAwait(false); } + finally + { + lock (_inflightIconLoadsLock) + { + if (_inflightIconLoads.TryGetValue(hash, out Task? current) + && ReferenceEquals(current, task)) + _inflightIconLoads.Remove(hash); + } + } + } + + private static async Task LoadAndCacheIconAsync(long hash, IPackage package) + { + await _iconLoadSemaphore.WaitAsync().ConfigureAwait(false); + try + { + var uri = await Task.Run(package.GetIconUrlIfAny).ConfigureAwait(false); + if (uri is null) { CacheIcon(hash, null); return null; } + + Bitmap? decoded; + if (uri.IsFile) + { + if (!IsSkiaDecodableExtension(uri.LocalPath)) { - if (!IsSkiaDecodableExtension(uri.LocalPath)) - { - // Avalonia's Bitmap (Skia) can't decode SVG/AVIF/ICO/TIFF — the - // icon cache may produce those. Reject upfront so we don't throw. - CacheIcon(hash, null); - return; - } - decoded = await Task.Run(() => TryDecodeIcon(uri.LocalPath), token).ConfigureAwait(false); + CacheIcon(hash, null); + return null; } - else if (uri.Scheme is "http" or "https") + decoded = await Task.Run(() => TryDecodeIcon(uri.LocalPath)).ConfigureAwait(false); + } + else if (uri.Scheme is "http" or "https") + { + using var response = await _iconHttpClient.GetAsync( + uri, + HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + if (response.Content.Headers.ContentLength > MaxIconDownloadBytes) { - var bytes = await _iconHttpClient.GetByteArrayAsync(uri, token).ConfigureAwait(false); - decoded = TryDecodeIcon(bytes, uri.Host); + CacheIcon(hash, null); + return null; } - else { CacheIcon(hash, null); return; } - if (decoded is null) { CacheIcon(hash, null); return; } - bitmap = decoded; - CacheIcon(hash, bitmap); - } - finally - { - _iconLoadSemaphore.Release(); + await response.Content.LoadIntoBufferAsync(MaxIconDownloadBytes).ConfigureAwait(false); + var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + decoded = TryDecodeIcon(bytes, uri.Host); } + else { CacheIcon(hash, null); return null; } - if (token.IsCancellationRequested) return; - await Dispatcher.UIThread.InvokeAsync(() => - { - if (!token.IsCancellationRequested) IconBitmap = bitmap; - }); + CacheIcon(hash, decoded); + return decoded; + } + catch + { + CacheIcon(hash, null); + return null; + } + finally + { + _iconLoadSemaphore.Release(); } - catch (OperationCanceledException) { /* row discarded before its icon finished loading */ } - catch { CacheIcon(hash, null); } } // Icons come from a shared on-disk cache that can hold empty or partial entries after an @@ -326,28 +385,10 @@ await Dispatcher.UIThread.InvokeAsync(() => catch (Exception ex) { Logger.Debug($"Discarding undecodable icon '{source}': {ex.Message}"); return null; } } - // Downscales oversized icons to MaxIconSide; small icons pass through (never upscaled). + // Decode directly at the display-cache width. This avoids allocating a full-size bitmap first, + // which is important for untrusted or unusually large package artwork. private static Bitmap DecodeDownscaled(Stream stream) - { - var bitmap = new Bitmap(stream); - PixelSize size = bitmap.PixelSize; - if (size.Width <= MaxIconSide && size.Height <= MaxIconSide) - return bitmap; - - double scale = (double)MaxIconSide / Math.Max(size.Width, size.Height); - var target = new PixelSize( - Math.Max(1, (int)Math.Round(size.Width * scale)), - Math.Max(1, (int)Math.Round(size.Height * scale))); - - try - { - return bitmap.CreateScaledBitmap(target, BitmapInterpolationMode.HighQuality); - } - finally - { - bitmap.Dispose(); - } - } + => Bitmap.DecodeToWidth(stream, MaxIconSide, BitmapInterpolationMode.HighQuality); private static bool IsSkiaDecodableExtension(string path) { diff --git a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs index 7b8fecbbfd..bd74b0ece6 100644 --- a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs @@ -103,6 +103,7 @@ public bool IsExpanded public partial class PackagesPageViewModel : ViewModelBase { + private const int MaximumPreloadedIcons = 512; // Live width of the filter pane. Code-behind keeps this in sync with the GridSplitter // so the toolbar's main button (bound to FilterPaneColumnWidth) tracks resizes. private double _trackedFilterPaneWidth = 220.0; @@ -204,6 +205,7 @@ partial void OnIsFilterPaneOpenChanged(bool value) public string QueryBackup { get; set; } = ""; private readonly ObservableCollection _wrappedPackages = new(); + private CancellationTokenSource? _iconPreloadCts; protected List UsedManagers = []; protected ConcurrentDictionary> UsedSourcesForManager = new(); protected ConcurrentDictionary RootNodeForManager = new(); @@ -473,9 +475,37 @@ private void Loader_FinishedLoading(object? sender, EventArgs e) _lastLoadTime = DateTime.Now; ReloadButtonTooltip = CoreTools.Translate("Last checked: {0}", _lastLoadTime.ToString(CultureInfo.CurrentCulture)); FilterPackages(); + _iconPreloadCts?.Cancel(); + _iconPreloadCts?.Dispose(); + _iconPreloadCts = new CancellationTokenSource(); + _ = PreloadPackageIconsAsync( + FilteredPackages.Take(MaximumPreloadedIcons).ToArray(), + _iconPreloadCts.Token); PackagesLoaded?.Invoke(ReloadReason.External); } + private static async Task PreloadPackageIconsAsync( + PackageWrapper[] wrappers, + CancellationToken cancellationToken) + { + try + { + // Leave half the global icon-loader slots free for rows that become visible immediately. + const int batchSize = 4; + for (int start = 0; start < wrappers.Length; start += batchSize) + { + cancellationToken.ThrowIfCancellationRequested(); + int count = Math.Min(batchSize, wrappers.Length - start); + var tasks = new Task[count]; + for (int index = 0; index < count; index++) + tasks[index] = wrappers[start + index].EnsureIconLoadedAsync(); + + await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) { } + } + private void Loader_StartedLoading(object? sender, EventArgs e) { if (!Dispatcher.UIThread.CheckAccess()) @@ -484,6 +514,7 @@ private void Loader_StartedLoading(object? sender, EventArgs e) return; } IsLoading = true; + _iconPreloadCts?.Cancel(); UpdateSubtitle(); } diff --git a/src/UniGetUI.Avalonia/Views/Controls/DataGridWheelAnimator.cs b/src/UniGetUI.Avalonia/Views/Controls/DataGridWheelAnimator.cs deleted file mode 100644 index 398ad3ea6f..0000000000 --- a/src/UniGetUI.Avalonia/Views/Controls/DataGridWheelAnimator.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Runtime.CompilerServices; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Input; -using Avalonia.Interactivity; -using UniGetUI.Avalonia.Infrastructure; - -namespace UniGetUI.Avalonia.Views.Controls; - -/// -/// Eases DataGrid wheel scrolling to a stop (WinUI-like) instead of jumping the whole delta at once. -/// The grid has no public scroll offset, so we statically bind its internal UpdateScroll method. -/// The binding is NativeAOT-safe but must be revalidated when Avalonia DataGrid is upgraded. -/// -public sealed class DataGridWheelAnimator -{ - private const double WheelStep = 70.0; // pixels travelled per notch; larger = faster scroll, more to glide over - private const double Tau = 0.12; // ease time constant in seconds; larger = longer, more visible glide - - private readonly DataGrid _grid; - private double _pending; - private TimeSpan? _lastFrame; - private bool _frameRequested; - - private DataGridWheelAnimator(DataGrid grid) - { - _grid = grid; - grid.AddHandler(InputElement.PointerWheelChangedEvent, OnWheel, RoutingStrategies.Tunnel); - } - - public static void Attach(DataGrid grid) - { - _ = new DataGridWheelAnimator(grid); - } - - private void OnWheel(object? sender, PointerWheelEventArgs e) - { - // Fall back to the native instant scroll for horizontal/shift, or when reduced motion is on. - if (e.Delta.Y == 0 || e.KeyModifiers == KeyModifiers.Shift || MotionPreference.ReducedMotion) return; - - if (_pending == 0) _lastFrame = null; // fresh gesture: don't carry a stale timestamp - _pending += e.Delta.Y * WheelStep; - e.Handled = true; - RequestFrame(); - } - - private void RequestFrame() - { - if (_frameRequested) return; - if (TopLevel.GetTopLevel(_grid) is not { } top) { _pending = 0; return; } - _frameRequested = true; - top.RequestAnimationFrame(OnFrame); - } - - private void OnFrame(TimeSpan now) - { - _frameRequested = false; - if (_pending == 0) return; - - double dt = _lastFrame is { } last ? (now - last).TotalSeconds : 1.0 / 60.0; - _lastFrame = now; - if (dt <= 0) dt = 1.0 / 60.0; - if (dt > 0.1) dt = 0.1; // clamp after a stall so the glide doesn't lurch - - double remaining = _pending * Math.Exp(-dt / Tau); - double step = Math.Abs(remaining) < 0.5 ? _pending : _pending - remaining; - - bool scrolled = UpdateScroll(_grid, new Vector(0, step)); - _pending -= step; - - if (!scrolled) { _pending = 0; return; } - if (_pending != 0) RequestFrame(); - } - - [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "UpdateScroll")] - private static extern bool UpdateScroll(DataGrid grid, Vector offset); -} diff --git a/src/UniGetUI.Avalonia/Views/Controls/SmoothScrollManager.cs b/src/UniGetUI.Avalonia/Views/Controls/SmoothScrollManager.cs new file mode 100644 index 0000000000..aa6cba3a49 --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/SmoothScrollManager.cs @@ -0,0 +1,190 @@ +using System; +using System.Runtime.CompilerServices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; +using UniGetUI.Avalonia.Infrastructure; + +namespace UniGetUI.Avalonia.Views.Controls; + +/// +/// Applies velocity-based wheel inertia to every scroll host in the application. Registration is +/// performed once at the TopLevel class-handler level, so dynamically created windows and controls +/// participate without per-view wiring. +/// +public sealed class SmoothScrollManager +{ + public static readonly AttachedProperty IsEnabledProperty = + AvaloniaProperty.RegisterAttached( + "IsEnabled", + defaultValue: true, + inherits: true); + + private const double MaximumFrameTime = 1.0 / 30.0; + private const double StopVelocity = 4.0; + + private static readonly ConditionalWeakTable _animators = new(); + private static IDisposable? _classHandler; + + private readonly Control _target; + private Vector _velocity; + private TimeSpan? _lastFrame; + private bool _frameRequested; + + private SmoothScrollManager(Control target) + { + _target = target; + } + + public static void Install() + { + _classHandler ??= InputElement.PointerWheelChangedEvent.AddClassHandler( + OnTopLevelWheel, + RoutingStrategies.Tunnel); + } + + public static bool GetIsEnabled(Control control) => control.GetValue(IsEnabledProperty); + public static void SetIsEnabled(Control control, bool value) => control.SetValue(IsEnabledProperty, value); + + private static void OnTopLevelWheel(TopLevel topLevel, PointerWheelEventArgs e) + { + // Modified wheel gestures may have control-specific meanings such as zooming. + if (e.Delta == default || e.KeyModifiers != KeyModifiers.None || MotionPreference.ReducedMotion) return; + if (e.Source is not Visual source || HasNativeWheelInteraction(source)) return; + Control? sourceControl = source.FindAncestorOfType(includeSelf: true); + if (sourceControl is null || !GetIsEnabled(sourceControl)) return; + + // DataGrid implements scrolling itself rather than through an ancestor ScrollViewer. + // Resolve it first to preserve the package list's virtualization-aware inertia path. + if (source.FindAncestorOfType(includeSelf: true) is { } grid) + { + _animators.GetValue(grid, static control => new(control)).AddImpulse(e.Delta); + e.Handled = true; + return; + } + + ScrollViewer? horizontalTarget = FindScrollTarget(source, e.Delta.X, horizontal: true); + ScrollViewer? verticalTarget = FindScrollTarget(source, e.Delta.Y, horizontal: false); + if (horizontalTarget is null && verticalTarget is null) return; + + if (horizontalTarget is not null && ReferenceEquals(horizontalTarget, verticalTarget)) + { + _animators.GetValue(horizontalTarget, static control => new(control)).AddImpulse(e.Delta); + } + else + { + if (horizontalTarget is not null) + _animators.GetValue(horizontalTarget, static control => new(control)) + .AddImpulse(new Vector(e.Delta.X, 0)); + if (verticalTarget is not null) + _animators.GetValue(verticalTarget, static control => new(control)) + .AddImpulse(new Vector(0, e.Delta.Y)); + } + e.Handled = true; + } + + private void AddImpulse(Vector delta) + { + if (_velocity == default) _lastFrame = null; // fresh gesture: don't carry a stale timestamp + (double x, double y) = SmoothScrollPhysics.AddImpulse( + _velocity.X, _velocity.Y, delta.X, delta.Y); + _velocity = new Vector(x, y); + RequestFrame(); + } + + private static bool HasNativeWheelInteraction(Visual source) + { + for (Visual? current = source; current is not null; current = current.GetVisualParent()) + { + if (current is ScrollContentPresenter) return false; + if (current is ComboBox or ButtonSpinner or ScrollBar or CalendarDatePicker or Calendar) + return true; + } + return false; + } + + private static ScrollViewer? FindScrollTarget(Visual source, double delta, bool horizontal) + { + if (delta == 0) return null; + for (Visual? current = source; current is not null; current = current.GetVisualParent()) + { + if (current is not ScrollViewer viewer) continue; + if (CanScroll(viewer, delta, horizontal)) return viewer; + if (!viewer.IsScrollChainingEnabled) return null; + } + return null; + } + + private static bool CanScroll(ScrollViewer viewer, double delta, bool horizontal) + { + double offset = horizontal ? viewer.Offset.X : viewer.Offset.Y; + double extent = horizontal ? viewer.Extent.Width : viewer.Extent.Height; + double viewport = horizontal ? viewer.Viewport.Width : viewer.Viewport.Height; + double maximum = Math.Max(0, extent - viewport); + return delta > 0 ? offset > 0 : offset < maximum; + } + + private void RequestFrame() + { + if (_frameRequested) return; + if (TopLevel.GetTopLevel(_target) is not { } top) { Stop(); return; } + _frameRequested = true; + top.RequestAnimationFrame(OnFrame); + } + + private void OnFrame(TimeSpan now) + { + _frameRequested = false; + if (_velocity == default) return; + + double dt = _lastFrame is { } last ? (now - last).TotalSeconds : 1.0 / 60.0; + _lastFrame = now; + if (dt <= 0) dt = 1.0 / 60.0; + dt = Math.Min(dt, MaximumFrameTime); + + // Integrate the exponential velocity curve over the frame. This makes travel independent + // of refresh rate, unlike applying a fixed fraction on every animation callback. + var frame = SmoothScrollPhysics.Integrate(_velocity.X, _velocity.Y, dt); + var step = new Vector(frame.StepX, frame.StepY); + + bool scrolled = ScrollBy(step); + _velocity = new Vector(frame.VelocityX, frame.VelocityY); + + if (!scrolled || (_velocity.X * _velocity.X + _velocity.Y * _velocity.Y) < StopVelocity * StopVelocity) + { + Stop(); + return; + } + RequestFrame(); + } + + private bool ScrollBy(Vector step) + { + if (_target is DataGrid grid) + return UpdateDataGridScroll(grid, step); + + var viewer = (ScrollViewer)_target; + Vector oldOffset = viewer.Offset; + double maximumX = Math.Max(0, viewer.Extent.Width - viewer.Viewport.Width); + double maximumY = Math.Max(0, viewer.Extent.Height - viewer.Viewport.Height); + double x = Math.Clamp(oldOffset.X - step.X, 0, maximumX); + double y = Math.Clamp(oldOffset.Y - step.Y, 0, maximumY); + if (x == oldOffset.X && y == oldOffset.Y) return false; + + viewer.Offset = new Vector(x, y); + return true; + } + + private void Stop() + { + _velocity = default; + _lastFrame = null; + } + + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "UpdateScroll")] + private static extern bool UpdateDataGridScroll(DataGrid grid, Vector offset); +} diff --git a/src/UniGetUI.Avalonia/Views/Controls/SmoothScrollPhysics.cs b/src/UniGetUI.Avalonia/Views/Controls/SmoothScrollPhysics.cs new file mode 100644 index 0000000000..82d0f2fc00 --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/SmoothScrollPhysics.cs @@ -0,0 +1,44 @@ +using System; + +namespace UniGetUI.Avalonia.Views.Controls; + +internal static class SmoothScrollPhysics +{ + internal const double DecayTime = 0.15; + private const double WheelDistance = 48.0; + private const double WheelVelocityImpulse = WheelDistance / DecayTime; + private const double MaximumVelocity = 7200.0; + + internal static (double X, double Y) AddImpulse( + double velocityX, + double velocityY, + double deltaX, + double deltaY) + => ( + AddAxisImpulse(velocityX, deltaX), + AddAxisImpulse(velocityY, deltaY)); + + internal static (double StepX, double StepY, double VelocityX, double VelocityY) Integrate( + double velocityX, + double velocityY, + double elapsedSeconds) + { + double decay = Math.Exp(-elapsedSeconds / DecayTime); + double distanceFactor = DecayTime * (1.0 - decay); + return ( + velocityX * distanceFactor, + velocityY * distanceFactor, + velocityX * decay, + velocityY * decay); + } + + private static double AddAxisImpulse(double velocity, double delta) + { + if (delta == 0) return velocity; + if (velocity != 0 && Math.Sign(velocity) != Math.Sign(delta)) velocity = 0; + return Math.Clamp( + velocity + delta * WheelVelocityImpulse, + -MaximumVelocity, + MaximumVelocity); + } +} diff --git a/src/UniGetUI.Avalonia/Views/Pages/LogPages/OperationHistoryPage.axaml.cs b/src/UniGetUI.Avalonia/Views/Pages/LogPages/OperationHistoryPage.axaml.cs index 3b789225c3..3bda9aec82 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/LogPages/OperationHistoryPage.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/Pages/LogPages/OperationHistoryPage.axaml.cs @@ -32,9 +32,6 @@ public OperationHistoryPage() col.CustomSortComparer = new OperationHistoryRowComparer(key); } - // Ease wheel scrolling to a stop (WinUI-like), matching the package list. - DataGridWheelAnimator.Attach(HistoryList); - // Right-click a row → the same actions as the inline buttons. HistoryList.ContextRequested += OnRowContextRequested; // Double-click → open the log; Enter/Delete keyboard shortcuts on the list. diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs index 5368116446..a089b20bdb 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs @@ -122,9 +122,6 @@ or nameof(PackagesPageViewModel.SortAscending)) // redirect focus + the typed character to the global search box. PackageList.TextInput += PackageList_TextInput; - // Ease wheel scrolling to a stop instead of jumping per notch (WinUI-like feel). - DataGridWheelAnimator.Attach(PackageList); - // Snap-close when splitter is dragged below the minimum (inline mode only). // Using ColumnDefinition.WidthProperty fires every drag step, not just on release. FilteringPanel.ColumnDefinitions[0]