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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Before publishing a version, add a `## [VERSION]` section to **both** files: the release workflow uses the section matching the tag and fails if either is missing.

## [Unreleased]
### Fixes
- **Recognizable icon for .rar files** (#274): with no associated program, Windows showed a blank page. ControlFS now uses the icon Windows registered for .rar (WinRAR's, for example) and, when there is none, its own archive icon (a box with a zipper, drawn in the app, no third-party artwork). It applies to List and Grid at any scale, and the icon refreshes by itself if you install or change the .rar program.

## [0.12.0-alpha.1]
### What's new
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CHANGELOG.en-US.md`. Antes de publicar uma versão, adicione uma seção `## [VERSÃO]` **nos dois arquivos**. O workflow de release usa a seção da tag e falha se faltar alguma.

## [Unreleased]
### Correções
- **Arquivos .rar com ícone reconhecível** (#274): sem programa associado, o Windows mostrava uma página em branco. Agora o ControlFS usa o ícone que o Windows registrou para .rar (o do WinRAR, por exemplo) e, quando não há nenhum, um ícone de arquivo compactado próprio (uma caixa com zíper, desenhado no app, sem arte de terceiros). Vale na lista e na grade, em qualquer escala, e o ícone se atualiza sozinho se você instalar ou trocar o programa de .rar.

## [0.12.0-alpha.1]
### Novidades
Expand Down
1 change: 1 addition & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ Ainda manual, num aparelho real:
- [ ] Windows → Acessibilidade → Tamanho do texto em 150% e 200% com o app aberto: o layout se ajusta na hora, nada
essencial fica cortado (menus rolam até o item focado).
- [ ] Arrastar a janela entre um monitor 100% e outro 150%/200%: textos e ícones nítidos, sem reiniciar.
- [ ] .rar: com WinRAR instalado, o ícone é o dele na lista e na grade; sem programa para .rar, aparece a caixa roxa com zíper (nunca a página em branco). Instalar/trocar o programa com o ControlFS aberto atualiza o ícone em até alguns segundos. Não validado em hardware.

## Modais (#172)

Expand Down
8 changes: 8 additions & 0 deletions src/ControlFS.App/Controls/IconLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public sealed class IconLoader(IIconProvider provider, double iconSize = IconLoa
public const double TileIconSize = 64;

private readonly LruCache<string, ImageSource?> _cache = new(capacity);
private long _revision = provider.Revision;
private readonly Dictionary<string, Flight> _inflight = [];
private readonly ConditionalWeakTable<Image, CancellationTokenSource> _pending = [];

Expand Down Expand Up @@ -61,6 +62,13 @@ public void Load(Image image, UIElement fallback, IconRequest? request)
Show(image, fallback, null, null);
return;
}
if (provider.Revision is var revision && revision != _revision)
{
// A associação de um tipo mudou (ex.: instalou o WinRAR): os ícones em cache podem estar velhos.
_revision = revision;
_cache.Clear();
_inflight.Clear();
}
var key = request.Key + "@" + SizePx;
image.Tag = key;
if (_cache.TryGet(key, out var cached))
Expand Down
6 changes: 6 additions & 0 deletions src/ControlFS.Core/Contracts/IIconProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,10 @@ public interface IIconProvider
{
/// <summary>Ícone com lado de <paramref name="sizePx"/> pixels físicos, ou <c>null</c> quando não há (o chamador usa um símbolo).</summary>
Task<IconImage?> GetIconAsync(IconRequest request, int sizePx, CancellationToken cancellationToken);

/// <summary>
/// Muda quando algo que altera os ícones já entregues mudou fora do app (ex.: o programa associado a .rar): o chamador
/// descarta o cache. Barato de ler; implementações sem essa noção devolvem sempre o mesmo valor.
/// </summary>
long Revision => 0;
}
83 changes: 83 additions & 0 deletions src/ControlFS.Core/Icons/ArchiveGlyph.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using ControlFS.Core.Contracts;

namespace ControlFS.Core.Icons;

/// <summary>
/// Ícone de reserva para arquivos compactados quando o Windows não tem ícone para a extensão (#274): uma caixa roxa com
/// tampa e um zíper. Desenho original, feito aqui em código (nenhuma arte de terceiros), nítido em qualquer tamanho porque
/// é rasterizado direto no tamanho pedido, com suavização de bordas (3×3 amostras por pixel).
/// </summary>
public static class ArchiveGlyph
{
private const int Samples = 3;

// Cores em RGB (não pré-multiplicado); o resultado sai BGRA pré-multiplicado, como o resto dos ícones.
private static readonly (byte R, byte G, byte B) Body = (0x7A, 0x5C, 0xE0);
private static readonly (byte R, byte G, byte B) Lid = (0x5B, 0x3F, 0xC4);
private static readonly (byte R, byte G, byte B) Zip = (0xF4, 0xF1, 0xFF);

public static IconImage Render(int sizePx)
{
var size = Math.Max(16, sizePx);
var pixels = new byte[size * size * 4];
for (var y = 0; y < size; y++)
{
for (var x = 0; x < size; x++)
{
double r = 0, g = 0, b = 0, a = 0;
for (var sy = 0; sy < Samples; sy++)
{
for (var sx = 0; sx < Samples; sx++)
{
var u = (x + ((sx + 0.5) / Samples)) / size;
var v = (y + ((sy + 0.5) / Samples)) / size;
if (Sample(u, v) is not { } color) continue;
r += color.R; g += color.G; b += color.B; a += 1;
}
}
if (a == 0) continue;
var coverage = a / (Samples * Samples);
var i = ((y * size) + x) * 4;
// Média das cores das amostras cobertas, pré-multiplicada pela cobertura.
pixels[i] = (byte)Math.Round(b / a * coverage);
pixels[i + 1] = (byte)Math.Round(g / a * coverage);
pixels[i + 2] = (byte)Math.Round(r / a * coverage);
pixels[i + 3] = (byte)Math.Round(255 * coverage);
}
}
return new IconImage(size, size, pixels);
}

/// <summary>Cor no ponto (u, v) de 0 a 1, ou <c>null</c> se fora do desenho.</summary>
private static (byte R, byte G, byte B)? Sample(double u, double v)
{
// Tampa: faixa de cima, um pouco mais larga que o corpo.
if (InRoundedRect(u, v, 0.14, 0.12, 0.86, 0.36, 0.06))
{
// Zíper: dentes alternados numa coluna central que atravessa a tampa e o corpo.
return InZip(u, v) ? Zip : Lid;
}
if (InRoundedRect(u, v, 0.20, 0.32, 0.80, 0.90, 0.06))
return InZip(u, v) ? Zip : Body;
return null;
}

private static bool InZip(double u, double v)
{
if (v < 0.12 || v > 0.72 || u < 0.455 || u > 0.545) return false;
// Dentes: quadrados alternados à esquerda e à direita do centro, de 0,06 de altura.
var row = (int)Math.Floor((v - 0.12) / 0.06);
var left = u < 0.5;
return left == (row % 2 == 0);
}

private static bool InRoundedRect(double u, double v, double x0, double y0, double x1, double y1, double radius)
{
if (u < x0 || u > x1 || v < y0 || v > y1) return false;
var cx = u < x0 + radius ? x0 + radius : u > x1 - radius ? x1 - radius : u;
var cy = v < y0 + radius ? y0 + radius : v > y1 - radius ? y1 - radius : v;
var dx = u - cx;
var dy = v - cy;
return (dx * dx) + (dy * dy) <= radius * radius;
}
}
55 changes: 55 additions & 0 deletions src/ControlFS.Infrastructure.Windows/Shell/ShellIconProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using ControlFS.Core.Contracts;
using ControlFS.Core.Icons;
using Microsoft.Win32;

namespace ControlFS.Infrastructure.Windows.Shell;

Expand Down Expand Up @@ -45,6 +47,46 @@ public ShellIconProvider()

public void Dispose() => _queue.CompleteAdding();

// ---------- Associação de .rar (#274) ----------

private static readonly string[] ArchiveExtensions = [".rar"];
private long _revision;
private long _revisionChecked = long.MinValue;
private int _revisionHash;

/// <summary>
/// Muda quando o programa associado a .rar (ou o ícone dele) muda no registro. Lido no máximo a cada 2 s, sem tocar no disco.
/// </summary>
public long Revision
{
get
{
if (!OperatingSystem.IsWindows()) return 0;
var now = Environment.TickCount64;
if (now - _revisionChecked < 2000) return _revision;
_revisionChecked = now;
var hash = AssociationFingerprint(".rar");
if (hash != _revisionHash) { _revisionHash = hash; _revision++; }
return _revision;
}
}

[SupportedOSPlatform("windows")]
private static int AssociationFingerprint(string extension)
{
try
{
var progId = Registry.GetValue(@"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\" + extension + @"\UserChoice", "ProgId", null) as string
?? Registry.ClassesRoot.OpenSubKey(extension)?.GetValue(null) as string;
var icon = progId is null ? null : Registry.ClassesRoot.OpenSubKey(progId + @"\DefaultIcon")?.GetValue(null) as string;
return HashCode.Combine(progId, icon);
}
catch (Exception ex) when (ex is System.Security.SecurityException or IOException or UnauthorizedAccessException or ArgumentException)
{
return 0;
}
}

private void Run()
{
foreach (var work in _queue.GetConsumingEnumerable())
Expand Down Expand Up @@ -156,13 +198,26 @@ private struct BitmapInfoHeader
}
else if (SHGetFileInfoW(name, attributes, &info, (uint)sizeof(ShFileInfo), flags) == 0) return null;

// Compactado sem programa associado: o Shell devolve a página em branco genérica; aqui entra o ícone de reserva.
if (request.Kind == IconSourceKind.Extension && Array.IndexOf(ArchiveExtensions, request.Value) >= 0 && IsGenericFileIcon(info.IIcon))
return ArchiveGlyph.Render(sizePx);

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;
}

/// <summary>O ícone é o de arquivo sem tipo (o mesmo de uma extensão que ninguém registrou)?</summary>
[SupportedOSPlatform("windows")]
private static unsafe bool IsGenericFileIcon(int iconIndex)
{
ShFileInfo generic = default;
return SHGetFileInfoW("arquivo.controlfs-sem-tipo", FileAttributeNormal, &generic, (uint)sizeof(ShFileInfo), ShgfiSysIconIndex | ShgfiUseFileAttributes) != 0
&& generic.IIcon == iconIndex;
}

/// <summary>
/// Ícone de um atalho. .url (jogo da Steam): <c>IconFile</c>/<c>IconIndex</c> do atalho e, se o arquivo não existir,
/// o mesmo ícone na instalação local da Steam. .lnk: o ícone declarado; sem ele, o do destino (programa .exe/.ico
Expand Down
26 changes: 26 additions & 0 deletions tests/ControlFS.UnitTests/Core/ArchiveGlyphTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using ControlFS.Core.Icons;
using Xunit;

namespace ControlFS.UnitTests.Core;

public sealed class ArchiveGlyphTests
{
[Theory]
[InlineData(16)]
[InlineData(48)]
[InlineData(256)]
public void The_fallback_archive_icon_is_drawn_at_the_requested_size_with_transparent_corners_and_visible_body(int size)
{
var icon = ArchiveGlyph.Render(size);
var pixels = icon.Pixels.Span;

Assert.Equal(size, icon.Width);
Assert.Equal(size, icon.Height);
Assert.Equal(size * size * 4, pixels.Length);
Assert.Equal(0, pixels[3]); // canto de cima à esquerda: transparente
var centre = (((size / 2) * size) + (size / 2)) * 4;
Assert.Equal(255, pixels[centre + 3]); // o corpo no centro: opaco
for (var i = 0; i < pixels.Length; i += 4)
Assert.True(pixels[i] <= pixels[i + 3] && pixels[i + 1] <= pixels[i + 3] && pixels[i + 2] <= pixels[i + 3], "alfa pré-multiplicado");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,13 @@ public async Task Text_files_folders_drives_and_the_recycle_bin_get_a_visible_ic
Assert.True(visible > icon.Width, $"{request.Key}: ícone sem pixels visíveis");
}
}

[Fact]
public async Task A_rar_file_gets_an_icon_even_when_windows_has_no_program_for_it()
{
var request = IconRequest.For(new FileEntry("a.rar", "a.rar", EntryKind.File, FullPath: Path.Join(_root, "a.rar")))!;
var icon = await _icons.GetIconAsync(request, 48, TestContext.Current.CancellationToken);
Assert.NotNull(icon); // o do Windows, ou o de reserva do ControlFS: nunca a página em branco nem nada
Assert.InRange(icon.Width, 16, 256);
}
}
Loading