diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/BinaryIconReference.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/BinaryIconReference.cs new file mode 100644 index 0000000000..d1940190da --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/BinaryIconReference.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.Helpers; + +internal readonly record struct BinaryIconReference(string Path, int Index); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/FontIconGlyphClassifier.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/FontIconGlyphClassifier.cs new file mode 100644 index 0000000000..30891d2a2e --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/FontIconGlyphClassifier.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Buffers; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal static partial class FontIconGlyphClassifier +{ + private const string FluentIconFontFamily = "Segoe Fluent Icons, Segoe MDL2 Assets"; + private const string EmojiFontFamily = "Segoe UI Emoji, Segoe UI"; + private const string GeneralFontFamily = "Segoe UI"; + private const int EmojiPresentationProperty = 58; + private const int FluentIconsPrivateUseAreaStart = 0xE700; + private const int FluentIconsPrivateUseAreaEnd = 0xF8FF; + private const int TextVariationSelector = 0xFE0E; + private const int EmojiVariationSelector = 0xFE0F; + + public static FontIconGlyphKind Classify(string? text) + { + if (string.IsNullOrEmpty(text)) + { + return FontIconGlyphKind.None; + } + + // Most CmdPal glyphs are one UTF-16 code unit in the Fluent icon PUA. + if (text.Length == 1) + { + var character = text[0]; + if (char.IsHighSurrogate(character)) + { + return FontIconGlyphKind.Invalid; + } + + if (IsFluentIconPua(character)) + { + return FontIconGlyphKind.FluentSymbol; + } + + return IsEmoji(text) ? FontIconGlyphKind.Emoji : FontIconGlyphKind.Other; + } + + // Two adjacent ASCII characters cannot be the single glyph expected here. This + // rejects common paths without paying for Unicode grapheme segmentation. + if (text[0] <= 0x7F && text[1] <= 0x7F) + { + return FontIconGlyphKind.Invalid; + } + + var textElementLength = StringInfo.GetNextTextElementLength(text.AsSpan()); + if (textElementLength == 0) + { + return FontIconGlyphKind.None; + } + + if (textElementLength != text.Length) + { + return FontIconGlyphKind.Invalid; + } + + return IsEmoji(text) ? FontIconGlyphKind.Emoji : FontIconGlyphKind.Other; + } + + public static string GetFontFamily(FontIconGlyphKind glyphKind, string? requestedFontFamily) + { + if (glyphKind == FontIconGlyphKind.Invalid) + { + return GeneralFontFamily; + } + + if (!string.IsNullOrEmpty(requestedFontFamily)) + { + return requestedFontFamily; + } + + return glyphKind switch + { + FontIconGlyphKind.FluentSymbol => FluentIconFontFamily, + FontIconGlyphKind.Emoji => EmojiFontFamily, + _ => GeneralFontFamily, + }; + } + + private static bool IsFluentIconPua(int codePoint) => + codePoint is >= FluentIconsPrivateUseAreaStart and <= FluentIconsPrivateUseAreaEnd; + + private static bool IsEmoji(string text) + { + foreach (var codePoint in text.EnumerateRunes()) + { + if (codePoint.Value == EmojiVariationSelector) + { + return true; + } + + if (codePoint.Value == TextVariationSelector) + { + return false; + } + } + + var status = Rune.DecodeFromUtf16(text.AsSpan(), out var first, out _); + return status == OperationStatus.Done && NativeMethods.HasBinaryProperty(first.Value, EmojiPresentationProperty) != 0; + } + + private static partial class NativeMethods + { + // ICU lazily loads emoji property data behind a one-time lock, so this call + // cannot safely suppress its GC transition. + [LibraryImport("icu.dll", EntryPoint = "u_hasBinaryProperty")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + internal static partial byte HasBinaryProperty(int codePoint, int property); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/FontIconGlyphKind.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/FontIconGlyphKind.cs new file mode 100644 index 0000000000..7d2657cf8a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/FontIconGlyphKind.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.Helpers; + +internal enum FontIconGlyphKind +{ + Invalid = -1, + None = 0, + Emoji = 1, + FluentSymbol = 2, + Other = 3, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoaderService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoaderService.cs index bece98f3f4..7a76ca8dd3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoaderService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoaderService.cs @@ -5,7 +5,6 @@ using System.Diagnostics.CodeAnalysis; using CommunityToolkit.WinUI; using ManagedCommon; -using Microsoft.Terminal.UI; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; @@ -98,9 +97,10 @@ internal sealed partial class IconLoaderService : IIconLoaderService private FontFamily GetOrCreateFontFamily(FontIconGlyphKind glyphKind, string? requestedFontFamily) { + var familySource = FontIconGlyphClassifier.GetFontFamily(glyphKind, requestedFontFamily); if (!string.IsNullOrEmpty(requestedFontFamily)) { - return new FontFamily(requestedFontFamily); + return new FontFamily(familySource); } // TryLoadGlyph gates this method to the service's dispatcher thread, so these @@ -108,10 +108,10 @@ internal sealed partial class IconLoaderService : IIconLoaderService return glyphKind switch { FontIconGlyphKind.FluentSymbol => - _fluentIconFontFamily ??= new FontFamily("Segoe Fluent Icons, Segoe MDL2 Assets"), + _fluentIconFontFamily ??= new FontFamily(familySource), FontIconGlyphKind.Emoji => - _emojiFontFamily ??= new FontFamily("Segoe UI Emoji, Segoe UI"), - _ => _generalFontFamily ??= new FontFamily("Segoe UI"), + _emojiFontFamily ??= new FontFamily(familySource), + _ => _generalFontFamily ??= new FontFamily(familySource), }; } @@ -216,37 +216,89 @@ internal sealed partial class IconLoaderService : IIconLoaderService if (!string.IsNullOrEmpty(iconString)) { - var dispatcherEnqueuedAt = diagnostics?.BeginDispatcherWait( - IconDispatcherMaterializationKind.Unknown) ?? 0; + var preparationStartedAt = diagnostics?.BeginBackgroundPreparation() ?? 0; + var targetSize = scaledSize.IsEmpty + ? DefaultIconSize + : (int)Math.Max(scaledSize.Width, scaledSize.Height); + var preparedIcon = IconPathConverter.Prepare(iconString, fontFamily, targetSize); + diagnostics?.CompleteBackgroundPreparation(preparationStartedAt); + try { - return await _dispatcherQueue - .EnqueueAsync( - () => + var materializationKind = diagnostics is null + ? IconDispatcherMaterializationKind.Unknown + : GetDispatcherMaterializationKind(preparedIcon); + var dispatcherEnqueuedAt = diagnostics?.BeginDispatcherWait(materializationKind) ?? 0; + try + { + return await _dispatcherQueue + .EnqueueAsync(CreateIconSourceOnDispatcher, LoadingPriorityOnDispatcher) + .ConfigureAwait(false); + } + catch + { + // This is a no-op after the callback has started or completed. + diagnostics?.DispatcherWaitFailed(dispatcherEnqueuedAt); + throw; + } + + async Task CreateIconSourceOnDispatcher() + { + var dispatcherStartedAt = diagnostics?.DispatcherStarted(dispatcherEnqueuedAt) ?? 0; + var suspensionStartedAt = 0L; + var continuationStartedAt = 0L; + try + { + var operation = IconPathConverter.CreateIconSourceAsync(preparedIcon); + if (operation.IsCompleted) { - var dispatcherStartedAt = diagnostics?.DispatcherStarted(dispatcherEnqueuedAt) ?? 0; - try + var synchronousResult = await operation; + diagnostics?.SetResult(synchronousResult); + return synchronousResult; + } + + suspensionStartedAt = diagnostics?.DispatcherUiSliceCompleted( + dispatcherStartedAt, + IconDispatcherUiSliceKind.BeforeAsyncSuspension) ?? 0; + IconSource result; + try + { + result = await operation; + } + finally + { + if (suspensionStartedAt != 0) { - var result = GetStringIconSource(iconString, fontFamily, scaledSize); - diagnostics?.SetResult(result); - return result; + continuationStartedAt = diagnostics?.DispatcherAsyncSuspensionCompleted( + suspensionStartedAt) ?? 0; } - finally - { - diagnostics?.DispatcherUiSliceCompleted( - dispatcherStartedAt, - IconDispatcherUiSliceKind.SynchronousCallback); - diagnostics?.DispatcherCompleted(dispatcherStartedAt); - } - }, - LoadingPriorityOnDispatcher) - .ConfigureAwait(false); + } + + diagnostics?.SetResult(result); + return result; + } + finally + { + if (suspensionStartedAt == 0) + { + diagnostics?.DispatcherUiSliceCompleted( + dispatcherStartedAt, + IconDispatcherUiSliceKind.SynchronousCallback); + } + else if (continuationStartedAt != 0) + { + diagnostics?.DispatcherUiSliceCompleted( + continuationStartedAt, + IconDispatcherUiSliceKind.AsyncContinuation); + } + + diagnostics?.DispatcherCompleted(dispatcherStartedAt); + } + } } - catch + finally { - // This is a no-op after the callback has started or completed. - diagnostics?.DispatcherWaitFailed(dispatcherEnqueuedAt); - throw; + preparedIcon.Dispose(); } } @@ -336,6 +388,18 @@ internal sealed partial class IconLoaderService : IIconLoaderService return null; } + private static IconDispatcherMaterializationKind GetDispatcherMaterializationKind( + IconPathConverter.PreparedIcon preparedIcon) => + preparedIcon.Kind switch + { + IconPathConverter.PreparedIconKind.Empty => IconDispatcherMaterializationKind.Empty, + IconPathConverter.PreparedIconKind.BitmapUri => IconDispatcherMaterializationKind.BitmapUri, + IconPathConverter.PreparedIconKind.SvgUri => IconDispatcherMaterializationKind.SvgUri, + IconPathConverter.PreparedIconKind.Glyph => IconDispatcherMaterializationKind.Glyph, + IconPathConverter.PreparedIconKind.Binary => IconDispatcherMaterializationKind.Binary, + _ => IconDispatcherMaterializationKind.Unknown, + }; + private static void ApplyDecodeSize(BitmapImage bitmap, Size size) { if (size.IsEmpty) @@ -353,14 +417,6 @@ internal sealed partial class IconLoaderService : IIconLoaderService } } - private static IconSource? GetStringIconSource(string iconString, string? fontFamily, Size size) - { - var iconSize = size.IsEmpty - ? DefaultIconSize - : (int)Math.Max(size.Width, size.Height); - return IconPathConverter.IconSourceMUX(iconString, fontFamily, iconSize); - } - private sealed class IconLoadOperation : IconLoadQueue.Operation { private readonly IconLoaderService _owner; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconPathConverter.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconPathConverter.cs new file mode 100644 index 0000000000..875a299556 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconPathConverter.cs @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.WindowsRuntime; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.Graphics.Imaging; +using DrawingIcon = System.Drawing.Icon; +using DrawingImageLockMode = System.Drawing.Imaging.ImageLockMode; +using DrawingPixelFormat = System.Drawing.Imaging.PixelFormat; +using DrawingRectangle = System.Drawing.Rectangle; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal static partial class IconPathConverter +{ + private const string InvalidGlyph = "\u25CC"; + private const int DefaultBinaryIconSize = 256; + + public static PreparedIcon Prepare(string iconPath, string? fontFamily, int targetSize) + { + if (string.IsNullOrEmpty(iconPath)) + { + return PreparedIcon.Empty(); + } + + if (IconPathParser.TryParseBinaryIconReference(iconPath, out var binaryIcon)) + { + var bitmap = ExtractBinaryIcon(binaryIcon, targetSize >= 0 ? targetSize : DefaultBinaryIconSize); + return PreparedIcon.FromBinary(bitmap); + } + + // Font glyphs start outside ASCII, while every supported URI starts inside it. + // Avoid using exception-based URI probing for the common Fluent glyph case. + if (iconPath[0] < 128 && Uri.TryCreate(iconPath, UriKind.Absolute, out var uri)) + { + var isSvg = Path.GetExtension(uri.AbsolutePath).Equals(".svg", StringComparison.OrdinalIgnoreCase); + return PreparedIcon.FromUri(uri, isSvg, targetSize); + } + + var glyphKind = FontIconGlyphClassifier.Classify(iconPath); + var glyph = glyphKind == FontIconGlyphKind.Invalid ? InvalidGlyph : iconPath; + var family = FontIconGlyphClassifier.GetFontFamily(glyphKind, fontFamily); + return PreparedIcon.FromGlyph(glyph, family, targetSize > 0 ? targetSize : 8); + } + + public static async Task CreateIconSourceAsync(PreparedIcon icon) + { + try + { + switch (icon.Kind) + { + case PreparedIconKind.BitmapUri: + var bitmap = new BitmapImage + { + DecodePixelWidth = icon.TargetSize > 0 ? icon.TargetSize : 0, + UriSource = icon.Uri!, + }; + return new ImageIconSource { ImageSource = bitmap }; + + case PreparedIconKind.SvgUri: + var svg = new SvgImageSource(icon.Uri!); + if (icon.TargetSize > 0) + { + svg.RasterizePixelWidth = icon.TargetSize; + } + + return new ImageIconSource { ImageSource = svg }; + + case PreparedIconKind.Glyph: + return new FontIconSource + { + FontFamily = new FontFamily(icon.FontFamily!), + FontSize = icon.TargetSize, + Glyph = icon.Glyph!, + }; + + case PreparedIconKind.Binary: + var softwareBitmap = icon.TakeSoftwareBitmap(); + if (softwareBitmap is null) + { + return new ImageIconSource(); + } + + var ownershipTransferred = false; + try + { + var bitmapSource = new SoftwareBitmapSource(); + try + { + await bitmapSource.SetBitmapAsync(softwareBitmap); + + var iconSource = new ImageIconSource { ImageSource = bitmapSource }; + + // SetBitmapAsync can finish before WinUI's AsyncCopyToSurfaceTask. + // Once XAML accepts the bitmap, explicitly closing either object can + // fail-fast that later copy with RO_E_CLOSED. Release both through + // their normal WinRT reference lifetimes instead. + ownershipTransferred = true; + return iconSource; + } + catch + { + // The source has not escaped to a caller or visual tree. + bitmapSource.Dispose(); + throw; + } + } + finally + { + if (!ownershipTransferred) + { + softwareBitmap.Dispose(); + } + } + + default: + return CreateEmptyIconSource(); + } + } + catch + { + return icon.Kind == PreparedIconKind.Binary + ? new ImageIconSource() + : CreateEmptyIconSource(); + } + } + + // Keep the empty value non-null. A virtualized ListView can crash when a + // data-bound IconSourceElement alternates between null and non-null sources; + // a BitmapIconSource with a null URI remains visually empty without crossing + // that unstable boundary. + private static BitmapIconSource CreateEmptyIconSource() => new() { UriSource = null }; + + private static SoftwareBitmap? ExtractBinaryIcon(BinaryIconReference iconReference, int targetSize) + { + nint iconHandle = 0; + try + { + _ = NativeMethods.SHDefExtractIcon( + iconReference.Path, + iconReference.Index, + 0, + out iconHandle, + 0, + (uint)targetSize); + if (iconHandle == 0) + { + return null; + } + + using var icon = DrawingIcon.FromHandle(iconHandle); + using var sourceBitmap = icon.ToBitmap(); + using var bitmap = sourceBitmap.Clone( + new DrawingRectangle(0, 0, sourceBitmap.Width, sourceBitmap.Height), + DrawingPixelFormat.Format32bppPArgb); + + var rectangle = new DrawingRectangle(0, 0, bitmap.Width, bitmap.Height); + var bitmapData = bitmap.LockBits(rectangle, DrawingImageLockMode.ReadOnly, DrawingPixelFormat.Format32bppPArgb); + try + { + var bytesPerRow = checked(bitmap.Width * 4); + var pixels = GC.AllocateUninitializedArray(checked(bytesPerRow * bitmap.Height)); + for (var row = 0; row < bitmap.Height; row++) + { + var source = nint.Add(bitmapData.Scan0, row * bitmapData.Stride); + Marshal.Copy(source, pixels, row * bytesPerRow, bytesPerRow); + } + + var softwareBitmap = new SoftwareBitmap( + BitmapPixelFormat.Bgra8, + bitmap.Width, + bitmap.Height, + BitmapAlphaMode.Premultiplied); + softwareBitmap.CopyFromBuffer(pixels.AsBuffer()); + return softwareBitmap; + } + finally + { + bitmap.UnlockBits(bitmapData); + } + } + catch + { + return null; + } + finally + { + if (iconHandle != 0) + { + _ = NativeMethods.DestroyIcon(iconHandle); + } + } + } + + internal sealed partial class PreparedIcon : IDisposable + { + private SoftwareBitmap? _softwareBitmap; + + private PreparedIcon( + PreparedIconKind kind, + Uri? uri = null, + string? glyph = null, + string? fontFamily = null, + SoftwareBitmap? softwareBitmap = null, + int targetSize = 0) + { + Kind = kind; + Uri = uri; + Glyph = glyph; + FontFamily = fontFamily; + _softwareBitmap = softwareBitmap; + TargetSize = targetSize; + } + + public PreparedIconKind Kind { get; } + + public Uri? Uri { get; } + + public string? Glyph { get; } + + public string? FontFamily { get; } + + public SoftwareBitmap? SoftwareBitmap => _softwareBitmap; + + public int TargetSize { get; } + + public static PreparedIcon Empty() => new(PreparedIconKind.Empty); + + public static PreparedIcon FromUri(Uri uri, bool isSvg, int targetSize) => + new(isSvg ? PreparedIconKind.SvgUri : PreparedIconKind.BitmapUri, uri: uri, targetSize: targetSize); + + public static PreparedIcon FromGlyph(string glyph, string fontFamily, int targetSize) => + new(PreparedIconKind.Glyph, glyph: glyph, fontFamily: fontFamily, targetSize: targetSize); + + public static PreparedIcon FromBinary(SoftwareBitmap? bitmap) => + new(PreparedIconKind.Binary, softwareBitmap: bitmap); + + // Asynchronous materialization takes ownership before the PreparedIcon is + // disposed. On success, XAML owns the bitmap's remaining lifetime. + public SoftwareBitmap? TakeSoftwareBitmap() => + Interlocked.Exchange(ref _softwareBitmap, null); + + public void Dispose() + { + Interlocked.Exchange(ref _softwareBitmap, null)?.Dispose(); + } + } + + internal enum PreparedIconKind + { + Empty, + BitmapUri, + SvgUri, + Glyph, + Binary, + } + + private static partial class NativeMethods + { + [LibraryImport("shell32.dll", EntryPoint = "SHDefExtractIconW", StringMarshalling = StringMarshalling.Utf16)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + internal static partial int SHDefExtractIcon( + string iconFile, + int iconIndex, + uint flags, + out nint largeIcon, + nint smallIcon, + uint iconSize); + + [LibraryImport("user32.dll")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + internal static partial int DestroyIcon(nint icon); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconPathParser.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconPathParser.cs new file mode 100644 index 0000000000..a7105690ba --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconPathParser.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.Helpers; + +internal static class IconPathParser +{ + private const uint UnsignedConversionError = uint.MaxValue; + private const int SignedConversionError = int.MaxValue; + + public static bool TryParseBinaryIconReference(string iconPath, out BinaryIconReference iconReference) + { + var commaIndex = iconPath.IndexOf(','); + var path = commaIndex < 0 ? iconPath : iconPath[..commaIndex]; + + if (!path.EndsWith(".exe", StringComparison.Ordinal) + && !path.EndsWith(".dll", StringComparison.Ordinal) + && !path.EndsWith(".lnk", StringComparison.Ordinal)) + { + iconReference = default; + return false; + } + + var index = 0; + if (commaIndex >= 0) + { + index = ParseNativeIconIndex(iconPath.AsSpan(commaIndex + 1)); + if (index == SignedConversionError) + { + iconReference = default; + return false; + } + } + + iconReference = new(path, index); + return true; + } + + // Preserve til::to_int quirks from the native converter: '-' is recognized anywhere, + // A-F digits are accepted even for decimal and octal input, and values at or above + // uint.MaxValue / 16 are rejected. + private static int ParseNativeIconIndex(ReadOnlySpan text) + { + var signPosition = text.IndexOf('-'); + var hasSign = signPosition >= 0; + var unsignedText = hasSign ? text[(signPosition + 1)..] : text; + var result = ParseNativeUnsignedLong(unsignedText); + if (result == UnsignedConversionError) + { + return SignedConversionError; + } + + return hasSign ? -(int)result : (int)result; + } + + private static uint ParseNativeUnsignedLong(ReadOnlySpan text) + { + const uint maximumValue = uint.MaxValue / 16; + + var numberBase = 10u; + var position = 0; + if (text.Length > 1 && text[0] == '0') + { + numberBase = 8; + position++; + if (text.Length > 2 && text[position] is 'x' or 'X') + { + numberBase = 16; + position++; + } + } + + if (position == text.Length) + { + return UnsignedConversionError; + } + + var accumulator = 0u; + while (true) + { + var character = text[position]; + uint value; + if (character is >= '0' and <= '9') + { + value = (uint)(character - '0'); + } + else if (character is >= 'A' and <= 'F') + { + value = (uint)(character - 'A') + 10u; + } + else if (character is >= 'a' and <= 'f') + { + value = (uint)(character - 'a') + 10u; + } + else + { + return UnsignedConversionError; + } + + accumulator = unchecked(accumulator + value); + if (accumulator >= maximumValue) + { + return UnsignedConversionError; + } + + position++; + if (position == text.Length) + { + return accumulator; + } + + accumulator = unchecked(accumulator * numberBase); + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj b/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj index 2302688641..3499278a87 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj @@ -128,6 +128,7 @@ + all diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.cpp b/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.cpp deleted file mode 100644 index e6cb46457b..0000000000 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.cpp +++ /dev/null @@ -1,177 +0,0 @@ -#include "pch.h" -#include "FontIconGlyphClassifier.h" -#include "FontIconGlyphClassifier.g.cpp" - -#include -#include - -namespace winrt::Microsoft::Terminal::UI::implementation -{ - namespace - { - // Check if the code point is in the Private Use Area range used by Fluent UI icons. - [[nodiscard]] constexpr bool _isFluentIconPua(const UChar32 cp) noexcept - { - constexpr UChar32 fluentIconsPrivateUseAreaStart = 0xE700; - constexpr UChar32 fluentIconsPrivateUseAreaEnd = 0xF8FF; - return cp >= fluentIconsPrivateUseAreaStart && cp <= fluentIconsPrivateUseAreaEnd; - } - - // Determine if the given text (as a sequence of UChar code units) is emoji - [[nodiscard]] bool _isEmoji(const UChar* p, const int32_t length) noexcept - { - if (!p || length < 1) - { - return false; - } - - // https://www.unicode.org/reports/tr51/#Emoji_Variation_Selector_Notes - constexpr UChar32 vs15CodePoint = 0xFE0E; // Variation Selectors 15: text variation selector - constexpr UChar32 vs16CodePoint = 0xFE0F; // Variation Selectors: 16 emoji variation selector - - // Decode the first code point correctly (surrogate-safe) - int32_t i0{ 0 }; - UChar32 first{ 0 }; - U16_NEXT(p, i0, length, first); - - for (int32_t i = 0; i < length;) - { - UChar32 cp{ 0 }; - U16_NEXT(p, i, length, cp); - - if (cp == vs16CodePoint) { return true; } - if (cp == vs15CodePoint) { return false; } - } - - return !U_IS_SURROGATE(first) && u_hasBinaryProperty(first, UCHAR_EMOJI_PRESENTATION); - } - } - - bool FontIconGlyphClassifier::IsLikelyToBeEmojiOrSymbolIcon(const hstring& text) - { - if (text.empty()) - { - return false; - } - - if (text.size() == 1 && !IS_HIGH_SURROGATE(text[0])) - { - // If it's a single code unit, it's definitely either zero or one grapheme clusters. - // If it turns out to be illegal Unicode, we don't really care. - return true; - } - - if (text.size() >= 2 && text[0] <= 0x7F && text[1] <= 0x7F) - { - // Two adjacent ASCII characters (as seen in most file paths) aren't a single - // grapheme cluster. - return false; - } - - // Use ICU to determine whether text is composed of a single grapheme cluster. - int32_t off{ 0 }; - UErrorCode status{ U_ZERO_ERROR }; - - UBreakIterator* const bi{ ubrk_open(UBRK_CHARACTER, - nullptr, - reinterpret_cast(text.data()), - static_cast(text.size()), - &status) }; - if (bi) - { - if (U_SUCCESS(status)) - { - off = ubrk_next(bi); - } - ubrk_close(bi); - } - return std::cmp_equal(off, text.size()); - } - - FontIconGlyphKind FontIconGlyphClassifier::Classify(hstring const& text) noexcept - { - if (text.empty()) - { - return FontIconGlyphKind::None; - } - - const size_t textSize{ text.size() }; - const auto* buffer{ reinterpret_cast(text.c_str()) }; - - // Fast path 1: Single UTF-16 code unit (most common case) - if (textSize == 1) - { - const UChar ch{ buffer[0] }; - - if (IS_HIGH_SURROGATE(ch)) - { - return FontIconGlyphKind::Invalid; - } - - if (_isFluentIconPua(ch)) - { - return FontIconGlyphKind::FluentSymbol; - } - - if (_isEmoji(&ch, 1)) - { - return FontIconGlyphKind::Emoji; - } - - return FontIconGlyphKind::Other; - } - - // Fast path 2: Common file path pattern - two ASCII printable characters - if (textSize >= 2 && buffer[0] <= 0x7F && buffer[1] <= 0x7F) - { - // Definitely multiple graphemes - return FontIconGlyphKind::Invalid; - } - - // Expensive path: Use ICU to determine grapheme boundaries - UErrorCode status{ U_ZERO_ERROR }; - - UBreakIterator* bi{ ubrk_open(UBRK_CHARACTER, - nullptr, - buffer, - static_cast(textSize), - &status) }; - - if (U_FAILURE(status) || !bi) - { - return FontIconGlyphKind::Invalid; - } - - const int32_t start{ ubrk_first(bi) }; - const int32_t end{ ubrk_next(bi) }; // end of first grapheme - ubrk_close(bi); - - // No graphemes found - if (end == UBRK_DONE || end <= start) - { - return FontIconGlyphKind::None; - } - - // If there's more than one grapheme, it's not a valid icon glyph - if (std::cmp_not_equal(end, textSize)) - { - return FontIconGlyphKind::Invalid; - } - - // Exactly one grapheme: classify - const UChar* grapheme = buffer + start; - const int32_t graphemeLength = end - start; - - if (graphemeLength == 1 && _isFluentIconPua(grapheme[0])) - { - return FontIconGlyphKind::FluentSymbol; - } - - if (_isEmoji(grapheme, graphemeLength)) - { - return FontIconGlyphKind::Emoji; - } - - return FontIconGlyphKind::Other; - } -} \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.h b/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.h deleted file mode 100644 index e51ca42c17..0000000000 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.h +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once - -#include "FontIconGlyphClassifier.g.h" - -namespace winrt::Microsoft::Terminal::UI::implementation -{ - struct FontIconGlyphClassifier - { - [[nodiscard]] static bool IsLikelyToBeEmojiOrSymbolIcon(const winrt::hstring& text); - - [[nodiscard]] static FontIconGlyphKind Classify(winrt::hstring const& text) noexcept; - }; -} - -namespace winrt::Microsoft::Terminal::UI::factory_implementation -{ - BASIC_FACTORY(FontIconGlyphClassifier); -} diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.idl b/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.idl deleted file mode 100644 index 1adccb9d44..0000000000 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.idl +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -namespace Microsoft.Terminal.UI -{ - /// - /// Categorizes the type of a single grapheme cluster or input text. - /// Used to determine how the input should be handled or rendered (for example, - /// whether it should be treated as an emoji, an icon from a symbol font, plain text, etc.). - /// - enum FontIconGlyphKind - { - /// - /// Input is invalid or contains more than one grapheme cluster and therefore cannot be - /// treated as a single symbol. Typical for multi-character text like file paths - /// or composed strings that include separators. - /// - Invalid = -1, - - /// - /// No grapheme present (empty string). Indicates absence of a symbol. - /// - None = 0, - - /// - /// A single emoji grapheme cluster. This may consist of multiple Unicode code - /// points combined into one visible glyph (e.g., emoji with modifiers or ZWJ sequences). - /// - Emoji = 1, - - /// - /// A single glyph from the Segoe Fluent Icons / MDL2 Assets Private Use Area (PUA), - /// typically in the Unicode range U+E700–U+F8FF. These are font-based icons (Fluent/MDL2). - /// - FluentSymbol = 2, - - /// - /// A single non-emoji grapheme that is not a Fluent/MDL2 PUA symbol. - /// Covers ordinary characters, letters, numbers, or other single glyph symbols. - /// - Other = 3, - }; - - /// - /// Static utility class for text and icon analysis - /// - static runtimeclass FontIconGlyphClassifier - { - /// - /// Determines if text represents a single grapheme cluster (emoji/symbol icon). - /// Uses ICU for Unicode boundary detection to distinguish icons from file paths. - /// - /// Text to analyze - /// True if single grapheme cluster, false for multi-character text or paths - static Boolean IsLikelyToBeEmojiOrSymbolIcon(String text); - - /// - /// Classifies the input into a glyph kind suitable for icon or text rendering. - /// - static FontIconGlyphKind Classify(String text); - }; -} diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.cpp b/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.cpp deleted file mode 100644 index ab0683ebb8..0000000000 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.cpp +++ /dev/null @@ -1,414 +0,0 @@ -#include "pch.h" -#include "IconPathConverter.h" -#include "IconPathConverter.g.cpp" - -#include "FontIconGlyphClassifier.h" - -#include -#include -#include - -namespace winrt -{ - namespace MUX = Microsoft::UI::Xaml; -} - -using namespace winrt::Windows; -using namespace winrt::Windows::UI::Xaml; - -using namespace winrt::Windows::Graphics::Imaging; -using namespace winrt::Windows::Storage::Streams; - -namespace winrt::Microsoft::Terminal::UI::implementation -{ -// These are templates that help us figure out which BitmapIconSource/FontIconSource to use for a given IconSource. -// We have to do this because some of our code still wants to use WUX/MUX IconSources. -#pragma region BitmapIconSource - template - struct BitmapIconSource - { - }; - - template<> - struct BitmapIconSource - { - using type = winrt::Microsoft::UI::Xaml::Controls::BitmapIconSource; - }; - - /*template<> - struct BitmapIconSource - { - using type = winrt::Windows::UI::Xaml::Controls::BitmapIconSource; - };*/ -#pragma endregion - -#pragma region FontIconSource - template - struct FontIconSource - { - }; - - template<> - struct FontIconSource - { - using type = winrt::Microsoft::UI::Xaml::Controls::FontIconSource; - }; - - /*template<> - struct FontIconSource - { - using type = winrt::Windows::UI::Xaml::Controls::FontIconSource; - };*/ -#pragma endregion - -#pragma region PathIconSource - template - struct PathIconSource - { - }; - - template<> - struct PathIconSource - { - using type = winrt::Microsoft::UI::Xaml::Controls::PathIconSource; - }; -#pragma endregion -#pragma region ImageIconSource - template - struct ImageIconSource - { - }; - - template<> - struct ImageIconSource - { - using type = winrt::Microsoft::UI::Xaml::Controls::ImageIconSource; - }; -#pragma endregion - - // Method Description: - // - Creates an IconSource for the given path. The icon returned is a colored - // icon. If we couldn't create the icon for any reason, we return an empty - // IconElement. - // Template Types: - // - : The type of IconSource (MUX, WUX) to generate. - // Arguments: - // - path: the full, expanded path to the icon. - // - targetSize: the target size for decoding/rasterizing the icon. - // Return Value: - // - An IconElement with its IconSource set, if possible. - template - TIconSource _getColoredBitmapIcon(const winrt::hstring& path, int targetSize) - { - // FontIcon uses glyphs in the private use area, whereas valid URIs only contain ASCII characters. - // To skip throwing on Uri construction, we can quickly check if the first character is ASCII. - if (path.empty() || path.front() >= 128) - { - return nullptr; - } - - try - { - winrt::Windows::Foundation::Uri iconUri{ path }; - - if (til::equals_insensitive_ascii(iconUri.Extension(), L".svg")) - { - typename ImageIconSource::type iconSource; - winrt::Microsoft::UI::Xaml::Media::Imaging::SvgImageSource source{ iconUri }; - if (targetSize > 0) - { - source.RasterizePixelWidth(static_cast(targetSize)); - // Set only single dimension here; the image might not be square and - // this will preserve the aspect ratio (for the price of keeping height unbound). - // source.RasterizePixelHeight(static_cast(targetSize)); - } - iconSource.ImageSource(source); - return iconSource; - } - else - { - typename ImageIconSource::type iconSource; - winrt::Microsoft::UI::Xaml::Media::Imaging::BitmapImage bitmapImage; - if (targetSize > 0) - { - bitmapImage.DecodePixelWidth(targetSize); - // Set only single dimension here; the image might not be square and - // this will preserve the aspect ratio (for the price of keeping height unbound). - // bitmapImage.DecodePixelHeight(targetSize); - } - bitmapImage.UriSource(iconUri); - iconSource.ImageSource(bitmapImage); - return iconSource; - } - } - CATCH_LOG(); - - return nullptr; - } - - static winrt::hstring _expandIconPath(const hstring& iconPath) - { - if (iconPath.empty()) - { - return iconPath; - } - // winrt::hstring envExpandedPath{ wil::ExpandEnvironmentStringsW(iconPath.c_str()) }; - winrt::hstring envExpandedPath{ iconPath }; - return envExpandedPath; - } - - // Method Description: - // - Creates an IconSource for the given path. - // * If the icon is a path to an image, we'll use that. - // * If it isn't, then we'll try and use the text as a FontIcon. If the - // character is in the range of symbols reserved for the Segoe MDL2 - // Asserts, well treat it as such. Otherwise, we'll default to a Sego - // UI icon, so things like emoji will work. - // * If we couldn't create the icon for any reason, we return an empty - // IconElement. - // Template Types: - // - : The type of IconSource (MUX, WUX) to generate. - // Arguments: - // - path: the unprocessed path to the icon. - // Return Value: - // - An IconElement with its IconSource set, if possible. - template - TIconSource _getIconSource(const winrt::hstring& iconPath, const winrt::hstring& fontFamily, const int targetSize) - { - TIconSource iconSource{ nullptr }; - - if (iconPath.size() != 0) - { - const auto expandedIconPath{ _expandIconPath(iconPath) }; - iconSource = _getColoredBitmapIcon(expandedIconPath, targetSize); - - // If we fail to set the icon source using the "icon" as a path, - // let's try it as a symbol/emoji. - if (!iconSource) - { - try - { - const auto glyph_kind = FontIconGlyphClassifier::Classify(iconPath); - - winrt::hstring family; - if (glyph_kind == FontIconGlyphKind::Invalid) - { - family = L"Segoe UI"; - } - else if (!fontFamily.empty()) - { - family = fontFamily; - } - else if (glyph_kind == FontIconGlyphKind::FluentSymbol) - { - family = L"Segoe Fluent Icons, Segoe MDL2 Assets"; - } - else if (glyph_kind == FontIconGlyphKind::Emoji) - { - // Emoji and other symbols go in the Segoe UI Emoji font. - // Some emojis (e.g. 2️⃣) would be rendered as emoji glyphs otherwise. - family = L"Segoe UI Emoji, Segoe UI"; - } - else - { - family = L"Segoe UI"; - } - - typename FontIconSource::type icon; - icon.FontFamily(winrt::Microsoft::UI::Xaml::Media::FontFamily{ family }); - icon.FontSize(targetSize > 0 ? targetSize : 8); - icon.Glyph(glyph_kind == FontIconGlyphKind::Invalid ? L"\u25CC" : iconPath); - iconSource = icon; - } - CATCH_LOG(); - } - } - - if (!iconSource) - { - // Set the default IconSource to a BitmapIconSource with a null source - // (instead of just nullptr) because there's a really weird crash when swapping - // data bound IconSourceElements in a ListViewTemplate (i.e. CommandPalette). - // Swapping between nullptr IconSources and non-null IconSources causes a crash - // to occur, but swapping between IconSources with a null source and non-null IconSources - // work perfectly fine :shrug:. - typename BitmapIconSource::type icon; - icon.UriSource(nullptr); - iconSource = icon; - } - - return iconSource; - } - - // Windows::UI::Xaml::Controls::IconSource IconPathConverter::IconSourceWUX(const hstring& path) - // { - // // * If the icon is a path to an image, we'll use that. - // // * If it isn't, then we'll try and use the text as a FontIcon. If the - // // character is in the range of symbols reserved for the Segoe MDL2 - // // Asserts, well treat it as such. Otherwise, we'll default to a Segoe - // // UI icon, so things like emoji will work. - // return _getIconSource(path, false); - // } - - static Microsoft::UI::Xaml::Controls::IconSource _IconSourceMUX(const hstring& path, const winrt::hstring& fontFamily, const int targetSize) - { - return _getIconSource(path, fontFamily, targetSize); - } - - static SoftwareBitmap _convertToSoftwareBitmap(HICON hicon, - BitmapPixelFormat pixelFormat, - BitmapAlphaMode alphaMode, - IWICImagingFactory* imagingFactory) - { - // Load the icon into an IWICBitmap - wil::com_ptr iconBitmap; - THROW_IF_FAILED(imagingFactory->CreateBitmapFromHICON(hicon, iconBitmap.put())); - - // Put the IWICBitmap into a SoftwareBitmap. This may fail if WICBitmap's format is not supported by - // SoftwareBitmap. CreateBitmapFromHICON always creates RGBA8 so we're ok. - auto softwareBitmap = winrt::capture( - winrt::create_instance(CLSID_SoftwareBitmapNativeFactory), - &ISoftwareBitmapNativeFactory::CreateFromWICBitmap, - iconBitmap.get(), - false); - - // Convert the pixel format and alpha mode if necessary - if (softwareBitmap.BitmapPixelFormat() != pixelFormat || softwareBitmap.BitmapAlphaMode() != alphaMode) - { - softwareBitmap = SoftwareBitmap::Convert(softwareBitmap, pixelFormat, alphaMode); - } - - return softwareBitmap; - } - - static SoftwareBitmap _getBitmapFromIconFileAsync(const winrt::hstring& iconPath, - int32_t iconIndex, - uint32_t iconSize) - { - wil::unique_hicon hicon; - LOG_IF_FAILED(SHDefExtractIcon(iconPath.c_str(), iconIndex, 0, &hicon, nullptr, iconSize)); - - if (!hicon) - { - return nullptr; - } - - wil::com_ptr wicImagingFactory; - THROW_IF_FAILED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&wicImagingFactory))); - - return _convertToSoftwareBitmap(hicon.get(), - BitmapPixelFormat::Bgra8, - BitmapAlphaMode::Premultiplied, - wicImagingFactory.get()); - } - - // Method Description: - // - Attempt to get the icon index from the icon path provided - // Arguments: - // - iconPath: the full icon path, including the index if present - // - iconPathWithoutIndex: the place to store the icon path, sans the index if present - // Return Value: - // - nullopt if the iconPath is not an exe/dll/lnk file in the first place - // - 0 if the iconPath is an exe/dll/lnk file but does not contain an index (i.e. we default - // to the first icon in the file) - // - the icon index if the iconPath is an exe/dll/lnk file and contains an index - static std::optional _getIconIndex(const winrt::hstring& iconPath, std::wstring_view& iconPathWithoutIndex) - { - const auto pathView = std::wstring_view{ iconPath }; - // Does iconPath have a comma in it? If so, split the string on the - // comma and look for the index and extension. - const auto commaIndex = pathView.find(L','); - - // split the path on the comma - iconPathWithoutIndex = pathView.substr(0, commaIndex); - - // It's an exe, dll, or lnk, so we need to extract the icon from the file. - if (!til::ends_with(iconPathWithoutIndex, L".exe") && - !til::ends_with(iconPathWithoutIndex, L".dll") && - !til::ends_with(iconPathWithoutIndex, L".lnk")) - { - return std::nullopt; - } - - if (commaIndex != std::wstring::npos) - { - // Convert the string iconIndex to a signed int to support negative numbers which represent an Icon's ID. - const auto index{ til::to_int(pathView.substr(commaIndex + 1)) }; - if (index == til::to_int_error) - { - return std::nullopt; - } - return static_cast(index); - } - - // We had a binary path, but no index. Default to 0. - return 0; - } - - static winrt::Microsoft::UI::Xaml::Media::Imaging::SoftwareBitmapSource _getImageIconSourceForBinary(std::wstring_view iconPathWithoutIndex, - int index, - int targetSize) - { - // Try: - // * c:\Windows\System32\SHELL32.dll, 210 - // * c:\Windows\System32\notepad.exe, 0 - // * C:\Program Files\PowerShell\6-preview\pwsh.exe, 0 (this doesn't exist for me) - // * C:\Program Files\PowerShell\7\pwsh.exe, 0 - - const auto swBitmap{ _getBitmapFromIconFileAsync(winrt::hstring{ iconPathWithoutIndex }, index, targetSize >= 0 ? targetSize : 256) }; - if (swBitmap == nullptr) - { - return nullptr; - } - - winrt::Microsoft::UI::Xaml::Media::Imaging::SoftwareBitmapSource bitmapSource{}; - bitmapSource.SetBitmapAsync(swBitmap); - return bitmapSource; - } - - MUX::Controls::IconSource IconPathConverter::IconSourceMUX(const winrt::hstring& iconPath, - const winrt::hstring& fontFamily, - const int targetSize) - { - std::wstring_view iconPathWithoutIndex; - const auto indexOpt = _getIconIndex(iconPath, iconPathWithoutIndex); - if (!indexOpt.has_value()) - { - return _IconSourceMUX(iconPath, fontFamily, targetSize); - } - - const auto bitmapSource = _getImageIconSourceForBinary(iconPathWithoutIndex, indexOpt.value(), targetSize); - - MUX::Controls::ImageIconSource imageIconSource{}; - imageIconSource.ImageSource(bitmapSource); - - return imageIconSource; - } - - Microsoft::UI::Xaml::Controls::IconElement IconPathConverter::IconMUX(const winrt::hstring& iconPath) - { - return IconMUX(iconPath, 24); - } - - Microsoft::UI::Xaml::Controls::IconElement IconPathConverter::IconMUX(const winrt::hstring& iconPath, const int targetSize) - { - std::wstring_view iconPathWithoutIndex; - const auto indexOpt = _getIconIndex(iconPath, iconPathWithoutIndex); - if (!indexOpt.has_value()) - { - auto source = IconSourceMUX(iconPath, L"", targetSize); - Microsoft::UI::Xaml::Controls::IconSourceElement icon; - icon.IconSource(source); - return icon; - } - - const auto bitmapSource = _getImageIconSourceForBinary(iconPathWithoutIndex, indexOpt.value(), targetSize); - - winrt::Microsoft::UI::Xaml::Controls::ImageIcon icon{}; - icon.Source(bitmapSource); - icon.Width(targetSize); - icon.Height(targetSize); - return icon; - } - -} diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.h b/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.h deleted file mode 100644 index de3698a923..0000000000 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "IconPathConverter.g.h" - -namespace winrt::Microsoft::Terminal::UI::implementation -{ - struct IconPathConverter - { - IconPathConverter() = default; - - //static Windows::UI::Xaml::Controls::IconElement IconWUX(const winrt::hstring& iconPath); - //static Windows::UI::Xaml::Controls::IconSource IconSourceWUX(const winrt::hstring& iconPath); - static Microsoft::UI::Xaml::Controls::IconSource IconSourceMUX(const winrt::hstring& iconPath, const winrt::hstring& fontFamily, const int targetSize=24); - static Microsoft::UI::Xaml::Controls::IconElement IconMUX(const winrt::hstring& iconPath); - static Microsoft::UI::Xaml::Controls::IconElement IconMUX(const winrt::hstring& iconPath, const int targetSize); - - }; -} - -namespace winrt::Microsoft::Terminal::UI::factory_implementation -{ - BASIC_FACTORY(IconPathConverter); -} diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.idl b/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.idl deleted file mode 100644 index 41bde322b9..0000000000 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.idl +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -namespace Microsoft.Terminal.UI -{ - static runtimeclass IconPathConverter - { - // static Windows.UI.Xaml.Controls.IconElement IconWUX(String path); - // static Windows.UI.Xaml.Controls.IconSource IconSourceWUX(String path); - static Microsoft.UI.Xaml.Controls.IconSource IconSourceMUX(String path, String fontFamily, Int32 targetSize); - static Microsoft.UI.Xaml.Controls.IconElement IconMUX(String path); - static Microsoft.UI.Xaml.Controls.IconElement IconMUX(String path, Int32 targetSize); - }; - -} diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj b/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj index 4e06dd60fc..d4a93a7ae1 100644 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj @@ -154,18 +154,12 @@ Converters.idl - - IconPathConverter.idl - RunHistory.idl ResourceString.idl - - FontIconGlyphClassifier.idl - @@ -175,9 +169,6 @@ Converters.idl - - IconPathConverter.idl - RunHistory.idl @@ -185,17 +176,12 @@ ResourceString.idl - - FontIconGlyphClassifier.idl - - - @@ -205,4 +191,4 @@ obj\$(Platform)\$(Configuration)\ - \ No newline at end of file + diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/FontIconGlyphClassifierTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/FontIconGlyphClassifierTests.cs new file mode 100644 index 0000000000..d73dcb5ee0 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/FontIconGlyphClassifierTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.UI.UnitTests; + +[TestClass] +public class FontIconGlyphClassifierTests +{ + [TestMethod] + public void EmptyInputHasNoGlyph() + { + Assert.AreEqual(FontIconGlyphKind.None, FontIconGlyphClassifier.Classify(null)); + Assert.AreEqual(FontIconGlyphKind.None, FontIconGlyphClassifier.Classify(string.Empty)); + } + + [TestMethod] + [DataRow("\uE700")] + [DataRow("\uF000")] + [DataRow("\uF8FF")] + public void FluentPrivateUseCharactersAreSymbols(string text) + { + Assert.AreEqual(FontIconGlyphKind.FluentSymbol, FontIconGlyphClassifier.Classify(text)); + } + + [TestMethod] + [DataRow("A")] + [DataRow("\uE6FF")] + [DataRow("\uF900")] + [DataRow("e\u0301")] + public void SingleNonEmojiGraphemesUseTheGeneralFont(string text) + { + Assert.AreEqual(FontIconGlyphKind.Other, FontIconGlyphClassifier.Classify(text)); + } + + [TestMethod] + public void IsolatedLowSurrogatePreservesNativeClassifierBehavior() + { + var text = new string((char)0xDC00, 1); + + Assert.AreEqual(FontIconGlyphKind.Other, FontIconGlyphClassifier.Classify(text)); + } + + [TestMethod] + [DataRow("\u231A")] + [DataRow("\U0001F600")] + [DataRow("\u2764\uFE0F")] + [DataRow("2\uFE0F\u20E3")] + [DataRow("\U0001F469\u200D\U0001F4BB")] + public void EmojiGraphemesUseTheEmojiFont(string text) + { + Assert.AreEqual(FontIconGlyphKind.Emoji, FontIconGlyphClassifier.Classify(text)); + } + + [TestMethod] + [DataRow("\u2764")] + [DataRow("\u2764\uFE0E")] + [DataRow("2\u20E3")] + public void TextPresentationRemainsGeneralText(string text) + { + Assert.AreEqual(FontIconGlyphKind.Other, FontIconGlyphClassifier.Classify(text)); + } + + [TestMethod] + [DataRow("\uD83D")] + [DataRow("ab")] + [DataRow("C:\\icon.png")] + [DataRow("\U0001F600\U0001F600")] + public void InvalidOrMultipleGraphemesAreRejected(string text) + { + Assert.AreEqual(FontIconGlyphKind.Invalid, FontIconGlyphClassifier.Classify(text)); + } + + [TestMethod] + public void FontFamilySelectionMatchesTheNativeConverter() + { + Assert.AreEqual( + "Segoe Fluent Icons, Segoe MDL2 Assets", + FontIconGlyphClassifier.GetFontFamily(FontIconGlyphKind.FluentSymbol, null)); + Assert.AreEqual( + "Segoe UI Emoji, Segoe UI", + FontIconGlyphClassifier.GetFontFamily(FontIconGlyphKind.Emoji, null)); + Assert.AreEqual( + "Custom Font", + FontIconGlyphClassifier.GetFontFamily(FontIconGlyphKind.Other, "Custom Font")); + Assert.AreEqual( + "Segoe UI", + FontIconGlyphClassifier.GetFontFamily(FontIconGlyphKind.Invalid, "Custom Font")); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconPathConverterTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconPathConverterTests.cs new file mode 100644 index 0000000000..2bcac467f8 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconPathConverterTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.Graphics.Imaging; + +namespace Microsoft.CmdPal.UI.UnitTests; + +[TestClass] +public class IconPathConverterTests +{ + [TestMethod] + [Timeout(5_000)] + public void IndexedShellIconIsPreparedAsSoftwareBitmap() + { + var shell32Path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "shell32.dll"); + + using var prepared = IconPathConverter.Prepare($"{shell32Path},0", null, 32); + + Assert.AreEqual(IconPathConverter.PreparedIconKind.Binary, prepared.Kind); + Assert.IsNotNull(prepared.SoftwareBitmap); + var bitmap = prepared.SoftwareBitmap; + Assert.IsTrue(bitmap.PixelWidth > 0); + Assert.IsTrue(bitmap.PixelHeight > 0); + Assert.AreEqual(BitmapPixelFormat.Bgra8, bitmap.BitmapPixelFormat); + Assert.AreEqual(BitmapAlphaMode.Premultiplied, bitmap.BitmapAlphaMode); + } + + [TestMethod] + public void PreparedBinaryIconTransfersSoftwareBitmapOwnership() + { + using var bitmap = new SoftwareBitmap( + BitmapPixelFormat.Bgra8, + 1, + 1, + BitmapAlphaMode.Premultiplied); + using var prepared = IconPathConverter.PreparedIcon.FromBinary(bitmap); + + var transferred = prepared.TakeSoftwareBitmap(); + prepared.Dispose(); + + Assert.IsNotNull(transferred); + Assert.AreSame(bitmap, transferred); + Assert.AreEqual(1, transferred.PixelWidth); + } + + [TestMethod] + public void UriAndInvalidTextPreparationPreserveConverterFallbacks() + { + using var svg = IconPathConverter.Prepare("ms-appx:///Assets/icon.svg", null, 20); + Assert.AreEqual(IconPathConverter.PreparedIconKind.SvgUri, svg.Kind); + Assert.AreEqual(20, svg.TargetSize); + + using var invalidText = IconPathConverter.Prepare("not a glyph", "Custom Font", 24); + Assert.AreEqual(IconPathConverter.PreparedIconKind.Glyph, invalidText.Kind); + Assert.AreEqual("\u25CC", invalidText.Glyph); + Assert.AreEqual("Segoe UI", invalidText.FontFamily); + + using var relativeText = IconPathConverter.Prepare("not-a-glyph", null, 24); + Assert.AreEqual(IconPathConverter.PreparedIconKind.Glyph, relativeText.Kind); + Assert.AreEqual("\u25CC", relativeText.Glyph); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconPathParserTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconPathParserTests.cs new file mode 100644 index 0000000000..7ad5ee3062 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconPathParserTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.UI.UnitTests; + +[TestClass] +public class IconPathParserTests +{ + [TestMethod] + [DataRow(@"C:\Windows\notepad.exe", @"C:\Windows\notepad.exe", 0)] + [DataRow(@"C:\Windows\System32\shell32.dll,-210", @"C:\Windows\System32\shell32.dll", -210)] + [DataRow(@"C:\shortcut.lnk,0", @"C:\shortcut.lnk", 0)] + [DataRow(@"C:\icons.dll,010", @"C:\icons.dll", 8)] + [DataRow(@"C:\icons.dll,0x10", @"C:\icons.dll", 16)] + public void ParsesSupportedBinaryIconReferences(string input, string expectedPath, int expectedIndex) + { + Assert.IsTrue(IconPathParser.TryParseBinaryIconReference(input, out var result)); + Assert.AreEqual(expectedPath, result.Path); + Assert.AreEqual(expectedIndex, result.Index); + } + + [TestMethod] + [DataRow(@"C:\icon.png")] + [DataRow(@"C:\APP.EXE,0")] + [DataRow(@"C:\icons.dll,not-an-index")] + [DataRow(@"C:\folder,with-comma\icons.dll,1")] + public void RejectsInputsTheNativeConverterDidNotTreatAsBinaryIcons(string input) + { + Assert.IsFalse(IconPathParser.TryParseBinaryIconReference(input, out _)); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/Microsoft.CmdPal.UI.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/Microsoft.CmdPal.UI.UnitTests.csproj index 0a05ea11a1..a1f7690623 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/Microsoft.CmdPal.UI.UnitTests.csproj +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/Microsoft.CmdPal.UI.UnitTests.csproj @@ -17,6 +17,7 @@ + @@ -28,9 +29,12 @@ + + + @@ -49,6 +53,8 @@ + +