FModel/FModel/Views/Resources/Converters/UrlToBitmapConverter.cs
Rob Trame 40d8b73ec5
fix: address remaining WPF→Avalonia gaps for phase 2 completion (#85)
* fix: address remaining WPF→Avalonia gaps for phase 2 completion

Resolves r6e/FModel-Linux#84

- Convert Colors.xaml & Icons.xaml to Avalonia xmlns
- Migrate CommitControl to Avalonia (replace DataTriggers with IsVisible)
- Migrate SearchTextBox to Avalonia (StyledProperty, native Watermark)
- Migrate TreeViewItemBehavior to Avalonia attached properties
- Replace ListCollectionView with DataGridCollectionView in 8 ViewModels
- Add Framework/CompositeCollection as WPF CompositeCollection replacement
- Remove AdonisUI.Controls refs from LoadCommand & AddEditDirectoryCommand
- Replace SystemParameters with Avalonia screen API in CUE4ParseViewModel
- Rewrite Resources.xaml for Avalonia Fluent theme (~2150→290 lines)
- Extend CommitMessageConverter with HasDescription parameter support

* fix: address review findings for PR #85

- C1: Fix ToolTip syntax (TextBlock.ToolTip → ToolTip.Tip) in tree view
- C2: Add Fill binding to folder icons via ConverterParameter=Brush
- C3: Convert AddEditDirectoryCommand.ShowDialog() to async with owner
- M1: Convert keyed Style → ControlTheme + Theme= in all consumers
- M2: Fix CommitControl badge overlap (Latest/Current mutual exclusivity)
  Add FallbackValue=False for null-safe Asset.IsLatest binding
- M3: Add TODO(P4-004) markers for OpenFileDialog/SaveFileDialog
- M4: Implement IDisposable on CompositeCollection; dispose on replace
- PR: Add UrlToBitmapConverter for HTTP avatar image loading
- PR: Fix ContextMenu DataContext in GameFilesTabControl ContentTemplate
  Restore 'Disable Alpha Channel' toggle (NoAlpha binding)
- PR: Replace ElementName=TabControlName with direct DataContext bindings
- PR: TreeViewItemBehavior uses GetObservable(IsSelectedProperty) with
  proper IDisposable subscription cleanup
- S1: Clarify UpdateViewModel grouping TODO (non-DataGrid limitation)
- S2: Cache compiled Regex in SearchViewModel.ItemFilter
- m3: Implement TabItemFillSpace with HorizontalAlignment=Stretch

Also updates FolderToGeometryConverter to support ConverterParameter
for explicit brush/geometry selection.

* fix: address review #2 findings on PR #85

- M1/m1/m2: UrlToBitmapConverter — sync download with ConcurrentDictionary
  URL cache, 4-space indent, doc comment noting async limitation
- M2: Remove duplicate IsVisible attribute on Latest badge Border
  (MultiBinding property element takes precedence; attribute was dead code)
- m3: Add TimeSpan.FromSeconds(1) timeout to user-input Regex construction
- PR: ZIndex → Panel.ZIndex in SearchTextBox.xaml (Avalonia attached prop)
- PR: ShadowEffect comment updated to reflect key removal (no consumers)
- PR: CustomSeparator Tag TextBlock hidden when empty via
  StringConverters.IsNotNullOrEmpty (eliminates unwanted 10px margin)
- PR: CustomVerticalSeparator BasedOn changed from default Separator to
  CustomSeparator (preserves tagged template inheritance from WPF)
- PR: AddEditDirectoryCommand — early return when owner is null instead
  of silently skipping dialog
- PR: CompositeCollection.Count — skip non-ICollection sources instead
  of O(n) enumeration fallback

* Fix Avalonia parity gaps in updates, styles, and dialogs

* fix: address parity review follow-ups and unresolved PR comments

* Fix remaining PR review thread regressions

* Address review findings: thread safety, Linux guards, indentation, and cleanup

- [C1] Fix LoadCoAuthors thread race by snapshotting Commits before Task.Run
- [M1] Remove IsAsync=True (WPF-only) from Avalonia bindings
- [M2] Add OperatingSystem.IsWindows() guard for Win32 OpenFileDialog
- [M3] Add null-safety for Application.Current in PlayPause converter
- [N1] Add BrushTransition to HighlightedCheckBox for animation parity
- [N2] Dispose TreeViewItem subscriptions on DetachedFromVisualTree
- [N3] Fix SelectionMode converter: only Multiple uses multi-select
- [S1] Simplify AddEditDirectoryCommand owner-null to early return
- [S3] Remove dead WPF trigger comments from UpdateView.xaml
- [S4] Add 10s timeout to UrlToBitmapConverter HttpClient
- PR: Reformat UrlToBitmapConverter to 4-space indentation
- PR: Catch RegexMatchTimeoutException in SearchViewModel filter

* Fix review round 2: ActualWidth→Bounds, IsAsync removal, FindAncestor xmlns, misc

Critical:
- C1: Replace ActualHeight/ActualWidth with Bounds.Height/Bounds.Width
  in UpdateView, MainWindow, SettingsView, DirectorySelector (Avalonia
  has no ActualWidth/ActualHeight properties)
- C2: Remove remaining IsAsync=True from SearchView.xaml and
  AudioPlayer.xaml (WPF-only, no-op in Avalonia)

Major:
- M1: Add xmlns:views for FModel.Views and replace dotted sub-namespace
  local:Views.SettingsView with views:SettingsView in all 23
  FindAncestor bindings (avoids ambiguous XAML type resolution)
- M2: CompositeCollection.Count now skips non-ICollection sources
  instead of enumerating them (O(1) for current callers)

Minor:
- N1: Add Application.Current null guard in FolderToGeometryConverter
  (matches PlaybackStateToPlayPauseConverter pattern)

Suggestions:
- S1: UrlToBitmapConverter.LoadAsync now cleans up _inFlight on all
  exit paths (defense-in-depth for exceptional failures)
- S2: Add comment in CommitMessageConverter documenting interaction
  with LoadCoAuthors co-author cleanup

* fix: merge resource dicts, regex cache, handler cleanup, null-init

- Merge Colors.xaml, Icons.xaml, Resources.xaml into App.xaml so
  StaticResource/DynamicResource lookups resolve at runtime
- Cache invalid regex state in SearchViewModel to avoid repeated
  recompilation on every filter invocation
- Detach DetachedFromVisualTree handler in TreeViewItemBehavior
  when IsBroughtIntoViewWhenSelected is set to false
- Fix LoadingModeToSelectionModeConverter doc to match behavior
- Initialize Commit/Author backing fields with null! to satisfy
  non-nullable contract before JSON deserialization
- Add Debug.Fail in CompositeCollection.Count for non-ICollection
  sources to surface unexpected usage in development

* fix: load missing resource dictionaries, remove dead style refs, fix using

- App.xaml: add ResourceInclude for FileContextMenu, FolderContextMenu,
  and TiledExplorer/Resources.xaml so StaticResource lookups resolve at
  runtime (C1 fix)
- TiledExplorer/Resources.xaml: remove ContextMenu Setters from both
  TiledExplorer and AssetsListBox styles; Avalonia does not support
  x:Shared="False", so the shared instance would break when two
  ListBoxes reference the same ContextMenu. ListBoxItemBehavior already
  handles assignment programmatically via TryFindResource on right-click.
- SettingsView.xaml: remove 11 references to undefined TextBoxDefaultStyle;
  HotkeyTextBox inherits FluentTheme TextBox styling by default (C2 fix)
- LoadingModeToSelectionModeConverter.cs: add missing 'using FModel' for
  ELoadingMode resolution (M1 fix)

* fix: convert Resources.xaml to Styles root, restore toggle button states

- Resources.xaml: change root from <ResourceDictionary> to <Styles> so
  StyleInclude in App.xaml correctly loads an IStyle-implementing element.
  All keyed resources (ControlThemes, converters) moved into
  <Styles.Resources>; selector-based styles (audio controls, FoldingMargin)
  are direct <Styles> children.
- AssetsExplorerToggleButtonStyle: restore IsChecked-dependent behavior
  using Avalonia selectors. :checked shows FolderIconAlt + accent background,
  :unchecked shows AssetIcon + default background. Tooltip shows hotkey.
- MainWindow.xaml: add Classes="AssetsExplorerToggle" to the toggle button
  so the selector-based checked/unchecked styles match.

* Fix Round 7 review findings: ControlTheme, ToolTip.Tip, dead code

- C1: Convert keyed Styles to ControlTheme in TiledExplorer/Resources.xaml,
  update Style= to Theme= in MainWindow.xaml, convert CustomRichTextBox
  to auto-applying Style Selector in Resources.xaml
- M1: Fix ToolTip= to ToolTip.Tip= across SearchView, SettingsView,
  AudioPlayer, and DirectorySelector (22 occurrences)
- m1: Remove duplicate _inFlight.TryRemove in UrlToBitmapConverter.cs
- m2: Remove dead Result property from CustomDir.xaml.cs
- S2: Remove unused InvertBooleanConverter from Resources.xaml

* Fix PR review findings: image column collapse, thread safety

- Add HasImageToColumnSpanConverter so AvalonEditor spans all columns
  when HasImage is false, preventing unused blank space from the image
  column width (Resources.xaml GameFilesTabControl ContentTemplate)
- Marshal LoadAssets and LoadCoAuthors collection/property mutations to
  Dispatcher.UIThread.InvokeAsync for explicit thread safety

* fix: reformat HasImageToColumnSpanConverter to 4-space indentation

* fix: map All/AllButNew/AllButModified/AllButPatched to multi-select

LoadingModeToSelectionModeConverter only mapped Multiple to
SelectionMode.Multiple. The original WPF DataTriggers also enabled
Extended selection for All, AllButNew, AllButModified, and
AllButPatched modes. Add those mappings to restore parity.
2026-03-16 21:35:16 -06:00

80 lines
2.4 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia.Data.Converters;
using Avalonia.Media.Imaging;
namespace FModel.Views.Resources.Converters;
/// <summary>
/// Converts an HTTP(S) URL string to an Avalonia <see cref="Bitmap"/>.
/// Converts only from cached images and never performs blocking network I/O
/// during binding evaluation.
/// </summary>
/// <remarks>
/// Use <see cref="LoadAsync"/> from ViewModel code to preload and cache images.
/// </remarks>
public class UrlToBitmapConverter : IValueConverter
{
public static readonly UrlToBitmapConverter Instance = new();
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) };
private static readonly ConcurrentDictionary<string, Bitmap?> _cache = new();
private static readonly ConcurrentDictionary<string, Task<Bitmap?>> _inFlight = new();
public static bool TryGetCached(string url, out Bitmap? bitmap)
{
return _cache.TryGetValue(url, out bitmap);
}
public static async Task<Bitmap?> LoadAsync(string url)
{
if (string.IsNullOrWhiteSpace(url))
return null;
if (_cache.TryGetValue(url, out var cached))
return cached;
try
{
return await _inFlight.GetOrAdd(url, DownloadAsync);
}
finally
{
_inFlight.TryRemove(url, out _);
}
}
private static async Task<Bitmap?> DownloadAsync(string url)
{
try
{
using var stream = await _http.GetStreamAsync(url);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
ms.Position = 0;
var bitmap = new Bitmap(ms);
_cache[url] = bitmap;
return bitmap;
}
catch
{
_cache.TryRemove(url, out _);
return null;
}
}
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not string url || string.IsNullOrWhiteSpace(url))
return null;
return _cache.TryGetValue(url, out var cached) ? cached : null;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotImplementedException();
}