diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index d11ff503..db777fda 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -13,6 +13,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor - **Hold to repeat** on the on-screen keyboard: holding West (backspace), LB/RB (cursor) or South on `⌫ ◀ ▶` repeats with acceleration; Done never repeats. (#42) - **Favorites**: pin folders from the actions menu (North → "Add to favorites"). They come first on the home screen and in the folder picker's "Go to another place", can be reordered from the home screen and are saved with your settings (portable mode included). A favorite that went missing shows as unavailable and is only removed when you say so. (#48) - **Retry failed items**: from the result of a copy, move, delete or extraction, run again only the items that failed or were not processed; what already succeeded is never redone. Items interrupted by a cancel are now reported as "not processed". (#19) +- **Native Windows icons** in the list (home, folders, picker and archives): file types, folders, special folders and drives. Icons load in the background without stalling scrolling and follow the display scale; no emoji left in the list. (#24) ### Improvements - **Clearer focus that always lands somewhere valid:** the list uses the same highlight ring as menus and dialogs; after deleting or moving items focus goes to the next remaining item (or the previous one); opening a folder scrolls the focused item into view right away; and the physical keyboard keeps working right after a menu or dialog closes. (#31) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf3cb40f..37707373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH - **Segurar para repetir** no teclado virtual: manter Oeste (apagar), LB/RB (cursor) ou Sul sobre `⌫ ◀ ▶` repete com aceleração; Concluir nunca repete. (#42) - **Favoritos**: fixe pastas pelo menu de ações (Norte → "Adicionar aos favoritos"). Elas aparecem primeiro na tela inicial e em "Ir para outro local" do seletor de pastas, podem ser reordenadas no início e ficam salvas nas preferências (inclusive no modo portátil). Uma favorita que sumiu aparece como indisponível e só sai da lista se você mandar. (#48) - **Tentar de novo só as falhas**: no resultado de uma cópia, movimentação, exclusão ou extração, refaz apenas os itens que falharam ou não foram processados; o que já deu certo nunca é refeito. Itens interrompidos por cancelamento agora aparecem como "não processados". (#19) +- **Ícones nativos do Windows** na lista (início, pastas, seletor e compactados): tipos de arquivo, pastas, pastas especiais e unidades. Os ícones carregam em segundo plano, sem travar a rolagem, e acompanham a escala da tela; nenhum emoji na lista. (#24) ### Melhorias - **Foco mais claro e sempre num lugar válido:** a lista usa o mesmo anel de destaque dos menus e diálogos; ao excluir ou mover itens o foco vai para o próximo item que sobrou (ou o anterior); ao abrir uma pasta o item focado já aparece rolado na tela; e o teclado físico volta a responder logo depois de fechar um menu ou diálogo. (#31) diff --git a/README.md b/README.md index 2bcb9bf2..7b9bb546 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Buttons follow **physical position**, so a Nintendo layout doesn't flip confirm ## Features - Real folders and drives, history, sorting, hidden items, marking, properties +- **Native Windows icons** for files, folders, special folders and drives, loaded in the background and sized for the screen's scale - **File operations:** rename, copy, cut, paste, move and delete to the Recycle Bin, with conflict handling (skip / keep both / replace / merge folders) and per-item results - Own **on-screen keyboard** (Portuguese/English, accents, symbols, cursor, masked passwords) usable with only directions + confirm + back - **Create folder** with Windows naming rules @@ -55,7 +56,7 @@ Buttons follow **physical position**, so a Nintendo layout doesn't flip confirm ## Roadmap -**Next:** native Windows icons, controller button prompts, virtual keyboard redesign, search, favorites ([roadmap](https://github.com/nextestudios/ControlFS/issues/95)) · **Later:** two panes, search, favorites, split volumes, unknown-controller wizard, light theme. Details in [docs/roadmap.md](docs/roadmap.md). +**Next:** controller button prompts, virtual keyboard redesign, search, favorites ([roadmap](https://github.com/nextestudios/ControlFS/issues/95)) · **Later:** two panes, search, favorites, split volumes, unknown-controller wizard, light theme. Details in [docs/roadmap.md](docs/roadmap.md). ## More diff --git a/README.pt-BR.md b/README.pt-BR.md index a2b9f519..69a77b25 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -42,6 +42,7 @@ Os botões seguem a **posição física**, então um controle Nintendo não inve ## Recursos - Pastas e unidades reais, histórico, ordenação, itens ocultos, marcação, propriedades +- **Ícones nativos do Windows** para arquivos, pastas, pastas especiais e unidades, carregados em segundo plano e no tamanho da escala da tela - **Operações de arquivo:** renomear, copiar, recortar, colar, mover e excluir para a Lixeira, com conflitos (pular / manter ambos / substituir / mesclar pastas) e resultado por item - **Teclado virtual** próprio (português/inglês, acentos, símbolos, cursor, senha mascarada) usável só com direções + confirmar + voltar - **Criar pasta** com as regras de nomes do Windows @@ -55,7 +56,7 @@ Os botões seguem a **posição física**, então um controle Nintendo não inve ## Roadmap -**Próximos:** ícones nativos do Windows, botões do controle no rodapé, novo teclado virtual, busca, favoritos ([roadmap](https://github.com/nextestudios/ControlFS/issues/95)) · **Depois:** dois painéis, busca, favoritos, volumes divididos, assistente para controles desconhecidos, tema claro. Detalhes em [docs/roadmap.md](docs/roadmap.md). +**Próximos:** botões do controle no rodapé, novo teclado virtual, busca, favoritos ([roadmap](https://github.com/nextestudios/ControlFS/issues/95)) · **Depois:** dois painéis, busca, favoritos, volumes divididos, assistente para controles desconhecidos, tema claro. Detalhes em [docs/roadmap.md](docs/roadmap.md). ## Mais diff --git a/docs/TESTING.md b/docs/TESTING.md index 7f857dbc..7b80293c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -30,6 +30,17 @@ versão, Windows, controle e conexão. Resultados de controles vão para `contro - [ ] Com controle real: segurar Oeste por 2 s apaga vários caracteres e para na hora ao soltar; segurar LB/RB move o cursor continuamente; segurar Sul em Concluir não repete. - [ ] Favoritar uma pasta num pendrive, fechar o app, remover o pendrive e reabrir: o favorito aparece primeiro como indisponível (⚠); reconectar e voltar ao início: volta a abrir. +## Ícones do Windows (#24) + +- [ ] Início: Downloads, Documentos, Área de trabalho, Imagens, Vídeos e Músicas com o ícone próprio; unidade fixa, pendrive, + leitor óptico e unidade de rede com ícones diferentes. +- [ ] Pasta com .txt, .pdf, .docx, .zip, .exe e .ico: cada um com o ícone do programa associado; .exe com o ícone do próprio programa. +- [ ] Dentro de um ZIP: ícones por extensão (nenhum emoji na lista). +- [ ] Pasta com 10.000 arquivos: rolar com o gatilho e o analógico; a rolagem não trava enquanto os ícones aparecem. +- [ ] 1080p 100%, 4K 200% e portátil (1280×800 a 150%): ícones nítidos, sem serrilhado; mover a janela para um monitor + com outra escala troca os ícones para o novo tamanho. +- [ ] Anel de foco e marcação continuam legíveis sobre os ícones. + ## Formatos, compactar e abrir com o Windows - [ ] Abrir e extrair um RAR e um 7z reais baixados da internet (conferir a marca de origem nos extraídos). diff --git a/docs/architecture.md b/docs/architecture.md index 919d9c97..63fad9e2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,7 +35,9 @@ Monólito modular desktop. O núcleo não depende de WinUI, SDL3 nem SharpCompre ## Onde está cada contrato pedido -`IFileSystemProvider`, `IArchiveService`, `IInputBackend`, `ISettingsStore` em `Core/Contracts`; `IArchiveEngine` em +`IFileSystemProvider`, `IArchiveService`, `IInputBackend`, `ISettingsStore`, `IIconProvider` em `Core/Contracts` +(ícones do Shell: `Infrastructure.Windows/Shell/ShellIconProvider`, numa thread STA própria; cache LRU por tipo e +tamanho em `App/Controls/IconLoader`); `IArchiveEngine` em `Infrastructure.Archives/Engines` (detalhe de adaptador). `IFileOperationService`, `IControllerProfileStore`, `IThumbnailService` e uma interface pública para a fila **ainda não existem**: serão criados quando houver implementação real (Etapas 2–3), não como classes vazias. diff --git a/src/ControlFS.App/Controls/EntryRowTemplate.cs b/src/ControlFS.App/Controls/EntryRowTemplate.cs index 4aa33f48..33d098c6 100644 --- a/src/ControlFS.App/Controls/EntryRowTemplate.cs +++ b/src/ControlFS.App/Controls/EntryRowTemplate.cs @@ -1,4 +1,5 @@ using ControlFS.App.Resources; +using ControlFS.Core.Contracts; using ControlFS.Core.Models; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation; @@ -19,7 +20,10 @@ public static class EntryRowTemplate "" + "" + "" + - "" + + "" + + "" + + "" + + "" + "" + "" + "" + @@ -29,7 +33,7 @@ public static class EntryRowTemplate public static DataTemplate Create() => (DataTemplate)XamlReader.Load(Xaml); - public static void Fill(SelectorItem container, FileEntry entry, bool focused, bool selected, bool cut = false) + public static void Fill(SelectorItem container, FileEntry entry, bool focused, bool selected, bool cut, IconLoader icons, IReadOnlySet? specialFolders) { if (container.ContentTemplateRoot is not FrameworkElement root) return; SetFocused(container, focused); @@ -38,18 +42,21 @@ public static void Fill(SelectorItem container, FileEntry entry, bool focused, b var detail = (TextBlock)root.FindName("Detail"); var mark = (TextBlock)root.FindName("Mark"); - icon.Text = entry.IsBlocked ? "⚠" : entry.Kind switch + // Símbolo de reserva (fonte de ícones do Windows, nunca emoji) até o ícone do Shell chegar. + icon.Text = entry.IsBlocked ? Glyphs.Warning : entry.Kind switch { - EntryKind.Drive => "🖴", - EntryKind.KnownFolder or EntryKind.Directory or EntryKind.ArchiveDirectory => "📁", - _ when IsArchiveName(entry.Name) => "📦", - _ => "📄", + EntryKind.Drive => Glyphs.Drive, + EntryKind.KnownFolder or EntryKind.Directory or EntryKind.ArchiveDirectory => Glyphs.Folder, + _ when IsArchiveName(entry.Name) => Glyphs.Archive, + _ => Glyphs.File, }; + icon.Foreground = entry.IsBlocked ? Theme.Danger : Theme.TextMuted; + icons.Load((Image)root.FindName("IconImage"), icon, IconRequest.For(entry, specialFolders)); title.Text = entry.Name; title.Foreground = entry.IsBlocked ? Theme.Danger : entry.IsHidden ? Theme.TextMuted : Theme.Text; detail.Text = entry.IsBlocked ? "Bloqueado: " + entry.BlockedReason : Describe(entry); detail.Foreground = entry.IsBlocked ? Theme.Danger : Theme.TextMuted; - mark.Text = selected ? "✔ marcado" : cut ? "✂ recortado" : entry.IsEncrypted ? "🔒" : string.Empty; + mark.Text = selected ? "✔ marcado" : cut ? "✂ recortado" : entry.IsEncrypted ? "com senha" : string.Empty; // Recortado: conteúdo esmaecido (e indicado em texto, não só visualmente); o anel de foco continua nítido. if (root is Border { Child: UIElement content }) content.Opacity = cut ? 0.5 : 1.0; mark.Foreground = selected ? Theme.Selected : Theme.TextMuted; @@ -57,6 +64,22 @@ _ when IsArchiveName(entry.Name) => "📦", AutomationProperties.SetName(container, entry.IsBlocked ? $"{entry.Name}, bloqueado: {entry.BlockedReason}" : $"{entry.Name}{state}"); } + /// Linha voltou para a fila de reciclagem: cancela o ícone pendente. + public static void Recycle(SelectorItem container, IconLoader icons) + { + if (container.ContentTemplateRoot is FrameworkElement root && root.FindName("IconImage") is Image image) icons.Cancel(image); + } + + /// Segoe Fluent Icons / Segoe MDL2 Assets. + private static class Glyphs + { + public const string Folder = "\uE8B7"; + public const string File = "\uE8A5"; + public const string Drive = "\uEDA2"; + public const string Archive = "\uE7B8"; + public const string Warning = "\uE7BA"; + } + /// Liga/desliga o anel de foco de uma linha já preenchida (sem refazer o conteúdo). public static void SetFocused(SelectorItem container, bool focused) { diff --git a/src/ControlFS.App/Controls/IconLoader.cs b/src/ControlFS.App/Controls/IconLoader.cs new file mode 100644 index 00000000..d34fd9cd --- /dev/null +++ b/src/ControlFS.App/Controls/IconLoader.cs @@ -0,0 +1,131 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices.WindowsRuntime; +using ControlFS.Core.Contracts; +using ControlFS.Core.Icons; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace ControlFS.App.Controls; + +/// +/// Carrega ícones do sistema para as linhas da lista sem travar a UI: o símbolo aparece na hora e a imagem entra quando +/// chega. Pedidos iguais (mesma extensão) viram um só; linhas recicladas cancelam o pedido. Cache limitado, por tipo e +/// tamanho em pixels; trocar a escala (DPI) esvazia o cache. Usar somente na thread de UI. +/// +public sealed class IconLoader(IIconProvider provider) +{ + /// Tamanho do ícone na linha, em pixels independentes de dispositivo. + public const double IconSize = 32; + + private readonly LruCache _cache = new(512); + private readonly Dictionary _inflight = []; + private readonly ConditionalWeakTable _pending = []; + + private sealed class Flight(Task task, CancellationTokenSource cancellation) + { + public Task Task { get; } = task; + public CancellationTokenSource Cancellation { get; } = cancellation; + public int Waiters { get; set; } + } + + public int SizePx { get; private set; } = (int)IconSize; + + /// A escala mudou (outro monitor, outro DPI): os ícones em cache ficaram do tamanho errado. + public event Action? Invalidated; + + public void SetScale(double scale) + { + var px = Math.Max(16, (int)Math.Round(IconSize * scale)); + if (px == SizePx) return; + SizePx = px; + _cache.Clear(); + _inflight.Clear(); + Invalidated?.Invoke(); + } + + /// Mostra o símbolo de reserva e, quando houver, troca pelo ícone do sistema. + public void Load(Image image, UIElement fallback, IconRequest? request) + { + Cancel(image); + if (request is null) + { + Show(image, fallback, null, null); + return; + } + var key = request.Key + "@" + SizePx; + image.Tag = key; + if (_cache.TryGet(key, out var cached)) + { + Show(image, fallback, cached, key); + return; + } + Show(image, fallback, null, key); + var cts = new CancellationTokenSource(); + _pending.AddOrUpdate(image, cts); + _ = LoadAsync(image, fallback, request, key, cts.Token); + } + + /// Linha reciclada pela virtualização: o pedido dela não interessa mais. + public void Cancel(Image image) + { + if (_pending.TryGetValue(image, out var previous)) + { + previous.Cancel(); + previous.Dispose(); + _pending.Remove(image); + } + image.Tag = null; + } + + private async Task LoadAsync(Image image, UIElement fallback, IconRequest request, string key, CancellationToken cancellationToken) + { + if (!_inflight.TryGetValue(key, out var flight)) + { + var cts = new CancellationTokenSource(); + flight = new Flight(provider.GetIconAsync(request, SizePx, cts.Token), cts); + _inflight[key] = flight; + } + flight.Waiters++; + IconImage? pixels; + try + { + pixels = await flight.Task.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + // Ninguém mais espera este ícone: o provedor pode pular o trabalho. + if (--flight.Waiters == 0 && _inflight.TryGetValue(key, out var current) && current == flight) + { + _inflight.Remove(key); + flight.Cancellation.Cancel(); + } + return; + } + flight.Waiters--; + if (_inflight.TryGetValue(key, out var same) && same == flight) _inflight.Remove(key); + if (!_cache.TryGet(key, out var source)) + { + source = pixels is null ? null : ToBitmap(pixels); + if (key.EndsWith("@" + SizePx, StringComparison.Ordinal)) _cache.Set(key, source); // não guarda tamanho antigo + } + if (Equals(image.Tag, key)) Show(image, fallback, source, key); + } + + private static void Show(Image image, UIElement fallback, ImageSource? source, string? key) + { + image.Source = source; + image.Visibility = source is null ? Visibility.Collapsed : Visibility.Visible; + fallback.Visibility = source is null ? Visibility.Visible : Visibility.Collapsed; + image.Tag = key; + } + + private static WriteableBitmap ToBitmap(IconImage icon) + { + var bitmap = new WriteableBitmap(icon.Width, icon.Height); + using (var stream = bitmap.PixelBuffer.AsStream()) stream.Write(icon.Pixels.Span); + bitmap.Invalidate(); + return bitmap; + } +} diff --git a/src/ControlFS.App/Views/MainWindow.cs b/src/ControlFS.App/Views/MainWindow.cs index 76db46a6..6aaccf98 100644 --- a/src/ControlFS.App/Views/MainWindow.cs +++ b/src/ControlFS.App/Views/MainWindow.cs @@ -28,6 +28,10 @@ public sealed class MainWindow : Window private readonly AppController _app; private readonly InputHost _input; private readonly GitHubReleaseUpdateService _updates; + private readonly ShellIconProvider _iconProvider = new(); + private readonly IconLoader _icons; + private IReadOnlyList? _shownPlaces; + private HashSet _specialFolders = new(StringComparer.OrdinalIgnoreCase); private readonly ContentControl _root = new() { IsTabStop = true, UseSystemFocusVisuals = false, HorizontalContentAlignment = HorizontalAlignment.Stretch, VerticalContentAlignment = VerticalAlignment.Stretch }; private readonly TextBlock _location = new() { FontSize = Theme.FontTitle, FontWeight = FontWeights.SemiBold, Foreground = Theme.Text, TextTrimming = TextTrimming.CharacterEllipsis }; private readonly TextBlock _badge = new() { FontSize = Theme.FontCaption, Foreground = Theme.Accent }; @@ -54,6 +58,12 @@ public MainWindow() _updates = GitHubReleaseUpdateService.CreateDefault(AppPaths.IsInstalled, Path.Join(AppPaths.DataDirectory, "updates")); _app = new AppController(new LocalFileSystemProvider(), new ArchiveService(), settingsStore, _updates, new WindowsShellService(), new FileOperationService()); _input = new InputHost(_app, DispatcherQueue); + _icons = new IconLoader(_iconProvider); + _icons.Invalidated += () => + { + _shownItems = null; // força recriar as linhas com ícones no novo tamanho + Render(); + }; AppLog.Info("MainWindow: serviços criados; montando layout"); Content = _root; @@ -65,7 +75,12 @@ public MainWindow() _input.OnKeyDown(e); }; _root.CharacterReceived += (_, e) => _input.OnCharacter(e.Character); - _root.Loaded += (_, _) => _root.Focus(FocusState.Programmatic); + _root.Loaded += (_, _) => + { + _root.Focus(FocusState.Programmatic); + _icons.SetScale(_root.XamlRoot.RasterizationScale); + _root.XamlRoot.Changed += (root, _) => _icons.SetScale(root.RasterizationScale); + }; Activated += (_, e) => { @@ -78,6 +93,7 @@ public MainWindow() _app.PrepareShutdown(); // instala em silêncio uma atualização verificada, se o usuário deixou ligado _input.Dispose(); _updates.Dispose(); + _iconProvider.Dispose(); }; _app.Changed += Render; @@ -123,8 +139,13 @@ private Grid BuildLayout() _list.Padding = new Thickness(Theme.SpaceM, 0, Theme.SpaceM, 0); _list.ContainerContentChanging += (_, args) => { - if (args.InRecycleQueue || args.Item is not FileEntry entry) return; - EntryRowTemplate.Fill(args.ItemContainer, entry, args.ItemIndex == _shownFocus, _shownSelection.Contains(entry.Id), _app.IsCut(entry)); + if (args.InRecycleQueue) + { + EntryRowTemplate.Recycle(args.ItemContainer, _icons); + return; + } + if (args.Item is not FileEntry entry) return; + EntryRowTemplate.Fill(args.ItemContainer, entry, args.ItemIndex == _shownFocus, _shownSelection.Contains(entry.Id), _app.IsCut(entry), _icons, _specialFolders); }; _list.ItemClick += (_, e) => { @@ -188,6 +209,12 @@ private void Render() : $"{op.Title} — {(op.Progress is { } p ? $"{p.ItemsProcessed}/{p.ItemsTotal?.ToString() ?? "?"}" : "…")} ({(op.State == OperationState.WaitingForUser ? "aguardando você" : "em andamento")})"; // Lista (a identidade dos itens decide se o ItemsSource muda) + if (!ReferenceEquals(_app.Places, _shownPlaces)) + { + // Pastas especiais (Downloads, Documentos…) mostram o ícone próprio também dentro das pastas do disco. + _shownPlaces = _app.Places; + _specialFolders = new HashSet(_app.Places.Where(p => p.Kind == EntryKind.KnownFolder && p.FullPath is not null).Select(p => p.FullPath!), StringComparer.OrdinalIgnoreCase); + } IReadOnlyList items = _app.Screen == Screen.Home ? _app.Places : pane.List.Items; var focus = _app.Screen == Screen.Home ? _app.PlacesFocus : pane.List.FocusIndex; var selection = _app.Screen == Screen.Home ? new HashSet() : pane.List.SelectedIds.ToHashSet(); diff --git a/src/ControlFS.Core/Contracts/IIconProvider.cs b/src/ControlFS.Core/Contracts/IIconProvider.cs new file mode 100644 index 00000000..7224551a --- /dev/null +++ b/src/ControlFS.Core/Contracts/IIconProvider.cs @@ -0,0 +1,57 @@ +using ControlFS.Core.Models; + +namespace ControlFS.Core.Contracts; + +/// Ícone já rasterizado: pixels BGRA de 32 bits com alfa pré-multiplicado, linha a linha de cima para baixo. +public sealed record IconImage(int Width, int Height, ReadOnlyMemory Pixels); + +public enum IconSourceKind +{ + /// Ícone do tipo de arquivo pela extensão, sem tocar no disco. + Extension, + + /// Pasta genérica, sem tocar no disco. + Folder, + + /// Ícone do item real (unidade, pasta especial, programa): o único caso que consulta o disco. + Path, +} + +/// +/// Pedido de ícone. identifica o ícone no cache: por tipo/extensão para os casos comuns e por caminho +/// só onde o ícone é próprio do item (unidades, pastas especiais, .exe/.ico). +/// +public sealed record IconRequest(string Key, IconSourceKind Kind, string Value) +{ + private static readonly HashSet PerFileExtensions = new(StringComparer.OrdinalIgnoreCase) { ".exe", ".ico" }; + + /// Ícone de uma linha da lista, ou null quando a linha usa só o símbolo de aviso (entrada bloqueada). + public static IconRequest? For(FileEntry entry, IReadOnlySet? specialFolders = null) + { + if (entry.IsBlocked) return null; + switch (entry.Kind) + { + case EntryKind.Drive or EntryKind.KnownFolder when entry.FullPath is { } place: + return ForPath(place); + case EntryKind.Directory when entry.FullPath is { } dir && specialFolders is not null && specialFolders.Contains(dir): + return ForPath(dir); + case EntryKind.Drive or EntryKind.KnownFolder or EntryKind.Directory or EntryKind.ArchiveDirectory: + return new IconRequest("folder", IconSourceKind.Folder, string.Empty); + case EntryKind.File when entry.FullPath is { } file && PerFileExtensions.Contains(entry.Extension): + return ForPath(file); + default: + // Entradas de compactados e arquivos comuns: só a extensão (nunca lê o disco). + var extension = entry.Extension.ToLowerInvariant(); + return new IconRequest("ext:" + extension, IconSourceKind.Extension, extension); + } + } + + private static IconRequest ForPath(string path) => new("path:" + path.ToUpperInvariant(), IconSourceKind.Path, path); +} + +/// Ícones do sistema. Implementações nunca bloqueiam quem chama: o trabalho acontece fora da thread de UI. +public interface IIconProvider +{ + /// Ícone com lado de pixels físicos, ou null quando não há (o chamador usa um símbolo). + Task GetIconAsync(IconRequest request, int sizePx, CancellationToken cancellationToken); +} diff --git a/src/ControlFS.Core/Icons/LruCache.cs b/src/ControlFS.Core/Icons/LruCache.cs new file mode 100644 index 00000000..d48f9166 --- /dev/null +++ b/src/ControlFS.Core/Icons/LruCache.cs @@ -0,0 +1,45 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ControlFS.Core.Icons; + +/// Cache limitado: ao passar da capacidade, descarta o item usado há mais tempo. Não é thread-safe. +public sealed class LruCache(int capacity) where TKey : notnull +{ + private readonly Dictionary> _map = []; + private readonly LinkedList<(TKey Key, TValue Value)> _order = new(); + + public int Capacity { get; } = capacity > 0 ? capacity : throw new ArgumentOutOfRangeException(nameof(capacity)); + + public int Count => _map.Count; + + public bool TryGet(TKey key, [MaybeNullWhen(false)] out TValue value) + { + if (_map.TryGetValue(key, out var node)) + { + _order.Remove(node); + _order.AddFirst(node); + value = node.Value.Value; + return true; + } + value = default; + return false; + } + + public void Set(TKey key, TValue value) + { + if (_map.Remove(key, out var existing)) _order.Remove(existing); + _map[key] = _order.AddFirst((key, value)); + while (_map.Count > Capacity) + { + var last = _order.Last!; + _order.RemoveLast(); + _map.Remove(last.Value.Key); + } + } + + public void Clear() + { + _map.Clear(); + _order.Clear(); + } +} diff --git a/src/ControlFS.Infrastructure.Windows/Shell/ShellIconProvider.cs b/src/ControlFS.Infrastructure.Windows/Shell/ShellIconProvider.cs new file mode 100644 index 00000000..e95ada16 --- /dev/null +++ b/src/ControlFS.Infrastructure.Windows/Shell/ShellIconProvider.cs @@ -0,0 +1,290 @@ +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using ControlFS.Core.Contracts; + +namespace ControlFS.Infrastructure.Windows.Shell; + +/// +/// Ícones do Shell do Windows (SHGetFileInfo + listas de imagens do sistema). Tudo roda numa thread STA dedicada: a thread +/// de UI nunca espera. Tipos comuns são pedidos por extensão com SHGFI_USEFILEATTRIBUTES (sem tocar no disco); só +/// unidades, pastas especiais e .exe/.ico consultam o item real. Fora do Windows devolve sempre null. +/// +public sealed partial class ShellIconProvider : IIconProvider, IDisposable +{ + private readonly BlockingCollection _queue = []; + private readonly Thread? _worker; + + private sealed record Work(IconRequest Request, int SizePx, TaskCompletionSource Result, CancellationToken Cancellation); + + public ShellIconProvider() + { + if (!OperatingSystem.IsWindows()) return; + _worker = new Thread(Run) { IsBackground = true, Name = "ControlFS shell icons" }; + _worker.SetApartmentState(ApartmentState.STA); + _worker.Start(); + } + + public Task GetIconAsync(IconRequest request, int sizePx, CancellationToken cancellationToken) + { + if (_worker is null || sizePx <= 0) return Task.FromResult(null); + if (cancellationToken.IsCancellationRequested) return Task.FromCanceled(cancellationToken); + var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + try + { + _queue.Add(new Work(request, sizePx, result, cancellationToken), CancellationToken.None); + } + catch (InvalidOperationException) + { + result.TrySetResult(null); // já descartado + } + return result.Task; + } + + public void Dispose() => _queue.CompleteAdding(); + + private void Run() + { + foreach (var work in _queue.GetConsumingEnumerable()) + { + // Linha que saiu da tela antes da vez dela: não gasta tempo com o Shell. + if (work.Cancellation.IsCancellationRequested) + { + work.Result.TrySetCanceled(work.Cancellation); + continue; + } + IconImage? image = null; + try + { + if (OperatingSystem.IsWindows()) image = Extract(work.Request, work.SizePx); + } + catch (Exception ex) when (ex is COMException or ExternalException or ArgumentException or OutOfMemoryException) + { + image = null; + } + work.Result.TrySetResult(image); + } + } + + // ---------- Win32 ---------- + + private const uint FileAttributeDirectory = 0x10; + private const uint FileAttributeNormal = 0x80; + private const uint ShgfiSysIconIndex = 0x4000; + private const uint ShgfiUseFileAttributes = 0x10; + private const int ShilLarge = 0; // 32 px + private const int ShilSmall = 1; // 16 px + private const int ShilExtraLarge = 2; // 48 px + private const int ShilJumbo = 4; // 256 px + private const int IldTransparent = 0x1; + private static readonly Guid IidImageList = new("46EB5926-582E-4017-9FDF-E8998DAA0950"); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private unsafe struct ShFileInfo + { + public nint HIcon; + public int IIcon; + public uint Attributes; + public fixed char DisplayName[260]; + public fixed char TypeName[80]; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IconInfo + { + public int FIcon; + public int XHotspot; + public int YHotspot; + public nint HbmMask; + public nint HbmColor; + } + + [StructLayout(LayoutKind.Sequential)] + private struct Bitmap + { + public int Type; + public int Width; + public int Height; + public int WidthBytes; + public ushort Planes; + public ushort BitsPixel; + public nint Bits; + } + + [StructLayout(LayoutKind.Sequential)] + private struct BitmapInfoHeader + { + public uint Size; + public int Width; + public int Height; + public ushort Planes; + public ushort BitCount; + public uint Compression; + public uint SizeImage; + public int XPelsPerMeter; + public int YPelsPerMeter; + public uint ClrUsed; + public uint ClrImportant; + } + + [SupportedOSPlatform("windows")] + private static unsafe IconImage? Extract(IconRequest request, int sizePx) + { + var (name, attributes, flags) = request.Kind switch + { + IconSourceKind.Folder => ("pasta", FileAttributeDirectory, ShgfiSysIconIndex | ShgfiUseFileAttributes), + IconSourceKind.Extension => ("arquivo" + request.Value, FileAttributeNormal, ShgfiSysIconIndex | ShgfiUseFileAttributes), + _ => (request.Value, 0u, ShgfiSysIconIndex), + }; + ShFileInfo info = default; + if (SHGetFileInfoW(name, attributes, &info, (uint)sizeof(ShFileInfo), flags) == 0) return null; + + var list = sizePx <= 16 ? ShilSmall : sizePx <= 32 ? ShilLarge : sizePx <= 48 ? ShilExtraLarge : ShilJumbo; + var image = FromImageList(list, info.IIcon); + // Tipos sem arte de 256 px vêm pequenos no canto da imagem "jumbo": nesse caso, usa a de 48 px. + if (list == ShilJumbo && image is not null && ContentExtent(image) <= 48) image = FromImageList(ShilExtraLarge, info.IIcon) ?? image; + return image; + } + + [SupportedOSPlatform("windows")] + private static unsafe IconImage? FromImageList(int list, int index) + { + var iid = IidImageList; + if (SHGetImageList(list, &iid, out var imageList) != 0 || imageList == 0) return null; + try + { + // IImageList::GetIcon é o 8º método depois de IUnknown (slot 10 da vtable). + var vtable = *(nint**)imageList; + var getIcon = (delegate* unmanaged[Stdcall])vtable[10]; + nint icon; + if (getIcon(imageList, index, IldTransparent, &icon) != 0 || icon == 0) return null; + try + { + return FromIcon(icon); + } + finally + { + _ = DestroyIcon(icon); + } + } + finally + { + var release = (delegate* unmanaged[Stdcall])(*(nint**)imageList)[2]; + _ = release(imageList); + } + } + + [SupportedOSPlatform("windows")] + private static unsafe IconImage? FromIcon(nint icon) + { + if (!GetIconInfo(icon, out var info)) return null; + try + { + if (info.HbmColor == 0) return null; // ícone monocromático: fica o símbolo + Bitmap bitmap; + if (GetObjectW(info.HbmColor, sizeof(Bitmap), &bitmap) == 0 || bitmap.Width <= 0 || bitmap.Height <= 0) return null; + var width = bitmap.Width; + var height = bitmap.Height; + var pixels = ReadBits(info.HbmColor, width, height); + if (pixels is null) return null; + + var hasAlpha = false; + for (var i = 3; i < pixels.Length && !hasAlpha; i += 4) hasAlpha = pixels[i] != 0; + if (!hasAlpha) + { + // Ícones antigos sem canal alfa: a máscara diz o que é transparente (branco) e o que é opaco (preto). + var mask = info.HbmMask != 0 ? ReadBits(info.HbmMask, width, height) : null; + for (var i = 0; i < pixels.Length; i += 4) + pixels[i + 3] = mask is not null && mask[i] != 0 ? (byte)0 : (byte)255; + } + for (var i = 0; i < pixels.Length; i += 4) + { + var a = pixels[i + 3]; + if (a == 255) continue; + pixels[i] = (byte)(pixels[i] * a / 255); + pixels[i + 1] = (byte)(pixels[i + 1] * a / 255); + pixels[i + 2] = (byte)(pixels[i + 2] * a / 255); + } + return new IconImage(width, height, pixels); + } + finally + { + if (info.HbmColor != 0) _ = DeleteObject(info.HbmColor); + if (info.HbmMask != 0) _ = DeleteObject(info.HbmMask); + } + } + + [SupportedOSPlatform("windows")] + private static unsafe byte[]? ReadBits(nint hbitmap, int width, int height) + { + var header = new BitmapInfoHeader + { + Size = (uint)sizeof(BitmapInfoHeader), + Width = width, + Height = -height, // de cima para baixo + Planes = 1, + BitCount = 32, + }; + var pixels = new byte[width * height * 4]; + var dc = GetDC(0); + try + { + fixed (byte* bits = pixels) + return GetDIBits(dc, hbitmap, 0, (uint)height, bits, &header, 0) == height ? pixels : null; + } + finally + { + _ = ReleaseDC(0, dc); + } + } + + /// Maior coordenada (x ou y) com pixel visível: mede o tamanho real do desenho dentro da imagem. + private static int ContentExtent(IconImage image) + { + var span = image.Pixels.Span; + var extent = 0; + for (var y = 0; y < image.Height; y++) + for (var x = 0; x < image.Width; x++) + if (span[((y * image.Width) + x) * 4 + 3] != 0) extent = Math.Max(extent, Math.Max(x, y) + 1); + return extent; + } + + [LibraryImport("shell32.dll", StringMarshalling = StringMarshalling.Utf16)] + [SupportedOSPlatform("windows")] + private static unsafe partial nint SHGetFileInfoW(string pszPath, uint dwFileAttributes, ShFileInfo* psfi, uint cbFileInfo, uint uFlags); + + [LibraryImport("shell32.dll")] + [SupportedOSPlatform("windows")] + private static unsafe partial int SHGetImageList(int iImageList, Guid* riid, out nint ppvObj); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + [SupportedOSPlatform("windows")] + private static partial bool GetIconInfo(nint hIcon, out IconInfo piconinfo); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + [SupportedOSPlatform("windows")] + private static partial bool DestroyIcon(nint hIcon); + + [LibraryImport("user32.dll")] + [SupportedOSPlatform("windows")] + private static partial nint GetDC(nint hWnd); + + [LibraryImport("user32.dll")] + [SupportedOSPlatform("windows")] + private static partial int ReleaseDC(nint hWnd, nint hDC); + + [LibraryImport("gdi32.dll")] + [SupportedOSPlatform("windows")] + private static unsafe partial int GetObjectW(nint h, int c, void* pv); + + [LibraryImport("gdi32.dll")] + [SupportedOSPlatform("windows")] + private static unsafe partial int GetDIBits(nint hdc, nint hbm, uint start, uint cLines, void* lpvBits, BitmapInfoHeader* lpbmi, uint usage); + + [LibraryImport("gdi32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + [SupportedOSPlatform("windows")] + private static partial bool DeleteObject(nint ho); +} diff --git a/tests/ControlFS.UnitTests/Core/IconRequestTests.cs b/tests/ControlFS.UnitTests/Core/IconRequestTests.cs new file mode 100644 index 00000000..01d2226c --- /dev/null +++ b/tests/ControlFS.UnitTests/Core/IconRequestTests.cs @@ -0,0 +1,45 @@ +using ControlFS.Core.Contracts; +using ControlFS.Core.Icons; +using ControlFS.Core.Models; + +namespace ControlFS.UnitTests.Core; + +public class IconRequestTests +{ + [Fact] + public void Common_types_and_archive_entries_are_keyed_by_extension_and_never_touch_the_disk() + { + var special = new HashSet(StringComparer.OrdinalIgnoreCase) { @"C:\Users\ana\Downloads" }; + + var txt = IconRequest.For(new FileEntry("a", "Nota.TXT", EntryKind.File, FullPath: @"C:\x\Nota.TXT"))!; + Assert.Equal((IconSourceKind.Extension, "ext:.txt"), (txt.Kind, txt.Key)); + Assert.Equal(txt.Key, IconRequest.For(new FileEntry("b", "outra.txt", EntryKind.File, FullPath: @"D:\y\outra.txt"))!.Key); + + // Dentro de um compactado, até um .exe usa só a extensão (o arquivo não existe no disco). + var inArchive = IconRequest.For(new FileEntry("arc:setup.exe", "setup.exe", EntryKind.ArchiveFile))!; + Assert.Equal(IconSourceKind.Extension, inArchive.Kind); + Assert.Equal(IconSourceKind.Folder, IconRequest.For(new FileEntry("arc:dir/", "dir", EntryKind.ArchiveDirectory))!.Kind); + + // Programas, unidades e pastas especiais têm ícone próprio: pedido pelo caminho. + Assert.Equal(IconSourceKind.Path, IconRequest.For(new FileEntry("s", "setup.exe", EntryKind.File, FullPath: @"C:\x\setup.exe"))!.Kind); + Assert.Equal(IconSourceKind.Path, IconRequest.For(new FileEntry("drive:C:\\", "C:", EntryKind.Drive, FullPath: @"C:\"))!.Kind); + Assert.Equal(IconSourceKind.Path, IconRequest.For(new FileEntry("d", "Downloads", EntryKind.Directory, FullPath: @"c:\users\ana\downloads"), special)!.Kind); + Assert.Equal("folder", IconRequest.For(new FileEntry("p", "Projetos", EntryKind.Directory, FullPath: @"C:\Users\ana\Projetos"), special)!.Key); + + Assert.Null(IconRequest.For(new FileEntry("x", "con", EntryKind.ArchiveFile, BlockedReason: "nome reservado"))); + } + + [Fact] + public void Icon_cache_is_bounded_and_evicts_the_least_recently_used() + { + var cache = new LruCache(2); + cache.Set("a", 1); + cache.Set("b", 2); + Assert.True(cache.TryGet("a", out _)); // "a" passa a ser o mais recente + cache.Set("c", 3); + Assert.Equal(2, cache.Count); + Assert.False(cache.TryGet("b", out _)); + Assert.True(cache.TryGet("a", out var a)); + Assert.Equal(1, a); + } +} diff --git a/tests/ControlFS.WindowsIntegrationTests/ShellIconIntegrationTests.cs b/tests/ControlFS.WindowsIntegrationTests/ShellIconIntegrationTests.cs new file mode 100644 index 00000000..63b24035 --- /dev/null +++ b/tests/ControlFS.WindowsIntegrationTests/ShellIconIntegrationTests.cs @@ -0,0 +1,53 @@ +using ControlFS.Core.Contracts; +using ControlFS.Core.Models; +using ControlFS.Infrastructure.Windows.Shell; +using Xunit; + +namespace ControlFS.WindowsIntegrationTests; + +/// Ícones reais do Shell do Windows. Nitidez em 4K e no portátil fica na verificação manual (docs/TESTING.md). +public sealed class ShellIconIntegrationTests : IDisposable +{ + private readonly string _root = Path.Join(Path.GetTempPath(), "controlfs-icon-tests", Guid.NewGuid().ToString("N")); + private readonly ShellIconProvider _icons = new(); + + public ShellIconIntegrationTests() + { + if (!OperatingSystem.IsWindows()) Assert.Skip("Requer Windows."); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + _icons.Dispose(); + try { Directory.Delete(_root, recursive: true); } catch (IOException) { } catch (UnauthorizedAccessException) { } + } + + [Theory] + [InlineData(32)] + [InlineData(64)] + public async Task Text_files_folders_and_drives_get_a_visible_icon_of_the_requested_scale(int size) + { + var file = Path.Join(_root, "nota.txt"); + File.WriteAllText(file, "x"); + var drive = Path.GetPathRoot(Environment.SystemDirectory)!; + var requests = new[] + { + IconRequest.For(new FileEntry("nota.txt", "nota.txt", EntryKind.File, FullPath: file))!, + IconRequest.For(new FileEntry("pasta", "pasta", EntryKind.Directory, FullPath: _root))!, + IconRequest.For(new FileEntry("drive:" + drive, drive, EntryKind.Drive, FullPath: drive))!, + }; + foreach (var request in requests) + { + var icon = await _icons.GetIconAsync(request, size, TestContext.Current.CancellationToken); + Assert.NotNull(icon); + Assert.InRange(icon.Width, 16, 256); + Assert.Equal(icon.Width * icon.Height * 4, icon.Pixels.Length); + // Não é uma imagem vazia: há pixels visíveis. + var span = icon.Pixels.Span; + var visible = 0; + for (var i = 3; i < span.Length; i += 4) if (span[i] != 0) visible++; + Assert.True(visible > icon.Width, $"{request.Key}: ícone sem pixels visíveis"); + } + } +}