From 3a5ff07cbd20eeae602761ca3b8b2aca7987eff7 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Wed, 8 Jul 2026 14:52:25 -0400 Subject: [PATCH 1/2] Fix laggy DataGrid wheel scrolling by driving the ease off compositor frames The wheel-scroll easing ran on a fixed-rate DispatcherTimer whose ticks are not aligned to the display refresh, so motion juddered on the Installed/Updates lists where per-row background work keeps the UI thread busy. Advance the ease from TopLevel.RequestAnimationFrame (vsync-aligned) with time-based decay so it stays smooth at any refresh rate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Views/Controls/DataGridWheelAnimator.cs | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/UniGetUI.Avalonia/Views/Controls/DataGridWheelAnimator.cs b/src/UniGetUI.Avalonia/Views/Controls/DataGridWheelAnimator.cs index 201bd36ec..d9eb2f313 100644 --- a/src/UniGetUI.Avalonia/Views/Controls/DataGridWheelAnimator.cs +++ b/src/UniGetUI.Avalonia/Views/Controls/DataGridWheelAnimator.cs @@ -4,7 +4,6 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; -using Avalonia.Threading; using UniGetUI.Avalonia.Infrastructure; namespace UniGetUI.Avalonia.Views.Controls; @@ -19,18 +18,18 @@ public sealed class DataGridWheelAnimator private static readonly MethodInfo? UpdateScroll = typeof(DataGrid).GetMethod("UpdateScroll", BindingFlags.Instance | BindingFlags.NonPublic); - private const double WheelStep = 50.0; // matches the DataGrid's own per-notch pixel amount - private const double EasePerFrame = 0.4; // fraction of the remaining distance consumed each frame; higher = snappier + 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 readonly DispatcherTimer _timer; private readonly object?[] _args = new object?[1]; private double _pending; + private TimeSpan? _lastFrame; + private bool _frameRequested; private DataGridWheelAnimator(DataGrid grid) { _grid = grid; - _timer = new DispatcherTimer(TimeSpan.FromMilliseconds(1000.0 / 90), DispatcherPriority.Render, OnTick); grid.AddHandler(InputElement.PointerWheelChangedEvent, OnWheel, RoutingStrategies.Tunnel); } @@ -44,24 +43,38 @@ 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; - if (!_timer.IsEnabled) _timer.Start(); + RequestFrame(); } - private void OnTick(object? sender, EventArgs e) + private void RequestFrame() { - double step = _pending * EasePerFrame; - if (Math.Abs(step) < 2.0) step = _pending; + 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; _args[0] = new Vector(0, step); bool scrolled = UpdateScroll!.Invoke(_grid, _args) is true; _pending -= step; - if (!scrolled || _pending == 0) - { - _pending = 0; - _timer.Stop(); - } + if (!scrolled) { _pending = 0; return; } + if (_pending != 0) RequestFrame(); } } From d8e49d81a0938495e62cce96517fe3cf23572d21 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Wed, 8 Jul 2026 15:31:41 -0400 Subject: [PATCH 2/2] Skip undecodable icon files instead of throwing --- .../Models/PackageCollections.cs | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/UniGetUI.Avalonia/Models/PackageCollections.cs b/src/UniGetUI.Avalonia/Models/PackageCollections.cs index 59c04fab6..b008c68bc 100644 --- a/src/UniGetUI.Avalonia/Models/PackageCollections.cs +++ b/src/UniGetUI.Avalonia/Models/PackageCollections.cs @@ -200,6 +200,7 @@ private async Task LoadIconAsync() var uri = await Task.Run(Package.GetIconUrlIfAny).ConfigureAwait(false); if (uri is null) { _iconCache[hash] = null; return; } + Bitmap? decoded; if (uri.IsFile) { if (!IsSkiaDecodableExtension(uri.LocalPath)) @@ -209,16 +210,17 @@ private async Task LoadIconAsync() _iconCache[hash] = null; return; } - bitmap = await Task.Run(() => new Bitmap(uri.LocalPath)).ConfigureAwait(false); + decoded = await Task.Run(() => TryDecodeIcon(uri.LocalPath)).ConfigureAwait(false); } else if (uri.Scheme is "http" or "https") { var bytes = await _iconHttpClient.GetByteArrayAsync(uri).ConfigureAwait(false); - using var ms = new MemoryStream(bytes); - bitmap = new Bitmap(ms); + decoded = TryDecodeIcon(bytes, uri.Host); } else { _iconCache[hash] = null; return; } + if (decoded is null) { _iconCache[hash] = null; return; } + bitmap = decoded; _iconCache[hash] = bitmap; } finally @@ -231,6 +233,24 @@ private async Task LoadIconAsync() catch { _iconCache[hash] = null; } } + // Icons come from a shared on-disk cache that can hold empty or partial entries after an + // interrupted download; decoding those throws. Skip them quietly instead of surfacing an error. + private static Bitmap? TryDecodeIcon(string filePath) + { + var info = new FileInfo(filePath); + if (!info.Exists || info.Length == 0) return null; + return TryDecodeIcon(() => new Bitmap(filePath), filePath); + } + + private static Bitmap? TryDecodeIcon(byte[] bytes, string source) + => bytes.Length == 0 ? null : TryDecodeIcon(() => new Bitmap(new MemoryStream(bytes)), source); + + private static Bitmap? TryDecodeIcon(Func decode, string source) + { + try { return decode(); } + catch (Exception ex) { Logger.Debug($"Discarding undecodable icon '{source}': {ex.Message}"); return null; } + } + private static bool IsSkiaDecodableExtension(string path) { string ext = Path.GetExtension(path);