Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions src/UniGetUI.Avalonia/Models/PackageCollections.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@
/// See issue #4617 — defense-in-depth signal that an upgrade may be redirecting the
/// download to a different domain than the user originally trusted.
/// </summary>
private void MaybeStartInstallerHostCheck()

Check warning on line 117 in src/UniGetUI.Avalonia/Models/PackageCollections.cs

View workflow job for this annotation

GitHub Actions / Linux (Avalonia)

Member 'MaybeStartInstallerHostCheck' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
{
#if WINDOWS
if (!Package.IsUpgradable) return;
Expand Down Expand Up @@ -200,6 +200,7 @@
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))
Expand All @@ -209,16 +210,17 @@
_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
Expand All @@ -231,6 +233,24 @@
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<Bitmap> 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);
Expand Down
41 changes: 27 additions & 14 deletions src/UniGetUI.Avalonia/Views/Controls/DataGridWheelAnimator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}

Expand All @@ -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();
}
}
Loading