From c30858b0001b6efc187b4dcdb6ec3a60554e4b6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 5 Oct 2026 19:25:36 +0300 Subject: [PATCH 1/4] Linux perf: ReadyToRun publish, indexed MIME glob lookup, cached MIME XML and icon names Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- src/Files.App/Files.App.csproj | 3 + .../Desktop/Services/LinuxIconHelper.cs | 6 +- .../Mime/LinuxMimeTypeService.cs | 123 ++++++++++++++---- 3 files changed, 103 insertions(+), 29 deletions(-) diff --git a/src/Files.App/Files.App.csproj b/src/Files.App/Files.App.csproj index cbd8f4a4ddf0..abbec46dabe2 100644 --- a/src/Files.App/Files.App.csproj +++ b/src/Files.App/Files.App.csproj @@ -59,6 +59,9 @@ true $(DefineConstants);DESKTOP + + true + false diff --git a/src/Files.App/Platforms/Desktop/Services/LinuxIconHelper.cs b/src/Files.App/Platforms/Desktop/Services/LinuxIconHelper.cs index 52ccf6a81af0..17f5a63a7b13 100644 --- a/src/Files.App/Platforms/Desktop/Services/LinuxIconHelper.cs +++ b/src/Files.App/Platforms/Desktop/Services/LinuxIconHelper.cs @@ -19,6 +19,7 @@ namespace Files.App.Utils.Storage internal static class LinuxIconHelper { private static readonly ConcurrentDictionary<(string Name, uint Size), byte[]?> _themeCache = new(); + private static readonly ConcurrentDictionary _mimeIconNames = new(StringComparer.Ordinal); private static IThumbnailService Thumbnails => Ioc.Default.GetRequiredService(); private static IIconThemeProvider Theme => Ioc.Default.GetRequiredService(); @@ -68,9 +69,12 @@ private static async Task> GetIconNamesAsync(string? path, } var mimeType = await Mime.GetMimeTypeAsync(path); + if (_mimeIconNames.TryGetValue(mimeType, out var cached)) + return cached; + var iconName = await Mime.GetIconNameAsync(mimeType); var generic = await Mime.GetGenericIconNameAsync(mimeType); - return [iconName, generic, "text-x-generic", "unknown"]; + return _mimeIconNames[mimeType] = [iconName, generic, "text-x-generic", "unknown"]; } private static string? GetSpecialFolderIconName(string? path) diff --git a/src/Files.Platform.Linux/Mime/LinuxMimeTypeService.cs b/src/Files.Platform.Linux/Mime/LinuxMimeTypeService.cs index 8084d3f38107..3a2df6c4abf1 100644 --- a/src/Files.Platform.Linux/Mime/LinuxMimeTypeService.cs +++ b/src/Files.Platform.Linux/Mime/LinuxMimeTypeService.cs @@ -3,6 +3,7 @@ using Files.Platform.Abstractions.Mime; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -30,9 +31,91 @@ public sealed class LinuxMimeTypeService : IMimeTypeService private readonly XdgDirectories xdg; private readonly CultureInfo culture; - private readonly Lazy globs; + private readonly Lazy globs; + private readonly ConcurrentDictionary mimeXmlCache = new(StringComparer.Ordinal); - private sealed record GlobEntry(int Weight, string MimeType, string Pattern, bool CaseSensitive, bool IsLiteral); + private sealed record GlobEntry(int Order, int Weight, string MimeType, string Pattern, string LowerPattern, bool CaseSensitive, bool IsLiteral) + { + /// Gets whether this entry outranks : higher weight, then longer pattern, then earlier in the database. + public bool Beats(GlobEntry? other) => + other is null || Weight > other.Weight || + (Weight == other.Weight && (Pattern.Length > other.Pattern.Length || (Pattern.Length == other.Pattern.Length && Order < other.Order))); + } + + /// + /// Globs grouped so a lookup touches only plausible entries: exact names and plain "*suffix" patterns are hashed, the rest are scanned. + /// + private sealed class GlobIndex + { + private readonly Dictionary> literalsCs = new(StringComparer.Ordinal); + private readonly Dictionary> literalsCi = new(StringComparer.Ordinal); + private readonly Dictionary> suffixesCs = new(StringComparer.Ordinal); + private readonly Dictionary> suffixesCi = new(StringComparer.Ordinal); + private readonly List complex = []; + + public GlobIndex(IEnumerable entries) + { + foreach (var entry in entries) + { + if (entry.IsLiteral) + { + Add(entry.CaseSensitive ? literalsCs : literalsCi, entry.CaseSensitive ? entry.Pattern : entry.LowerPattern, entry); + } + else if (entry.Pattern.Length > 1 && entry.Pattern[0] == '*' && entry.Pattern.AsSpan(1).IndexOfAny("*?[\\") < 0) + { + Add(entry.CaseSensitive ? suffixesCs : suffixesCi, entry.CaseSensitive ? entry.Pattern[1..] : entry.LowerPattern[1..], entry); + } + else + { + complex.Add(entry); + } + } + } + + private static void Add(Dictionary> map, string key, GlobEntry entry) + { + if (!map.TryGetValue(key, out var list)) + map[key] = list = []; + list.Add(entry); + } + + public string? Match(string fileName, string lower) + { + GlobEntry? best = null; + + void Consider(Dictionary> map, ReadOnlySpan key) + { + if (map.GetAlternateLookup>().TryGetValue(key, out var list)) + { + foreach (var entry in list) + { + if (entry.Beats(best)) + best = entry; + } + } + } + + Consider(literalsCs, fileName); + Consider(literalsCi, lower); + for (var i = 0; i < fileName.Length; i++) + { + Consider(suffixesCs, fileName.AsSpan(i)); + Consider(suffixesCi, lower.AsSpan(i)); + } + + foreach (var glob in complex) + { + if (!glob.Beats(best)) + continue; + + var matches = glob.CaseSensitive ? GlobMatch(glob.Pattern, fileName) : GlobMatch(glob.LowerPattern, lower); + if (matches) + best = glob; + } + + return best?.MimeType; + } + } /// /// Creates the service for the process environment and current UI culture. @@ -48,7 +131,7 @@ public LinuxMimeTypeService(XdgDirectories xdg, CultureInfo culture) { this.xdg = xdg; this.culture = culture; - globs = new(LoadGlobs); + globs = new(() => new GlobIndex(LoadGlobs())); } /// @@ -124,26 +207,7 @@ public Task GetGenericIconNameAsync(string mimeType, CancellationToken c if (fileName.Length == 0) return null; - var lower = fileName.ToLowerInvariant(); - GlobEntry? best = null; - - foreach (var glob in globs.Value) - { - var name = glob.CaseSensitive ? fileName : lower; - var pattern = glob.CaseSensitive ? glob.Pattern : glob.Pattern.ToLowerInvariant(); - var matches = glob.IsLiteral ? name == pattern : GlobMatch(pattern, name); - if (!matches) - continue; - - if (best is null - || glob.Weight > best.Weight - || (glob.Weight == best.Weight && glob.Pattern.Length > best.Pattern.Length)) - { - best = glob; - } - } - - return best?.MimeType; + return globs.Value.Match(fileName, fileName.ToLowerInvariant()); } /// @@ -256,7 +320,7 @@ private GlobEntry[] LoadGlobs() continue; var flags = parts.Length > 3 ? parts[3].Split(',') : []; - entries.Add(MakeEntry(weight, parts[1], parts[2], flags.Contains("cs"))); + entries.Add(MakeEntry(entries.Count, weight, parts[1], parts[2], flags.Contains("cs"))); } } else if (TryReadLines(Path.Combine(dir, "mime", "globs"), out lines)) @@ -268,7 +332,7 @@ private GlobEntry[] LoadGlobs() var parts = line.Split(':', 2); if (parts.Length == 2) - entries.Add(MakeEntry(50, parts[0], parts[1], false)); + entries.Add(MakeEntry(entries.Count, 50, parts[0], parts[1], false)); } } } @@ -276,8 +340,8 @@ private GlobEntry[] LoadGlobs() return [.. entries]; } - private static GlobEntry MakeEntry(int weight, string mime, string pattern, bool caseSensitive) => - new(weight, mime, pattern, caseSensitive, pattern.IndexOfAny(['*', '?', '[']) < 0); + private static GlobEntry MakeEntry(int order, int weight, string mime, string pattern, bool caseSensitive) => + new(order, weight, mime, pattern, pattern.ToLowerInvariant(), caseSensitive, pattern.IndexOfAny(['*', '?', '[']) < 0); private static bool TryReadLines(string path, out string[] lines) { @@ -293,7 +357,10 @@ private static bool TryReadLines(string path, out string[] lines) } } - private XElement? LoadMimeXml(string mimeType) + private XElement? LoadMimeXml(string mimeType) => + mimeXmlCache.GetOrAdd(mimeType, static (type, self) => self.ReadMimeXml(type), this); + + private XElement? ReadMimeXml(string mimeType) { if (mimeType.Contains("..", StringComparison.Ordinal) || mimeType.Split('/').Length != 2) return null; From bfea5286b58224870233a9e6d87684ebd4059abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 5 Oct 2026 20:08:29 +0300 Subject: [PATCH 2/4] Linux: virtualize Grid/Cards layouts with a wrapping virtualizing panel on Uno Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- Directory.Packages.props | 1 + .../Files.App.UnoVirtualization.csproj | 23 +++ .../VirtualizingWrapGrid.cs | 183 ++++++++++++++++++ src/Files.App/Files.App.csproj | 1 + .../Views/Layouts/GridLayoutPage.xaml.cs | 18 ++ 5 files changed, 226 insertions(+) create mode 100644 src/Files.App.UnoVirtualization/Files.App.UnoVirtualization.csproj create mode 100644 src/Files.App.UnoVirtualization/VirtualizingWrapGrid.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 5963e819292e..03f3b6f313e9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,6 +45,7 @@ + diff --git a/src/Files.App.UnoVirtualization/Files.App.UnoVirtualization.csproj b/src/Files.App.UnoVirtualization/Files.App.UnoVirtualization.csproj new file mode 100644 index 000000000000..251835dddf62 --- /dev/null +++ b/src/Files.App.UnoVirtualization/Files.App.UnoVirtualization.csproj @@ -0,0 +1,23 @@ + + + + + + $(DesktopTargetFramework) + enable + enable + Debug;Release + x86;x64;arm64 + true + false + true + + + + + + + + + + diff --git a/src/Files.App.UnoVirtualization/VirtualizingWrapGrid.cs b/src/Files.App.UnoVirtualization/VirtualizingWrapGrid.cs new file mode 100644 index 000000000000..a9b703745b2e --- /dev/null +++ b/src/Files.App.UnoVirtualization/VirtualizingWrapGrid.cs @@ -0,0 +1,183 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Windows.Foundation; + +namespace Files.App.UnoVirtualization +{ + /// + /// A virtualizing wrapping grid for on Uno Skia, where is not implemented. + /// Only the lines inside the viewport are realized, so large folders stay cheap. + /// + /// + /// Plugs a multi-item line layout into Uno's VirtualizingPanelLayout (the same machinery as ). + /// That layout API is internal to Uno.UI, so the project publicizes Uno.UI (Krafs.Publicizer), which also lets the runtime skip access checks. + /// + public sealed class VirtualizingWrapGrid : Panel, IVirtualizingPanel + { + private readonly WrapGridLayout _layout; + + public VirtualizingWrapGrid() + { + _layout = new WrapGridLayout { CacheLength = 4d }; + _layout.Initialize(this); + UpdateLayoutOrientation(); + } + + public static DependencyProperty OrientationProperty { get; } = DependencyProperty.Register( + nameof(Orientation), + typeof(Orientation), + typeof(VirtualizingWrapGrid), + new PropertyMetadata(Orientation.Horizontal, new PropertyChangedCallback((d, _) => ((VirtualizingWrapGrid)d).UpdateLayoutOrientation()))); + + /// + /// Gets or sets the direction items are laid out in before wrapping (same meaning as WrapPanel.Orientation). + /// + public Orientation Orientation + { + get => (Orientation)GetValue(OrientationProperty); + set => SetValue(OrientationProperty, value); + } + + private Orientation ScrollOrientation => + Orientation == Orientation.Horizontal ? Orientation.Vertical : Orientation.Horizontal; + + public override Orientation? PhysicalOrientation => ScrollOrientation; + + public override bool WantsScrollViewerToObscureAvailableSizeBasedOnScrollBarVisibility(Orientation orientation) => + ScrollOrientation == orientation; + + VirtualizingPanelLayout IVirtualizingPanel.GetLayouter() => _layout; + + public override Size MeasureOverride(Size availableSize) + { + _layout.Update(Orientation == Orientation.Horizontal ? availableSize.Width : availableSize.Height); + return _layout.MeasureOverride(availableSize); + } + + public override Size ArrangeOverride(Size finalSize) => _layout.ArrangeOverride(finalSize); + + public static DependencyProperty ProvisionalCellSizeProperty { get; } = DependencyProperty.Register( + nameof(ProvisionalCellSize), + typeof(Size), + typeof(VirtualizingWrapGrid), + new PropertyMetadata(new Size(120, 140))); + + /// + /// Gets or sets the cell size assumed until the first container has loaded and reports its real size. + /// + public Size ProvisionalCellSize + { + get => (Size)GetValue(ProvisionalCellSizeProperty); + set + { + SetValue(ProvisionalCellSizeProperty, value); + _layout.SetProvisionalCell(value); + } + } + + private void UpdateLayoutOrientation() => _layout.Orientation = ScrollOrientation; + + /// + /// Uno's layout works in lines of one item (its scroll-offset to item mapping assumes that). Each item therefore gets a virtual strip + /// (cell extent / items per row) along the scroll axis, which keeps every item inside the real band of its row, and + /// places the container at its real grid cell. + /// + private sealed class WrapGridLayout : VirtualizingPanelLayout + { + private const double MinReliableSize = 8d; + private const int MaxConsecutiveResizes = 4; + + private double _cellBreadth = 120d; + private double _cellExtent = 140d; + private int _itemsPerRow = 1; + private bool _cellMeasured; + private int _consecutiveResizes; + + public override Orientation ScrollOrientation => Orientation; + + public override int GetItemsPerLine() => 1; + + private double StripExtent => _cellExtent / _itemsPerRow; + + public void SetProvisionalCell(Size size) + { + if (_cellMeasured || size.Width < MinReliableSize || size.Height < MinReliableSize) + return; + + _cellBreadth = ScrollOrientation == Orientation.Vertical ? size.Width : size.Height; + _cellExtent = ScrollOrientation == Orientation.Vertical ? size.Height : size.Width; + } + + /// + /// Adopts the real cell size once a container has loaded (the item template sizes itself through bindings that only resolve after load) + /// and re-derives the number of items per row, rebuilding the realized lines when either changed. + /// + public void Update(double availableBreadth) + { + var changed = false; + if (GetFirstMaterializedLine()?.FirstView is { IsLoaded: true } view) + { + var breadth = GetBreadth(view.DesiredSize); + var extent = GetExtent(view.DesiredSize); + if (breadth >= MinReliableSize && extent >= MinReliableSize && + (Math.Abs(breadth - _cellBreadth) > 0.5 || Math.Abs(extent - _cellExtent) > 0.5 || !_cellMeasured)) + { + _cellBreadth = breadth; + _cellExtent = extent; + _cellMeasured = true; + changed = true; + } + } + + if (!double.IsInfinity(availableBreadth) && !double.IsNaN(availableBreadth)) + { + var perRow = Math.Max(1, (int)(availableBreadth / _cellBreadth)); + if (perRow != _itemsPerRow) + { + _itemsPerRow = perRow; + changed = true; + } + } + + if (!changed) + { + _consecutiveResizes = 0; + return; + } + + // Guards against a cell size that keeps changing between passes + if (++_consecutiveResizes <= MaxConsecutiveResizes) + Refresh(); + } + + public override Line CreateLine(GeneratorDirection fillDirection, double extentOffset, double availableBreadth, Uno.UI.IndexPath nextVisibleItem) + { + var flat = GetFlatItemIndex(nextVisibleItem); + var view = Generator.DequeueViewForItem(flat)!; + AddView(view, fillDirection, extentOffset, 0); + + // Pin the virtual strip: a container whose template has not sized itself yet must not distort the line positions + var strip = StripExtent; + var start = fillDirection == GeneratorDirection.Forward ? extentOffset : extentOffset - strip; + SetBounds(view, ScrollOrientation == Orientation.Vertical + ? new Rect(0, start, _cellBreadth, strip) + : new Rect(start, 0, strip, _cellBreadth)); + + return new Line(flat, (view, nextVisibleItem)); + } + + public override Rect GetElementArrangeBounds(int elementIndex, Rect containerBounds, Size windowConstraint, Size finalSize) + { + var row = elementIndex / _itemsPerRow; + var column = elementIndex % _itemsPerRow; + return ScrollOrientation == Orientation.Vertical + ? new Rect(column * _cellBreadth, row * _cellExtent, _cellBreadth, _cellExtent) + : new Rect(row * _cellExtent, column * _cellBreadth, _cellExtent, _cellBreadth); + } + } + } +} diff --git a/src/Files.App/Files.App.csproj b/src/Files.App/Files.App.csproj index abbec46dabe2..99d6bb0c580c 100644 --- a/src/Files.App/Files.App.csproj +++ b/src/Files.App/Files.App.csproj @@ -177,6 +177,7 @@ + diff --git a/src/Files.App/Views/Layouts/GridLayoutPage.xaml.cs b/src/Files.App/Views/Layouts/GridLayoutPage.xaml.cs index 2b42d22b3f82..83f5a5514aef 100644 --- a/src/Files.App/Views/Layouts/GridLayoutPage.xaml.cs +++ b/src/Files.App/Views/Layouts/GridLayoutPage.xaml.cs @@ -367,6 +367,24 @@ private void SetItemTemplate() var oldSource = FileList.ItemsSource; FileList.ItemsSource = null; FileList.Style = newFileListStyle; +#if !WINDOWS + // Uno Skia has no ItemsWrapGrid; this panel virtualizes, the style's WrapPanel would realize every item + var wrapOrientation = folderSettings.LayoutMode == FolderLayoutModes.ListView ? Orientation.Vertical : Orientation.Horizontal; + FileList.ItemsPanel = new ItemsPanelTemplate(() => new Files.App.UnoVirtualization.VirtualizingWrapGrid + { + Orientation = wrapOrientation, + ProvisionalCellSize = folderSettings.LayoutMode == FolderLayoutModes.GridView + ? new Windows.Foundation.Size(ItemWidthGridView, ItemWidthGridView + 48) + : new Windows.Foundation.Size(240, 96), + }); + // A virtualizing panel sizes its lines from the first realized containers, so the template must be in place before items are restored + FileList.ItemTemplate = (DataTemplate)Resources[folderSettings.LayoutMode switch + { + FolderLayoutModes.ListView => "ListViewBrowserTemplate", + FolderLayoutModes.CardsView => "CardsBrowserTemplate", + _ => "GridViewBrowserTemplate", + }]; +#endif FileList.ItemsSource = oldSource; } From a3546d4d372b0c945b6ea8bd7921dbdb8d013ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 5 Oct 2026 20:32:12 +0300 Subject: [PATCH 3/4] Linux perf: cache SVG typefaces, MIME XML recheck, incremental list updates on watcher changes Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- .../Desktop/Services/LinuxIconHelper.cs | 11 ++-- .../ViewModels/ShellViewModel.Linux.cs | 35 +++++++++++++ src/Files.App/ViewModels/ShellViewModel.cs | 11 ++++ .../Icons/CachingTypefaceProvider.cs | 40 ++++++++++++++ .../Icons/SvgRasterizer.cs | 1 + .../Mime/LinuxMimeTypeService.cs | 52 ++++++++++++++++--- .../Mime/MimeTypeServiceTests.cs | 15 ++++++ 7 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 src/Files.Platform.Linux/Icons/CachingTypefaceProvider.cs diff --git a/src/Files.App/Platforms/Desktop/Services/LinuxIconHelper.cs b/src/Files.App/Platforms/Desktop/Services/LinuxIconHelper.cs index 2f8f397f7dba..7fa5b3754b33 100644 --- a/src/Files.App/Platforms/Desktop/Services/LinuxIconHelper.cs +++ b/src/Files.App/Platforms/Desktop/Services/LinuxIconHelper.cs @@ -21,7 +21,8 @@ namespace Files.App.Utils.Storage internal static class LinuxIconHelper { private static readonly ConcurrentDictionary<(string Name, uint Size), byte[]?> _themeCache = new(); - private static readonly ConcurrentDictionary _mimeIconNames = new(StringComparer.Ordinal); + private static readonly ConcurrentDictionary _mimeIconNames = new(StringComparer.Ordinal); + private const long MimeIconNamesTtlMs = 5000; private static IThumbnailService Thumbnails => Ioc.Default.GetRequiredService(); private static IIconThemeProvider Theme => Ioc.Default.GetRequiredService(); @@ -118,12 +119,14 @@ private static async Task> GetIconNamesAsync(string? path, } var mimeType = await Mime.GetMimeTypeAsync(path); - if (_mimeIconNames.TryGetValue(mimeType, out var cached)) - return cached; + if (_mimeIconNames.TryGetValue(mimeType, out var cached) && Environment.TickCount64 - cached.At < MimeIconNamesTtlMs) + return cached.Names; var iconName = await Mime.GetIconNameAsync(mimeType); var generic = await Mime.GetGenericIconNameAsync(mimeType); - return _mimeIconNames[mimeType] = [iconName, generic, "text-x-generic", "unknown"]; + string[] names = [iconName, generic, "text-x-generic", "unknown"]; + _mimeIconNames[mimeType] = (names, Environment.TickCount64); + return names; } private static string? GetSpecialFolderIconName(string? path) diff --git a/src/Files.App/Platforms/Desktop/ViewModels/ShellViewModel.Linux.cs b/src/Files.App/Platforms/Desktop/ViewModels/ShellViewModel.Linux.cs index b30aca541f25..546ec29fb19d 100644 --- a/src/Files.App/Platforms/Desktop/ViewModels/ShellViewModel.Linux.cs +++ b/src/Files.App/Platforms/Desktop/ViewModels/ShellViewModel.Linux.cs @@ -456,6 +456,41 @@ private void ScheduleLinuxChanges(string folder) }); } + /// + /// Brings to with individual remove and insert notifications when only + /// a few items differ and the rest keep their order. Returns false when the caller must rebuild the list instead. + /// + private bool TryApplyIncrementalDisplayChanges(List desired) + { + const int MaxIncrementalChanges = 64; + + if (folderSettings.DirectoryGroupOption != GroupOption.None || FilesAndFolders.Count == 0) + return false; + + var current = FilesAndFolders.ToList(); + var currentSet = new HashSet(current); + var desiredSet = new HashSet(desired); + var removed = current.Where(i => !desiredSet.Contains(i)).ToList(); + var added = desired.Count(i => !currentSet.Contains(i)); + if (removed.Count + added > MaxIncrementalChanges) + return false; + + // Items present on both sides must already be in the same relative order (no sort change) + if (!current.Where(desiredSet.Contains).SequenceEqual(desired.Where(currentSet.Contains))) + return false; + + foreach (var item in removed) + FilesAndFolders.Remove(item); + + for (var i = 0; i < desired.Count; i++) + { + if (!currentSet.Contains(desired[i])) + FilesAndFolders.Insert(i, desired[i]); + } + + return true; + } + /// /// Applies a burst of watcher events as add, remove, rename and update operations on the existing items. Falls back to a /// full reload when events were lost or the burst was too large. diff --git a/src/Files.App/ViewModels/ShellViewModel.cs b/src/Files.App/ViewModels/ShellViewModel.cs index a31118048ca8..fd11a16151f1 100644 --- a/src/Files.App/ViewModels/ShellViewModel.cs +++ b/src/Files.App/ViewModels/ShellViewModel.cs @@ -1135,6 +1135,17 @@ await dispatcherQueue.EnqueueOrInvokeAsync(() => if (addFilesCTS.IsCancellationRequested || FilesAndFoldersFilter != filter) return; +#if !WINDOWS + // A few watcher changes must not rebuild (and visibly blank) the whole list + if (TryApplyIncrementalDisplayChanges(displayedFilesAndFolders)) + { + UpdateEmptyTextType(); + UpdateNetworkAvailabilityInfoBar(); + DirectoryInfoUpdated?.Invoke(this, EventArgs.Empty); + return; + } +#endif + FilesAndFolders.BeginBulkOperation(); try { diff --git a/src/Files.Platform.Linux/Icons/CachingTypefaceProvider.cs b/src/Files.Platform.Linux/Icons/CachingTypefaceProvider.cs new file mode 100644 index 000000000000..b742c07e1c5d --- /dev/null +++ b/src/Files.Platform.Linux/Icons/CachingTypefaceProvider.cs @@ -0,0 +1,40 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using SkiaSharp; +using Svg.Skia.TypefaceProviders; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace Files.Platform.Linux.Icons +{ + /// + /// Memoizes typeface lookups for SVG rendering. Svg.Skia resolves a typeface for every paint it converts, and the default + /// font-manager provider enumerates fontconfig style sets each time, which is very slow with thousands of installed fonts. + /// + internal sealed class CachingTypefaceProvider : ITypefaceProvider + { + private static readonly ConcurrentDictionary<(string Family, SKFontStyleWeight Weight, SKFontStyleWidth Width, SKFontStyleSlant Slant), SKTypeface?> _cache = new(); + + private readonly IReadOnlyList _providers; + + public CachingTypefaceProvider(IEnumerable? providers) + { + _providers = [.. providers ?? [new FontManagerTypefaceProvider(), new DefaultTypefaceProvider()]]; + } + + public SKTypeface? FromFamilyName(string fontFamily, SKFontStyleWeight fontWeight, SKFontStyleWidth fontWidth, SKFontStyleSlant fontStyle) + { + return _cache.GetOrAdd((fontFamily ?? string.Empty, fontWeight, fontWidth, fontStyle), key => + { + foreach (var provider in _providers) + { + if (provider.FromFamilyName(key.Family, key.Weight, key.Width, key.Slant) is { } typeface) + return typeface; + } + + return null; + }); + } + } +} diff --git a/src/Files.Platform.Linux/Icons/SvgRasterizer.cs b/src/Files.Platform.Linux/Icons/SvgRasterizer.cs index 76da7f187f0a..069c7add31a5 100644 --- a/src/Files.Platform.Linux/Icons/SvgRasterizer.cs +++ b/src/Files.Platform.Linux/Icons/SvgRasterizer.cs @@ -43,6 +43,7 @@ public static class SvgRasterizer try { using var svg = new SKSvg(); + svg.Settings.TypefaceProviders = [new CachingTypefaceProvider(svg.Settings.TypefaceProviders)]; if (load(svg) is not { } picture) return null; diff --git a/src/Files.Platform.Linux/Mime/LinuxMimeTypeService.cs b/src/Files.Platform.Linux/Mime/LinuxMimeTypeService.cs index 3a2df6c4abf1..93d07ea0d9fd 100644 --- a/src/Files.Platform.Linux/Mime/LinuxMimeTypeService.cs +++ b/src/Files.Platform.Linux/Mime/LinuxMimeTypeService.cs @@ -32,7 +32,10 @@ public sealed class LinuxMimeTypeService : IMimeTypeService private readonly XdgDirectories xdg; private readonly CultureInfo culture; private readonly Lazy globs; - private readonly ConcurrentDictionary mimeXmlCache = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary mimeXmlCache = new(StringComparer.Ordinal); + + + private sealed record MimeXmlEntry(XElement? Root, string? File, DateTime LastWriteUtc, long CheckedAt); private sealed record GlobEntry(int Order, int Weight, string MimeType, string Pattern, string LowerPattern, bool CaseSensitive, bool IsLiteral) { @@ -134,6 +137,11 @@ public LinuxMimeTypeService(XdgDirectories xdg, CultureInfo culture) globs = new(() => new GlobIndex(LoadGlobs())); } + /// + /// Gets how long a cached MIME XML (or a miss) is trusted before the file is checked again, so newly installed types appear without a restart. + /// + public TimeSpan RecheckInterval { get; init; } = TimeSpan.FromSeconds(5); + /// public async Task GetMimeTypeAsync(string path, CancellationToken cancellationToken = default) { @@ -357,13 +365,43 @@ private static bool TryReadLines(string path, out string[] lines) } } - private XElement? LoadMimeXml(string mimeType) => - mimeXmlCache.GetOrAdd(mimeType, static (type, self) => self.ReadMimeXml(type), this); + private XElement? LoadMimeXml(string mimeType) + { + var now = Environment.TickCount64 * TimeSpan.TicksPerMillisecond; + if (mimeXmlCache.TryGetValue(mimeType, out var entry)) + { + if (now - entry.CheckedAt < RecheckInterval.Ticks) + return entry.Root; + + // Hit: keep it while the file is unchanged. Miss: probe again. + if (entry.File is not null && TryGetWriteTime(entry.File) == entry.LastWriteUtc) + { + mimeXmlCache[mimeType] = entry with { CheckedAt = now }; + return entry.Root; + } + } - private XElement? ReadMimeXml(string mimeType) + var (root, file) = ReadMimeXml(mimeType); + mimeXmlCache[mimeType] = new MimeXmlEntry(root, file, file is null ? default : TryGetWriteTime(file), now); + return root; + } + + private static DateTime TryGetWriteTime(string file) + { + try + { + return File.GetLastWriteTimeUtc(file); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return default; + } + } + + private (XElement? Root, string? File) ReadMimeXml(string mimeType) { if (mimeType.Contains("..", StringComparison.Ordinal) || mimeType.Split('/').Length != 2) - return null; + return (null, null); foreach (var dir in xdg.AllDataDirs) { @@ -373,14 +411,14 @@ private static bool TryReadLines(string path, out string[] lines) try { - return XDocument.Load(file).Root; + return (XDocument.Load(file).Root, file); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Xml.XmlException) { } } - return null; + return (null, null); } private static bool IsDanglingSymbolicLink(string path) diff --git a/tests/Files.Platform.Tests/Mime/MimeTypeServiceTests.cs b/tests/Files.Platform.Tests/Mime/MimeTypeServiceTests.cs index 457bcf3725a1..f59c8eb8d265 100644 --- a/tests/Files.Platform.Tests/Mime/MimeTypeServiceTests.cs +++ b/tests/Files.Platform.Tests/Mime/MimeTypeServiceTests.cs @@ -30,6 +30,21 @@ private static LinuxMimeTypeService Create(XdgFixture fx, string lang = "en-US") return new LinuxMimeTypeService(fx.Directories, CultureInfo.GetCultureInfo(lang)); } + [TestMethod] + public async Task MimeXml_InstalledAfterFirstLookup_IsPickedUp() + { + using var fx = new XdgFixture(); + fx.Write("usr-share/mime/globs2", Globs2); + var svc = new LinuxMimeTypeService(fx.Directories, CultureInfo.GetCultureInfo("en-US")) { RecheckInterval = System.TimeSpan.Zero }; + + Assert.AreEqual("application-x-newtype", await svc.GetIconNameAsync("application/x-newtype")); + + fx.Write("usr-share/mime/application/x-newtype.xml", + ""); + + Assert.AreEqual("custom-icon", await svc.GetIconNameAsync("application/x-newtype")); + } + [TestMethod] public async Task Glob_SimpleExtension_CaseInsensitive() { From 823ee5bab1a1c26ef81b3665efc6c5102a6d31d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 5 Oct 2026 21:39:23 +0300 Subject: [PATCH 4/4] Linux perf: grid panel review fixes (extent, resize anchoring, row/column keys, grouping and Uno guard), bounded typeface cache, MIME override precedence Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- .../VirtualizingWrapGrid.cs | 169 +++++++++++++++++- .../Views/Layouts/BaseGroupableLayoutPage.cs | 60 +++++++ src/Files.App/Views/Layouts/BaseLayoutPage.cs | 6 + .../Views/Layouts/GridLayoutPage.xaml.cs | 87 +++++++-- .../Icons/CachingTypefaceProvider.cs | 59 +++++- .../Mime/LinuxMimeTypeService.cs | 22 ++- .../Mime/MimeTypeServiceTests.cs | 16 ++ 7 files changed, 389 insertions(+), 30 deletions(-) diff --git a/src/Files.App.UnoVirtualization/VirtualizingWrapGrid.cs b/src/Files.App.UnoVirtualization/VirtualizingWrapGrid.cs index a9b703745b2e..1da43b06fe3d 100644 --- a/src/Files.App.UnoVirtualization/VirtualizingWrapGrid.cs +++ b/src/Files.App.UnoVirtualization/VirtualizingWrapGrid.cs @@ -55,10 +55,64 @@ public override bool WantsScrollViewerToObscureAvailableSizeBasedOnScrollBarVisi public override Size MeasureOverride(Size availableSize) { _layout.Update(Orientation == Orientation.Horizontal ? availableSize.Width : availableSize.Height); - return _layout.MeasureOverride(availableSize); + return _layout.CorrectExtent(_layout.MeasureOverride(availableSize), availableSize); } - public override Size ArrangeOverride(Size finalSize) => _layout.ArrangeOverride(finalSize); + public override Size ArrangeOverride(Size finalSize) => _layout.CorrectExtent(_layout.ArrangeOverride(finalSize), finalSize); + + /// + /// Gets the number of items in a row (a column for the vertical-wrap orientation) at the current size. + /// + public int ItemsPerRow => _layout.ItemsPerRow; + + /// + /// Gets the scroll direction: Vertical when items wrap horizontally into rows, Horizontal when they wrap vertically into columns. + /// + public Orientation PanelScrollOrientation => ScrollOrientation; + + /// + /// Scrolls the minimum distance that shows the whole cell of the item at . + /// + public void EnsureItemVisible(int index) => _layout.EnsureItemVisible(index); + + private const string SupportedUnoVersionPrefix = "6.7."; + private static string? s_unsupportedReason; + private static bool s_probed; + + /// + /// Checks that the Uno internals this panel builds on are present, so callers can fall back to a plain panel (and log + /// ) after a Uno upgrade changes them. The result is computed once. + /// + public static bool IsSupported(out string? reason) + { + if (!s_probed) + { + try + { + var unoVersion = typeof(Panel).Assembly.GetName().Version?.ToString() ?? string.Empty; + var informational = typeof(Panel).Assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false); + var full = informational.Length > 0 ? ((System.Reflection.AssemblyInformationalVersionAttribute)informational[0]).InformationalVersion : unoVersion; + if (!full.StartsWith(SupportedUnoVersionPrefix, StringComparison.Ordinal)) + { + s_unsupportedReason = $"Uno.UI {full} is not the version the virtualizing wrap grid was built against ({SupportedUnoVersionPrefix}x)"; + } + else + { + // Instantiating exercises the internal types and members the layout subclasses + _ = ((IVirtualizingPanel)new VirtualizingWrapGrid()).GetLayouter(); + } + } + catch (Exception ex) when (ex is TypeLoadException or MissingMemberException or MethodAccessException or TypeInitializationException or BadImageFormatException or InvalidProgramException) + { + s_unsupportedReason = $"{ex.GetType().Name}: {ex.Message}"; + } + + s_probed = true; + } + + reason = s_unsupportedReason; + return s_unsupportedReason is null; + } public static DependencyProperty ProvisionalCellSizeProperty { get; } = DependencyProperty.Register( nameof(ProvisionalCellSize), @@ -112,12 +166,16 @@ public void SetProvisionalCell(Size size) _cellExtent = ScrollOrientation == Orientation.Vertical ? size.Height : size.Width; } + public int ItemsPerRow => _itemsPerRow; + /// /// Adopts the real cell size once a container has loaded (the item template sizes itself through bindings that only resolve after load) - /// and re-derives the number of items per row, rebuilding the realized lines when either changed. + /// and re-derives the number of items per row. When either changed, the realized lines are rebuilt around the item that was + /// at the top of the viewport, so a resize deep in a large folder does not realize items from the start. /// public void Update(double availableBreadth) { + var anchorItem = GetAnchorItem(); var changed = false; if (GetFirstMaterializedLine()?.FirstView is { IsLoaded: true } view) { @@ -150,10 +208,113 @@ public void Update(double availableBreadth) } // Guards against a cell size that keeps changing between passes - if (++_consecutiveResizes <= MaxConsecutiveResizes) + if (++_consecutiveResizes > MaxConsecutiveResizes) + return; + + if (anchorItem > 0 && ScrollViewer is not null) + Reanchor(anchorItem); + else Refresh(); } + private int GetAnchorItem() + { + if (GetFirstMaterializedLine() is null || ScrollViewer is null || _cellExtent <= 0) + return -1; + + var count = ItemsControl?.NumberOfItems ?? 0; + var row = (int)(ScrollOffset / _cellExtent); + return Math.Clamp(row * _itemsPerRow, 0, Math.Max(0, count - 1)); + } + + private double? _pendingOffset; + + /// + /// Rebuilds the realized lines around the current scroll offset (a plain refresh would realize items from the start up to the + /// offset), then moves the scroll offset so the item that was at the top stays there once the new extent is known. + /// + private void Reanchor(int anchorItem) + { + _pendingOffset = anchorItem / _itemsPerRow * _cellExtent; + + var count = ItemsControl?.NumberOfItems ?? 0; + var currentRow = Math.Min((int)(ScrollOffset / _cellExtent), Math.Max(0, (count - 1) / _itemsPerRow)); + ClearLines(clearContainer: true); + Generator.ClearIdCache(); + SetDynamicSeed(Uno.UI.IndexPath.FromRowSection(currentRow * _itemsPerRow - 1, 0), currentRow * _cellExtent); + UpdateLayout(null, isScroll: true); + UpdateCompleted(); + + ScheduleApplyPendingOffset(0); + } + + private void ScheduleApplyPendingOffset(int attempt) + { + var dispatcher = OwnerPanel.DispatcherQueue; + dispatcher?.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => ApplyPendingOffset(attempt)); + } + + private void ApplyPendingOffset(int attempt) + { + if (_pendingOffset is not { } target || ScrollViewer is not { } scroller) + return; + + var vertical = ScrollOrientation == Orientation.Vertical; + var extent = vertical ? scroller.ExtentHeight : scroller.ExtentWidth; + var viewport = vertical ? scroller.ViewportHeight : scroller.ViewportWidth; + + // The new extent arrives with the next layout pass; wait for it so the offset isn't clamped to the old one + if (extent < target + viewport * 0.5 && attempt < 8) + { + ScheduleApplyPendingOffset(attempt + 1); + return; + } + + _pendingOffset = null; + var clamped = Math.Clamp(target, 0, Math.Max(0, extent - viewport)); + scroller.ChangeView(vertical ? null : clamped, vertical ? clamped : null, null, disableAnimation: true); + } + + /// + /// Uno sizes the panel from per-item strips, which falls short of the real extent by the empty part of the last row. + /// + public Size CorrectExtent(Size size, Size available) + { + var count = ItemsControl?.NumberOfItems ?? 0; + if (count == 0) + return size; + + var extent = Math.Ceiling(count / (double)_itemsPerRow) * _cellExtent; + var scrollsVertically = ScrollOrientation == Orientation.Vertical; + var availableExtent = scrollsVertically ? available.Height : available.Width; + extent = Math.Max(extent, double.IsInfinity(availableExtent) ? 0 : availableExtent); + return scrollsVertically ? new Size(size.Width, extent) : new Size(extent, size.Height); + } + + /// + /// Scrolls the minimum distance that shows the whole cell of an item; Uno only knows the item's virtual strip. + /// + public void EnsureItemVisible(int index) + { + if (ScrollViewer is not { } scroller || index < 0) + return; + + var start = index / _itemsPerRow * _cellExtent; + var end = start + _cellExtent; + var vertical = ScrollOrientation == Orientation.Vertical; + var offset = vertical ? scroller.VerticalOffset : scroller.HorizontalOffset; + var viewport = vertical ? scroller.ViewportHeight : scroller.ViewportWidth; + + double? target = null; + if (start < offset) + target = start; + else if (end > offset + viewport) + target = Math.Max(0, end - viewport); + + if (target is { } value) + scroller.ChangeView(vertical ? null : value, vertical ? value : null, null, disableAnimation: true); + } + public override Line CreateLine(GeneratorDirection fillDirection, double extentOffset, double availableBreadth, Uno.UI.IndexPath nextVisibleItem) { var flat = GetFlatItemIndex(nextVisibleItem); diff --git a/src/Files.App/Views/Layouts/BaseGroupableLayoutPage.cs b/src/Files.App/Views/Layouts/BaseGroupableLayoutPage.cs index f4ed7057f461..403258b90fd0 100644 --- a/src/Files.App/Views/Layouts/BaseGroupableLayoutPage.cs +++ b/src/Files.App/Views/Layouts/BaseGroupableLayoutPage.cs @@ -421,6 +421,66 @@ protected virtual async void RenameTextBox_LostFocus(object sender, RoutedEventA // Methods #if DESKTOP + /// + /// Arrow keys follow the rows and columns of the virtualizing wrap grid (Uno's list controls would step by one item). + /// + protected bool TryHandleWrapGridArrowKey(KeyRoutedEventArgs e) + { + if (e.Key is not (VirtualKey.Up or VirtualKey.Down or VirtualKey.Left or VirtualKey.Right) || + ListViewBase.ItemsPanelRoot is not Files.App.UnoVirtualization.VirtualizingWrapGrid panel || + ListViewBase.Items.Count == 0 || + FocusManager.GetFocusedElement(MainWindow.Instance.Content.XamlRoot) is TextBox || + InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down) || + InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down)) + return false; + + var perLine = panel.ItemsPerRow; + var withinLine = panel.PanelScrollOrientation == Orientation.Vertical ? 1 : perLine; + var acrossLines = panel.PanelScrollOrientation == Orientation.Vertical ? perLine : 1; + var step = e.Key switch + { + VirtualKey.Left => -withinLine, + VirtualKey.Right => withinLine, + VirtualKey.Up => -acrossLines, + _ => acrossLines, + }; + + // Horizontal-scroll layouts fill columns top to bottom, so Up/Down move within a column and Left/Right across columns + if (panel.PanelScrollOrientation == Orientation.Horizontal) + { + step = e.Key switch + { + VirtualKey.Up => -1, + VirtualKey.Down => 1, + VirtualKey.Left => -perLine, + _ => perLine, + }; + } + + var count = ListViewBase.Items.Count; + var current = ListViewBase.SelectedIndex; + if (current < 0) + current = 0; + else + current += step; + + if (current < 0 || current >= count) + { + e.Handled = true; + return true; + } + + if (ListViewBase.Items[current] is ListedItem item) + { + ItemManipulationModel.SetSelectedItem(item); + ItemManipulationModel.ScrollIntoView(item); + ItemManipulationModel.FocusSelectedItems(); + } + + e.Handled = true; + return true; + } + /// /// Home, End, Page Up and Page Down don't move the selection in Uno's list controls. /// diff --git a/src/Files.App/Views/Layouts/BaseLayoutPage.cs b/src/Files.App/Views/Layouts/BaseLayoutPage.cs index 7b9e768cb2b4..389df88f2735 100644 --- a/src/Files.App/Views/Layouts/BaseLayoutPage.cs +++ b/src/Files.App/Views/Layouts/BaseLayoutPage.cs @@ -181,9 +181,15 @@ public CollectionViewSource CollectionViewSource if (collectionViewSource.View is not null) collectionViewSource.View.VectorChanged += View_VectorChanged; + + OnCollectionViewSourceChanged(); } } + protected virtual void OnCollectionViewSourceChanged() + { + } + private bool isItemSelected = false; public bool IsItemSelected { diff --git a/src/Files.App/Views/Layouts/GridLayoutPage.xaml.cs b/src/Files.App/Views/Layouts/GridLayoutPage.xaml.cs index 509e955dbf64..ed105643158d 100644 --- a/src/Files.App/Views/Layouts/GridLayoutPage.xaml.cs +++ b/src/Files.App/Views/Layouts/GridLayoutPage.xaml.cs @@ -195,6 +195,21 @@ public GridLayoutPage() : base() protected override void ItemManipulationModel_ScrollIntoViewInvoked(object? sender, ListedItem e) { FileList.ScrollIntoView(e); +#if !WINDOWS + // The virtualizing panel only knows a virtual strip per item; make sure the whole cell is in view + if (FileList.ItemsPanelRoot is Files.App.UnoVirtualization.VirtualizingWrapGrid panel) + { + var index = FileList.Items.IndexOf(e); + var request = ++_ensureVisibleRequest; + + // Only the latest request may scroll; earlier ones would drag the view back after a faster jump (e.g. End after Page Down) + DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => + { + if (request == _ensureVisibleRequest) + panel.EnsureItemVisible(index); + }); + } +#endif } protected override void ItemManipulationModel_ScrollToTopInvoked(object? sender, EventArgs e) @@ -342,6 +357,57 @@ private void FolderSettings_LayoutModeChangeRequested(object? sender, LayoutMode } } +#if !WINDOWS + private (bool Virtualized, FolderLayoutModes? Mode)? _appliedPanelKey; + private int _ensureVisibleRequest; + private static bool s_loggedPanelFallback; + + /// + /// Uses the virtualizing wrap panel (Uno Skia has no ItemsWrapGrid, and the style's WrapPanel would realize every item) unless + /// the list is grouped (the panel has no group headers) or Uno's internals no longer match what the panel was built against. + /// + protected override void OnCollectionViewSourceChanged() => UpdateItemsPanel(); + + private void UpdateItemsPanel() + { + if (FolderSettings is not { } folderSettings) + return; + + // The folder setting decides, not the CollectionViewSource: that is still a placeholder when this first runs, and swapping the panel after + // the items are assigned would first realize every item in the style's non-virtualizing panel + var virtualize = folderSettings.DirectoryGroupOption == GroupOption.None; + if (virtualize && !Files.App.UnoVirtualization.VirtualizingWrapGrid.IsSupported(out var reason)) + { + virtualize = false; + if (!s_loggedPanelFallback) + { + s_loggedPanelFallback = true; + Microsoft.Extensions.Logging.LoggerExtensions.LogWarning(App.Logger, "Falling back to the non-virtualizing wrap panel for Grid and Cards layouts: {Reason}", reason); + } + } + + var key = (virtualize, (FolderLayoutModes?)folderSettings.LayoutMode); + if (_appliedPanelKey == key) + return; + + _appliedPanelKey = key; + if (!virtualize) + { + FileList.ClearValue(ItemsControl.ItemsPanelProperty); + return; + } + + var wrapOrientation = folderSettings.LayoutMode == FolderLayoutModes.ListView ? Orientation.Vertical : Orientation.Horizontal; + FileList.ItemsPanel = new ItemsPanelTemplate(() => new Files.App.UnoVirtualization.VirtualizingWrapGrid + { + Orientation = wrapOrientation, + ProvisionalCellSize = folderSettings.LayoutMode == FolderLayoutModes.GridView + ? new Windows.Foundation.Size(ItemWidthGridView, ItemWidthGridView + 48) + : new Windows.Foundation.Size(240, 96), + }); + } +#endif + [DynamicWindowsRuntimeCast(typeof(Style))] private void SetItemTemplate() { @@ -368,22 +434,9 @@ private void SetItemTemplate() FileList.ItemsSource = null; FileList.Style = newFileListStyle; #if !WINDOWS - // Uno Skia has no ItemsWrapGrid; this panel virtualizes, the style's WrapPanel would realize every item - var wrapOrientation = folderSettings.LayoutMode == FolderLayoutModes.ListView ? Orientation.Vertical : Orientation.Horizontal; - FileList.ItemsPanel = new ItemsPanelTemplate(() => new Files.App.UnoVirtualization.VirtualizingWrapGrid - { - Orientation = wrapOrientation, - ProvisionalCellSize = folderSettings.LayoutMode == FolderLayoutModes.GridView - ? new Windows.Foundation.Size(ItemWidthGridView, ItemWidthGridView + 48) - : new Windows.Foundation.Size(240, 96), - }); - // A virtualizing panel sizes its lines from the first realized containers, so the template must be in place before items are restored - FileList.ItemTemplate = (DataTemplate)Resources[folderSettings.LayoutMode switch - { - FolderLayoutModes.ListView => "ListViewBrowserTemplate", - FolderLayoutModes.CardsView => "CardsBrowserTemplate", - _ => "GridViewBrowserTemplate", - }]; + _appliedPanelKey = null; + FileList.ClearValue(ItemsControl.ItemsPanelProperty); + UpdateItemsPanel(); #endif FileList.ItemsSource = oldSource; } @@ -628,7 +681,7 @@ protected override async void FileList_PreviewKeyDown(object sender, KeyRoutedEv return; #if DESKTOP - if (TryHandleListJumpKey(e)) + if (TryHandleListJumpKey(e) || TryHandleWrapGridArrowKey(e)) return; #endif diff --git a/src/Files.Platform.Linux/Icons/CachingTypefaceProvider.cs b/src/Files.Platform.Linux/Icons/CachingTypefaceProvider.cs index b742c07e1c5d..e9854b2e5d57 100644 --- a/src/Files.Platform.Linux/Icons/CachingTypefaceProvider.cs +++ b/src/Files.Platform.Linux/Icons/CachingTypefaceProvider.cs @@ -3,7 +3,7 @@ using SkiaSharp; using Svg.Skia.TypefaceProviders; -using System.Collections.Concurrent; +using System; using System.Collections.Generic; namespace Files.Platform.Linux.Icons @@ -11,30 +11,75 @@ namespace Files.Platform.Linux.Icons /// /// Memoizes typeface lookups for SVG rendering. Svg.Skia resolves a typeface for every paint it converts, and the default /// font-manager provider enumerates fontconfig style sets each time, which is very slow with thousands of installed fonts. + /// The cache is a small LRU; each key is created once even when requested concurrently. /// internal sealed class CachingTypefaceProvider : ITypefaceProvider { - private static readonly ConcurrentDictionary<(string Family, SKFontStyleWeight Weight, SKFontStyleWidth Width, SKFontStyleSlant Slant), SKTypeface?> _cache = new(); + internal const int Capacity = 64; + + private static readonly object _gate = new(); + private static readonly Dictionary<(string Family, SKFontStyleWeight Weight, SKFontStyleWidth Width, SKFontStyleSlant Slant), LinkedListNode> _cache = new(); + private static readonly LinkedList _order = new(); private readonly IReadOnlyList _providers; + private sealed record Entry((string Family, SKFontStyleWeight Weight, SKFontStyleWidth Width, SKFontStyleSlant Slant) Key, SKTypeface? Typeface); + public CachingTypefaceProvider(IEnumerable? providers) { _providers = [.. providers ?? [new FontManagerTypefaceProvider(), new DefaultTypefaceProvider()]]; } + /// Gets the number of cached lookups. + internal static int Count + { + get + { + lock (_gate) + return _cache.Count; + } + } + public SKTypeface? FromFamilyName(string fontFamily, SKFontStyleWeight fontWeight, SKFontStyleWidth fontWidth, SKFontStyleSlant fontStyle) { - return _cache.GetOrAdd((fontFamily ?? string.Empty, fontWeight, fontWidth, fontStyle), key => + var key = (fontFamily ?? string.Empty, fontWeight, fontWidth, fontStyle); + + // Creating under the lock keeps a key from being resolved twice; lookups are cheap after the first + lock (_gate) { + if (_cache.TryGetValue(key, out var node)) + { + // A typeface disposed elsewhere must not be handed out again + if (node.Value.Typeface is null || node.Value.Typeface.Handle != IntPtr.Zero) + { + _order.Remove(node); + _order.AddFirst(node); + return node.Value.Typeface; + } + + _order.Remove(node); + _cache.Remove(key); + } + + SKTypeface? typeface = null; foreach (var provider in _providers) { - if (provider.FromFamilyName(key.Family, key.Weight, key.Width, key.Slant) is { } typeface) - return typeface; + if (provider.FromFamilyName(key.Item1, fontWeight, fontWidth, fontStyle) is { } found) + { + typeface = found; + break; + } + } + + _cache[key] = _order.AddFirst(new Entry(key, typeface)); + while (_cache.Count > Capacity) + { + _cache.Remove(_order.Last!.Value.Key); + _order.RemoveLast(); } - return null; - }); + return typeface; + } } } } diff --git a/src/Files.Platform.Linux/Mime/LinuxMimeTypeService.cs b/src/Files.Platform.Linux/Mime/LinuxMimeTypeService.cs index 93d07ea0d9fd..3602de50fc61 100644 --- a/src/Files.Platform.Linux/Mime/LinuxMimeTypeService.cs +++ b/src/Files.Platform.Linux/Mime/LinuxMimeTypeService.cs @@ -373,8 +373,10 @@ private static bool TryReadLines(string path, out string[] lines) if (now - entry.CheckedAt < RecheckInterval.Ticks) return entry.Root; - // Hit: keep it while the file is unchanged. Miss: probe again. - if (entry.File is not null && TryGetWriteTime(entry.File) == entry.LastWriteUtc) + // Keep the entry while the highest-priority file is the same and unchanged (a new override in an earlier directory, or a type + // installed after a miss, changes which file that is) + var current = FindMimeXmlPath(mimeType); + if (current == entry.File && (current is null || TryGetWriteTime(current) == entry.LastWriteUtc)) { mimeXmlCache[mimeType] = entry with { CheckedAt = now }; return entry.Root; @@ -382,6 +384,7 @@ private static bool TryReadLines(string path, out string[] lines) } var (root, file) = ReadMimeXml(mimeType); + file = FindMimeXmlPath(mimeType) ?? file; mimeXmlCache[mimeType] = new MimeXmlEntry(root, file, file is null ? default : TryGetWriteTime(file), now); return root; } @@ -398,6 +401,21 @@ private static DateTime TryGetWriteTime(string file) } } + private string? FindMimeXmlPath(string mimeType) + { + if (mimeType.Contains("..", StringComparison.Ordinal) || mimeType.Split('/').Length != 2) + return null; + + foreach (var dir in xdg.AllDataDirs) + { + var file = Path.Combine(dir, "mime", mimeType + ".xml"); + if (File.Exists(file)) + return file; + } + + return null; + } + private (XElement? Root, string? File) ReadMimeXml(string mimeType) { if (mimeType.Contains("..", StringComparison.Ordinal) || mimeType.Split('/').Length != 2) diff --git a/tests/Files.Platform.Tests/Mime/MimeTypeServiceTests.cs b/tests/Files.Platform.Tests/Mime/MimeTypeServiceTests.cs index f59c8eb8d265..da2ab0a32af4 100644 --- a/tests/Files.Platform.Tests/Mime/MimeTypeServiceTests.cs +++ b/tests/Files.Platform.Tests/Mime/MimeTypeServiceTests.cs @@ -45,6 +45,22 @@ public async Task MimeXml_InstalledAfterFirstLookup_IsPickedUp() Assert.AreEqual("custom-icon", await svc.GetIconNameAsync("application/x-newtype")); } + [TestMethod] + public async Task MimeXml_HigherPriorityOverrideInstalledLater_IsPickedUp() + { + using var fx = new XdgFixture(); + fx.Write("usr-share/mime/globs2", Globs2); + const string xml = ""; + fx.Write("usr-share/mime/application/x-over.xml", string.Format(xml, "system-icon")); + var svc = new LinuxMimeTypeService(fx.Directories, CultureInfo.GetCultureInfo("en-US")) { RecheckInterval = System.TimeSpan.Zero }; + + Assert.AreEqual("system-icon", await svc.GetIconNameAsync("application/x-over")); + + fx.Write("data/mime/application/x-over.xml", string.Format(xml, "user-icon")); + + Assert.AreEqual("user-icon", await svc.GetIconNameAsync("application/x-over")); + } + [TestMethod] public async Task Glob_SimpleExtension_CaseInsensitive() {