From be527af356151dea9fe8de1fa0fc14424acf95bd Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 27 Mar 2026 06:25:52 -0700 Subject: [PATCH 01/22] Add EXE / DLL extraction of icons --- .../Constants/FileTypes.cs | 6 + .../Controls/PreviewImage.xaml.cs | 13 +- .../Controls/PreviewStack.xaml | 41 +++- .../Controls/PreviewStack.xaml.cs | 41 ++++ .../Helpers/DllIconExtractorHelper.cs | 210 ++++++++++++++++++ .../Helpers/FilePickerHelper.cs | 55 +++-- .../ViewModels/MainViewModel.cs | 74 +++--- .../ViewModels/MultiViewModel.cs | 59 ++++- .../Views/MainPage.xaml | 13 +- .../Views/MultiPage.xaml | 23 +- 10 files changed, 450 insertions(+), 85 deletions(-) create mode 100644 Simple Icon File Maker/Simple Icon File Maker/Helpers/DllIconExtractorHelper.cs diff --git a/Simple Icon File Maker/Simple Icon File Maker/Constants/FileTypes.cs b/Simple Icon File Maker/Simple Icon File Maker/Constants/FileTypes.cs index 7f61d03..534b274 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Constants/FileTypes.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Constants/FileTypes.cs @@ -6,9 +6,15 @@ public static class FileTypes { public static readonly HashSet SupportedImageFormats = [".png", ".bmp", ".jpeg", ".jpg", ".ico"]; + public static readonly HashSet SupportedDllFormats = [".dll", ".exe", ".mun"]; + public static bool IsSupportedImageFormat(this StorageFile file) { return SupportedImageFormats.Contains(file.FileType, StringComparer.OrdinalIgnoreCase); } + public static bool IsSupportedDllFormat(this StorageFile file) + { + return SupportedDllFormats.Contains(file.FileType, StringComparer.OrdinalIgnoreCase); + } } diff --git a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs index e6ad1d1..a253d63 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs @@ -41,9 +41,8 @@ public bool ZoomPreview if (value != isZooming) { isZooming = value; - mainImageCanvas.Children.Clear(); + LoadImageOnToCanvas(); InvalidateMeasure(); - InvalidateArrange(); } } } @@ -52,17 +51,13 @@ protected override Size ArrangeOverride(Size finalSize) { double dim = Math.Min(finalSize.Width, finalSize.Height); mainImageCanvas.Arrange(new Rect(new Point((finalSize.Width - dim) / 2, (finalSize.Height - dim) / 2), new Size(dim, dim))); - LoadImageOnToCanvas(); return finalSize; } protected override Size MeasureOverride(Size availableSize) { - double dim = Math.Min(availableSize.Width, availableSize.Height); - // smallerAvailableSize = (int)dim; - if (double.IsPositiveInfinity(dim)) - dim = 3000; - return new Size(dim, dim); + int size = isZooming ? ZoomedWidthSpace : _sideLength; + return new Size(size, size); } private async void MenuFlyoutItem_Click(object sender, RoutedEventArgs e) @@ -129,8 +124,6 @@ private void LoadImageOnToCanvas() brush.Surface = image; int size = isZooming ? ZoomedWidthSpace : _sideLength; - Width = size; - Height = size; // set the visual size when the image has loaded image.LoadCompleted += (s, e) => diff --git a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml index a01f15e..8765dc9 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml +++ b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml @@ -13,11 +13,42 @@ - + + + + + + + + + + + + + + + + + + + + + + + + sizes, bool showTitle = false) InitializeComponent(); if (showTitle) + { FileNameText.Text = Path.GetFileName(imagePath); + SaveColumnButton.Visibility = Visibility.Visible; + } } public async Task InitializeAsync(IProgress progress) @@ -416,4 +423,38 @@ public void UpdateSizeAndZoom() img.ZoomPreview = IsZoomingPreview; } } + + private async void SaveIconMenuItem_Click(object sender, RoutedEventArgs e) + { + await SaveColumnWithPickerAsync(saveAllImages: false); + } + + private async void SaveAllImagesMenuItem_Click(object sender, RoutedEventArgs e) + { + await SaveColumnWithPickerAsync(saveAllImages: true); + } + + private async Task SaveColumnWithPickerAsync(bool saveAllImages) + { + FileSavePicker savePicker = new() + { + SuggestedStartLocation = PickerLocationId.PicturesLibrary, + SuggestedFileName = Path.GetFileNameWithoutExtension(imagePath), + DefaultFileExtension = ".ico", + }; + savePicker.FileTypeChoices.Add("ICO File", [".ico"]); + InitializeWithWindow.Initialize(savePicker, App.MainWindow.GetWindowHandle()); + + StorageFile? file = await savePicker.PickSaveFileAsync(); + if (file is null) + return; + + IIconSizesService iconSizesService = App.GetService(); + IconSortOrder sortOrder = iconSizesService.SortOrder; + + if (saveAllImages) + await SaveAllImagesAsync(file.Path, sortOrder); + else + await SaveIconAsync(file.Path, sortOrder); + } } diff --git a/Simple Icon File Maker/Simple Icon File Maker/Helpers/DllIconExtractorHelper.cs b/Simple Icon File Maker/Simple Icon File Maker/Helpers/DllIconExtractorHelper.cs new file mode 100644 index 0000000..e3d578e --- /dev/null +++ b/Simple Icon File Maker/Simple Icon File Maker/Helpers/DllIconExtractorHelper.cs @@ -0,0 +1,210 @@ +using ImageMagick; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Simple_Icon_File_Maker.Helpers; + +[SupportedOSPlatform("windows")] +public static class DllIconExtractorHelper +{ + private const int RT_ICON = 3; + private const int RT_GROUP_ICON = 14; + private const uint LOAD_LIBRARY_AS_DATAFILE = 0x00000002; + private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern nint LoadLibraryEx(string lpFileName, nint hFile, uint dwFlags); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool FreeLibrary(nint hModule); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern nint FindResource(nint hModule, nint lpName, nint lpType); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern nint LoadResource(nint hModule, nint hResInfo); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern nint LockResource(nint hResData); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint SizeofResource(nint hModule, nint hResInfo); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool EnumResourceNames(nint hModule, nint lpszType, EnumResNameProc lpEnumFunc, nint lParam); + + private delegate bool EnumResNameProc(nint hModule, nint lpszType, nint lpszName, nint lParam); + + public static Task> ExtractIconsToFolderAsync(string dllPath, string outputFolder) + { + return Task.Run>(() => + { + List savedPaths = []; + Directory.CreateDirectory(outputFolder); + + nint hModule = LoadLibraryEx(dllPath, 0, LOAD_LIBRARY_AS_DATAFILE); + if (hModule == 0) + return savedPaths; + + try + { + ExtractGroupIcons(hModule, outputFolder, savedPaths); + } + finally + { + FreeLibrary(hModule); + } + + return savedPaths; + }); + } + + private static void ExtractGroupIcons(nint hModule, string outputFolder, List savedPaths) + { + EnumResourceNames(hModule, RT_GROUP_ICON, (hMod, _, lpszName, _) => + { + nint hResInfo = FindResource(hMod, lpszName, RT_GROUP_ICON); + if (hResInfo == 0) + return true; + + byte[]? groupData = ReadResourceBytes(hMod, hResInfo); + if (groupData is null || groupData.Length < 6) + return true; + + // GRPICONDIR: reserved(2) + type(2) + count(2) + int count = BitConverter.ToInt16(groupData, 4); + string groupName = GetResourceName(lpszName); + + // Pick the entry with the largest pixel dimensions — that is the best source + // image for the user to scale down from. All entries in the same group are + // different-resolution versions of the same icon; we only need one source. + int bestDim = -1; + ushort bestNId = 0; + int bestDisplayWidth = 0; + int bestDisplayHeight = 0; + + for (int j = 0; j < count; j++) + { + // GRPICONDIRENTRY starts at offset 6, each entry is 14 bytes + int entryOffset = 6 + j * 14; + if (entryOffset + 14 > groupData.Length) + break; + + byte width = groupData[entryOffset]; + byte height = groupData[entryOffset + 1]; + int displayWidth = width == 0 ? 256 : width; + int displayHeight = height == 0 ? 256 : height; + int dim = displayWidth * displayHeight; + + if (dim > bestDim) + { + bestDim = dim; + // nId is at offset +12 within the entry (2 bytes) + bestNId = BitConverter.ToUInt16(groupData, entryOffset + 12); + bestDisplayWidth = displayWidth; + bestDisplayHeight = displayHeight; + } + } + + if (bestDim < 0) + return true; + + nint hIconRes = FindResource(hMod, bestNId, RT_ICON); + if (hIconRes == 0) + return true; + + byte[]? iconData = ReadResourceBytes(hMod, hIconRes); + if (iconData is null) + return true; + + string outputPath = Path.Combine(outputFolder, $"{groupName}.png"); + + try + { + SaveIconAsPng(iconData, bestDisplayWidth, bestDisplayHeight, outputPath); + savedPaths.Add(outputPath); + } + catch + { + // Skip icons that fail to convert + } + + return true; + }, 0); + } + + private static string GetResourceName(nint lpszName) + { + // IS_INTRESOURCE: pointer value <= 0xFFFF + if ((ulong)lpszName <= 0xFFFF) + return lpszName.ToString(); + + return Marshal.PtrToStringUni(lpszName) ?? lpszName.ToString(); + } + + private static byte[]? ReadResourceBytes(nint hModule, nint hResInfo) + { + uint size = SizeofResource(hModule, hResInfo); + if (size == 0) + return null; + + nint hResData = LoadResource(hModule, hResInfo); + if (hResData == 0) + return null; + + nint pData = LockResource(hResData); + if (pData == 0) + return null; + + byte[] data = new byte[size]; + Marshal.Copy(pData, data, 0, (int)size); + return data; + } + + private static void SaveIconAsPng(byte[] iconData, int width, int height, string outputPath) + { + // Windows Vista+ stores 256x256 RT_ICON entries as raw PNG data + if (iconData.Length >= 8 && iconData.AsSpan(0, 8).SequenceEqual(PngSignature)) + { + File.WriteAllBytes(outputPath, iconData); + return; + } + + // DIB data — wrap in a minimal ICO container so ImageMagick can decode it + byte[] icoBytes = BuildIcoFile(iconData, width, height); + MagickReadSettings settings = new() { Format = MagickFormat.Ico }; + using MagickImage image = new(icoBytes, settings); + image.Write(outputPath, MagickFormat.Png); + } + + private static byte[] BuildIcoFile(byte[] iconData, int width, int height) + { + // ICO header: 6 bytes + // ICONDIRENTRY: 16 bytes + // Total header: 22 bytes, image data follows + const int headerSize = 22; + byte[] ico = new byte[headerSize + iconData.Length]; + using MemoryStream ms = new(ico); + using BinaryWriter w = new(ms); + + // ICONDIR + w.Write((ushort)0); // reserved + w.Write((ushort)1); // type = 1 (icon) + w.Write((ushort)1); // count = 1 + + // ICONDIRENTRY + w.Write((byte)(width >= 256 ? 0 : width)); // width (0 = 256) + w.Write((byte)(height >= 256 ? 0 : height)); // height (0 = 256) + w.Write((byte)0); // color count + w.Write((byte)0); // reserved + w.Write((ushort)1); // planes + w.Write((ushort)32); // bit count + w.Write((uint)iconData.Length); // size of image data + w.Write((uint)headerSize); // offset of image data + + // Image data + w.Write(iconData); + + return ico; + } +} diff --git a/Simple Icon File Maker/Simple Icon File Maker/Helpers/FilePickerHelper.cs b/Simple Icon File Maker/Simple Icon File Maker/Helpers/FilePickerHelper.cs index b39c1ca..9c03e0a 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Helpers/FilePickerHelper.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Helpers/FilePickerHelper.cs @@ -1,32 +1,55 @@ +using Simple_Icon_File_Maker.Constants; using Windows.Storage; using Windows.Storage.Pickers; +using WinRT.Interop; namespace Simple_Icon_File_Maker.Helpers; public static class FilePickerHelper { - public static async Task TrySetSuggestedFolderFromSourceImage(FileSavePicker savePicker, string imagePath) + public static FileOpenPicker CreateDllPicker() { - if (string.IsNullOrWhiteSpace(imagePath)) - return; + FileOpenPicker picker = new() + { + ViewMode = PickerViewMode.List, + SuggestedStartLocation = PickerLocationId.ComputerFolder, + }; + + foreach (string ext in FileTypes.SupportedDllFormats) + picker.FileTypeFilter.Add(ext); - try + InitializeWithWindow.Initialize(picker, App.MainWindow.WindowHandle); + + return picker; + } + + public static async Task CreateSavePicker(string OutputPath, string ImagePath) + { + FileSavePicker savePicker = new() { - // Use the source image file itself to suggest the folder - // This makes the picker open in the source image's folder - if (File.Exists(imagePath)) + SuggestedStartLocation = PickerLocationId.PicturesLibrary, + + DefaultFileExtension = ".ico", + FileTypeChoices = { - StorageFile sourceFile = await StorageFile.GetFileFromPathAsync(imagePath); - savePicker.SuggestedSaveFile = sourceFile; - - // SuggestedSaveFile overrides SuggestedFileName, so re-set - // the name without the source extension to avoid names like "file.png.ico" - savePicker.SuggestedFileName = Path.GetFileNameWithoutExtension(imagePath); + { "ICO File", [".ico"] } } - } - catch + }; + + if (!string.IsNullOrWhiteSpace(OutputPath) && File.Exists(OutputPath)) { - // If file access fails, fall back to default picker behavior + try + { + StorageFile previousFile = await StorageFile.GetFileFromPathAsync(OutputPath); + savePicker.SuggestedSaveFile = previousFile; + } + catch { } } + + savePicker.SuggestedFileName = Path.GetFileNameWithoutExtension(ImagePath) + ".ico"; + + InitializeWithWindow.Initialize(savePicker, App.MainWindow.WindowHandle); + + return savePicker; } } diff --git a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs index 091cc94..de89f4b 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs @@ -29,7 +29,7 @@ public partial class MainViewModel : ObservableRecipient, INavigationAware, IDis private const int SettingsSaveDelayMs = 300; // Delay before saving settings private const int UiInitializationDelayMs = 200; // Delay to allow UI to initialize private int _countdownElapsedMs = 0; - private System.Timers.Timer _settingsSaveTimer = new(); + private readonly System.Timers.Timer _settingsSaveTimer = new(); private readonly UndoRedo _undoRedo = new(); private CancellationTokenSource? _loadImageCancellationTokenSource; private bool _disposed; @@ -206,7 +206,7 @@ public async Task NavigateToMulti() StorageFolder folder = await picker.PickSingleFolderAsync(); if (folder is not null) - NavigationService.NavigateTo(typeof(MultiViewModel).FullName!, folder); + NavigationService.NavigateTo(typeof(MultiViewModel).FullName!, new MultiPageParameter(folder)); } else { @@ -242,6 +242,35 @@ public async Task BrowseAndSelectImage() await LoadFromImagePathAsync(_loadImageCancellationTokenSource.Token); } + [RelayCommand] + public async Task BrowseAndSelectDll() + { + FileOpenPicker picker = FilePickerHelper.CreateDllPicker(); + + StorageFile file = await picker.PickSingleFileAsync(); + if (file is null) + return; + + string tempFolderPath = Path.Combine( + ApplicationData.Current.LocalCacheFolder.Path, + "dll_extract"); + + if (Directory.Exists(tempFolderPath)) + Directory.Delete(tempFolderPath, recursive: true); + + IReadOnlyList extractedPaths = + await DllIconExtractorHelper.ExtractIconsToFolderAsync(file.Path, tempFolderPath); + + if (extractedPaths.Count == 0) + { + ShowError($"No icons found in {file.Name}."); + return; + } + + StorageFolder extractFolder = await StorageFolder.GetFolderFromPathAsync(tempFolderPath); + NavigationService.NavigateTo(typeof(MultiViewModel).FullName!, new MultiPageParameter(extractFolder, IsFromDllExtraction: true, SourceFilePath: file.Path)); + } + [RelayCommand] public async Task ClearImage() { @@ -369,9 +398,7 @@ public async Task SaveIcon() { try { - FileSavePicker savePicker = CreateSavePicker(); - await FilePickerHelper.TrySetSuggestedFolderFromSourceImage(savePicker, ImagePath); - InitializeWithWindow.Initialize(savePicker, App.MainWindow.WindowHandle); + FileSavePicker savePicker = await FilePickerHelper.CreateSavePicker(OutputPath, ImagePath); StorageFile file = await savePicker.PickSaveFileAsync(); @@ -410,9 +437,7 @@ public async Task SaveAllImages() { try { - FileSavePicker savePicker = CreateSavePicker(); - await FilePickerHelper.TrySetSuggestedFolderFromSourceImage(savePicker, ImagePath); - InitializeWithWindow.Initialize(savePicker, App.MainWindow.WindowHandle); + FileSavePicker savePicker = await FilePickerHelper.CreateSavePicker(OutputPath, ImagePath); StorageFile file = await savePicker.PickSaveFileAsync(); @@ -624,9 +649,8 @@ public async Task RemoveBackground() if (dialog.ResultImagePath is not null) { - ImageMagick.MagickImage resultImage = new(dialog.ResultImagePath); - if (MainImage != null) - MainImage.Source = resultImage.ToImageSource(); + MagickImage resultImage = new(dialog.ResultImagePath); + MainImage?.Source = resultImage.ToImageSource(); MagickImageUndoRedoItem undoRedoItem = new(MainImage!, ImagePath, dialog.ResultImagePath); _undoRedo.AddUndo(undoRedoItem); @@ -788,7 +812,9 @@ public async Task HandleDrop(DragEventArgs e) } } +#pragma warning disable CA1822 // Mark members as static public void HandleDragOver(DragEventArgs e) +#pragma warning restore CA1822 // Mark members as static { DataPackageView dataView = e.DataView; @@ -1031,32 +1057,6 @@ private void SelectIconSizesFromPreview() SizesControl.UpdateEnabledSizes(smallerSide); } - private FileSavePicker CreateSavePicker() - { - FileSavePicker savePicker = new() - { - SuggestedStartLocation = PickerLocationId.PicturesLibrary, - }; - savePicker.FileTypeChoices.Add("ICO File", [".ico"]); - savePicker.DefaultFileExtension = ".ico"; - savePicker.SuggestedFileName = Path.GetFileNameWithoutExtension(ImagePath); - - if (!string.IsNullOrWhiteSpace(OutputPath) && File.Exists(OutputPath)) - { - try - { - Task.Run(async () => - { - StorageFile previousFile = await StorageFile.GetFileFromPathAsync(OutputPath); - savePicker.SuggestedSaveFile = previousFile; - }).Wait(); - } - catch { } - } - - return savePicker; - } - private void ShowError(string message) { ErrorMessage = message; diff --git a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs index 3a96e3e..eeb50f1 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs @@ -13,9 +13,12 @@ namespace Simple_Icon_File_Maker.ViewModels; +public record MultiPageParameter(StorageFolder Folder, bool IsFromDllExtraction = false, string? SourceFilePath = null); + public partial class MultiViewModel : ObservableRecipient, INavigationAware { private StorageFolder? _folder; + private string? _sourceFilePath; public ObservableCollection Previews { get; } = []; @@ -59,6 +62,21 @@ public partial class MultiViewModel : ObservableRecipient, INavigationAware [ObservableProperty] public partial int SizesGenerating { get; set; } = 0; + [ObservableProperty] + public partial bool IsFromDllExtraction { get; set; } = false; + + [ObservableProperty] + public partial bool IsFolderMode { get; set; } = true; + + [ObservableProperty] + public partial string FilesDescriptionSuffix { get; set; } = "\u00A0image files in the folder."; + + [ObservableProperty] + public partial string CloseButtonText { get; set; } = "Close Folder"; + + [ObservableProperty] + public partial string OpenSourceTooltip { get; set; } = "Open folder..."; + [RelayCommand] public void GoBack() { @@ -175,12 +193,14 @@ public async Task ReloadFiles() [RelayCommand] public async Task OpenFolder() { - if (_folder is null) - return; + string? targetPath = IsFromDllExtraction && _sourceFilePath is not null + ? Path.GetDirectoryName(_sourceFilePath) + : _folder?.Path; - string outputDirectory = _folder.Path; + if (string.IsNullOrEmpty(targetPath)) + return; - Uri uri = new(outputDirectory); + Uri uri = new(targetPath); LauncherOptions options = new() { TreatAsUntrusted = false, @@ -207,12 +227,35 @@ public void OnNavigatedFrom() public async void OnNavigatedTo(object parameter) { - if (parameter is StorageFolder folder) + if (parameter is MultiPageParameter navParam) + { + _folder = navParam.Folder; + IsFromDllExtraction = navParam.IsFromDllExtraction; + IsFolderMode = !IsFromDllExtraction; + _sourceFilePath = navParam.SourceFilePath; + } + else if (parameter is StorageFolder folder) + { _folder = folder; + } - FolderName = _folder?.Path ?? "Folder path"; - if (FolderName.Length > 50) // truncate the text from the middle - FolderName = string.Concat(FolderName.AsSpan(0, 20), "...", FolderName.AsSpan(FolderName.Length - 20)); + if (IsFromDllExtraction && _sourceFilePath is not null) + { + FolderName = Path.GetFileName(_sourceFilePath); + FilesDescriptionSuffix = "\u00A0icons extracted."; + CloseButtonText = "Close"; + OpenSourceTooltip = "Open containing folder..."; + } + else + { + string path = _folder?.Path ?? "Folder path"; + FolderName = path.Length > 50 + ? string.Concat(path.AsSpan(0, 20), "...", path.AsSpan(path.Length - 20)) + : path; + FilesDescriptionSuffix = "\u00A0image files in the folder."; + CloseButtonText = "Close Folder"; + OpenSourceTooltip = "Open folder..."; + } LoadIconSizes(); await LoadFiles(); diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/MainPage.xaml b/Simple Icon File Maker/Simple Icon File Maker/Views/MainPage.xaml index 43da06d..6b43b8f 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Views/MainPage.xaml +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/MainPage.xaml @@ -137,7 +137,7 @@ Height="32" HorizontalAlignment="Left" Command="{x:Bind ViewModel.NavigateToMultiCommand}" - ToolTipService.ToolTip="(Pro) Open a folder of images"> + ToolTipService.ToolTip="(Pro) Open a folder of images Right-click for more options"> + + + + + + + + + diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/MultiPage.xaml b/Simple Icon File Maker/Simple Icon File Maker/Views/MultiPage.xaml index b623f79..3a05195 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Views/MultiPage.xaml +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/MultiPage.xaml @@ -64,11 +64,17 @@ Orientation="Vertical" Spacing="12"> - + + + ToolTipService.ToolTip="{x:Bind ViewModel.OpenSourceTooltip, Mode=OneWay}"> - + - + @@ -95,15 +103,14 @@ From 3716766592e251bb8dc97863c5975b0e611eb28d Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 27 Mar 2026 09:46:47 -0700 Subject: [PATCH 02/22] Add a checker background option behind the image previews --- .claude/settings.local.json | 7 ++ .../Controls/PreviewImage.xaml.cs | 68 ++++++++++++++++++- .../Controls/PreviewStack.xaml.cs | 6 +- .../ViewModels/MainViewModel.cs | 32 ++++++++- .../ViewModels/MultiViewModel.cs | 26 ++++++- .../Views/MainPage.xaml | 31 +++++++-- .../Views/MultiPage.xaml | 49 +++++++++---- 7 files changed, 191 insertions(+), 28 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..00fc07d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet build:*)" + ] + } +} diff --git a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs index a253d63..8cf03ee 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs @@ -5,7 +5,9 @@ using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Hosting; using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; using System.Diagnostics; +using System.Runtime.InteropServices.WindowsRuntime; using Windows.ApplicationModel.DataTransfer; using Windows.Foundation; using Windows.Storage; @@ -21,18 +23,43 @@ public sealed partial class PreviewImage : UserControl private readonly StorageFile _imageFile; private readonly int _sideLength = 0; - public PreviewImage(StorageFile imageFile, int sideLength, string originalName) + public PreviewImage(StorageFile imageFile, int sideLength, string originalName, bool showCheckerBackground) { InitializeComponent(); _imageFile = imageFile; _sideLength = sideLength; + ShowCheckerBackground = showCheckerBackground; ToolTipService.SetToolTip(this, $"{sideLength} x {sideLength}"); OriginalName = originalName; + ActualThemeChanged += (_, _) => + { + int size = isZooming ? ZoomedWidthSpace : _sideLength; + mainImageCanvas.Background = _showCheckerBackground + ? CreateCheckerBrush(size, ActualTheme) + : new SolidColorBrush(Colors.Transparent); + }; } private bool isZooming = false; + private bool _showCheckerBackground = true; public int ZoomedWidthSpace = 100; + public bool ShowCheckerBackground + { + get => _showCheckerBackground; + set + { + if (value != _showCheckerBackground) + { + _showCheckerBackground = value; + int size = isZooming ? ZoomedWidthSpace : _sideLength; + mainImageCanvas.Background = _showCheckerBackground + ? CreateCheckerBrush(size, ActualTheme) + : new SolidColorBrush(Colors.Transparent); + } + } + } + public bool ZoomPreview { get => isZooming; @@ -101,6 +128,12 @@ private void UserControl_Loaded(object sender, RoutedEventArgs e) private void LoadImageOnToCanvas() { mainImageCanvas.Children.Clear(); + + int size = isZooming ? ZoomedWidthSpace : _sideLength; + mainImageCanvas.Background = ShowCheckerBackground + ? CreateCheckerBrush(size, ActualTheme) + : new SolidColorBrush(Colors.Transparent); + // from StackOverflow // user: // https://stackoverflow.com/users/403671/simon-mourier @@ -123,8 +156,6 @@ private void LoadImageOnToCanvas() LoadedImageSurface image = LoadedImageSurface.StartLoadFromUri(new Uri(_imageFile.Path)); brush.Surface = image; - int size = isZooming ? ZoomedWidthSpace : _sideLength; - // set the visual size when the image has loaded image.LoadCompleted += (s, e) => { @@ -146,6 +177,37 @@ private void LoadImageOnToCanvas() mainImageCanvas.Children.Add(tempGrid); } + private static ImageBrush CreateCheckerBrush(int size, ElementTheme theme) + { + int tileSize = 8; + WriteableBitmap bitmap = new(size, size); + + // Light mode: #F0F0F0 / #C4C4C4 — Dark mode: #404040 / #2A2A2A + bool isDark = theme == ElementTheme.Dark; + byte tileLight = isDark ? (byte)0x40 : (byte)0xF0; + byte tileDark = isDark ? (byte)0x2A : (byte)0xC4; + + byte[] pixels = new byte[size * size * 4]; // BGRA format + for (int row = 0; row < size; row++) + { + for (int col = 0; col < size; col++) + { + bool isLightTile = ((row / tileSize) + (col / tileSize)) % 2 == 0; + byte val = isLightTile ? tileLight : tileDark; + int idx = (row * size + col) * 4; + pixels[idx] = val; // B + pixels[idx + 1] = val; // G + pixels[idx + 2] = val; // R + pixels[idx + 3] = 255; // A + } + } + + using Stream stream = bitmap.PixelBuffer.AsStream(); + stream.Write(pixels, 0, pixels.Length); + + return new ImageBrush { ImageSource = bitmap, Stretch = Stretch.Fill }; + } + private async void ImagePreview_DragStarting(UIElement sender, DragStartingEventArgs args) { DragOperationDeferral deferral = args.GetDeferral(); diff --git a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs index 77a7573..90afad5 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs @@ -24,6 +24,7 @@ public sealed partial class PreviewStack : UserControl public List ChosenSizes { get; private set; } public bool IsZoomingPreview { get; set; } = false; + public bool ShowCheckerBackground { get; set; } = true; public bool CanRefresh => CheckIfRefreshIsNeeded(); @@ -334,7 +335,7 @@ private async Task OpenIconFile(IProgress progress) int sideLength = (int)image.Width; StorageFile imageSF = await StorageFile.GetFileFromPathAsync(pathForSingleImage); - PreviewImage previewImage = new(imageSF, sideLength, imageName); + PreviewImage previewImage = new(imageSF, sideLength, imageName, ShowCheckerBackground); PreviewStackPanel.Children.Add(previewImage); currentLocation++; @@ -375,7 +376,7 @@ public async Task UpdatePreviewsAsync() StorageFile imageSF = await StorageFile.GetFileFromPathAsync(imagePath); - PreviewImage image = new(imageSF, sideLength, originalName); + PreviewImage image = new(imageSF, sideLength, originalName, ShowCheckerBackground); PreviewStackPanel.Children.Add(image); } @@ -421,6 +422,7 @@ public void UpdateSizeAndZoom() if (!double.IsNaN(ActualWidth) && ActualWidth > 40) img.ZoomedWidthSpace = (int)ActualWidth - 40; img.ZoomPreview = IsZoomingPreview; + img.ShowCheckerBackground = ShowCheckerBackground; } } diff --git a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs index de89f4b..6ecc1e5 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs @@ -41,6 +41,9 @@ public partial class MainViewModel : ObservableRecipient, INavigationAware, IDis [ObservableProperty] public partial bool IsAutoRefreshEnabled { get; set; } = true; + [ObservableProperty] + public partial bool IsCheckerBackgroundVisible { get; set; } = false; + [ObservableProperty] public partial double CountdownProgress { get; set; } = 0; @@ -133,6 +136,23 @@ partial void OnIsAutoRefreshEnabledChanged(bool value) _settingsSaveTimer.Start(); } + partial void OnIsCheckerBackgroundVisibleChanged(bool value) + { + if (PreviewsGrid is null) + return; + + foreach (UIElement element in PreviewsGrid.Children) + { + if (element is Controls.PreviewStack stack) + { + stack.ShowCheckerBackground = value; + stack.UpdateSizeAndZoom(); + } + } + + _localSettingsService.SaveSettingAsync(nameof(IsCheckerBackgroundVisible), value); + } + private async void SettingsSaveTimer_Elapsed(object? sender, ElapsedEventArgs e) { _settingsSaveTimer.Stop(); @@ -150,6 +170,15 @@ public async void OnNavigatedTo(object parameter) IsAutoRefreshEnabled = true; } + try + { + IsCheckerBackgroundVisible = await _localSettingsService.ReadSettingAsync(nameof(IsCheckerBackgroundVisible)); + } + catch (Exception) + { + IsCheckerBackgroundVisible = false; + } + ShowUpgradeToProButton = !_storeService.OwnsPro; // Load shared image path from share target activation @@ -963,7 +992,8 @@ private async Task LoadFromImagePathAsync(CancellationToken cancellationToken = List selectedSizes = [.. SizesControl.ViewModel.IconSizes.Where(x => x.IsSelected)]; Controls.PreviewStack previewStack = new(ImagePath, selectedSizes) { - SortOrder = _iconSizesService.SortOrder + SortOrder = _iconSizesService.SortOrder, + ShowCheckerBackground = IsCheckerBackgroundVisible }; PreviewsGrid?.Children.Add(previewStack); diff --git a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs index eeb50f1..1abab5d 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs @@ -7,6 +7,7 @@ using Simple_Icon_File_Maker.Controls; using Simple_Icon_File_Maker.Helpers; using Simple_Icon_File_Maker.Models; +using Simple_Icon_File_Maker.Services; using System.Collections.ObjectModel; using Windows.Storage; using Windows.System; @@ -19,6 +20,8 @@ public partial class MultiViewModel : ObservableRecipient, INavigationAware { private StorageFolder? _folder; private string? _sourceFilePath; + private readonly ILocalSettingsService _localSettingsService; + public ObservableCollection Previews { get; } = []; @@ -32,6 +35,9 @@ public partial class MultiViewModel : ObservableRecipient, INavigationAware [ObservableProperty] public partial int FileLoadProgress { get; set; } = 0; + [ObservableProperty] + public partial bool IsCheckerBackgroundVisible { get; set; } = false; + [ObservableProperty] public partial bool LoadingImages { get; set; } = false; @@ -77,6 +83,20 @@ public partial class MultiViewModel : ObservableRecipient, INavigationAware [ObservableProperty] public partial string OpenSourceTooltip { get; set; } = "Open folder..."; + partial void OnIsCheckerBackgroundVisibleChanged(bool value) + { + if (Previews is null || Previews.Count == 0) + return; + + foreach (PreviewStack stack in Previews) + { + stack.ShowCheckerBackground = value; + stack.UpdateSizeAndZoom(); + } + + _localSettingsService.SaveSettingAsync(nameof(IsCheckerBackgroundVisible), value); + } + [RelayCommand] public void GoBack() { @@ -215,9 +235,10 @@ private INavigationService NavigationService get; } - public MultiViewModel(INavigationService navigationService) + public MultiViewModel(INavigationService navigationService, ILocalSettingsService localSettingsService) { NavigationService = navigationService; + _localSettingsService = localSettingsService; } public void OnNavigatedFrom() @@ -352,7 +373,8 @@ private async Task LoadFiles() MaxWidth = 600, MinWidth = 300, Margin = new Thickness(6), - SortOrder = sortOrder + SortOrder = sortOrder, + ShowCheckerBackground = IsCheckerBackgroundVisible }; Previews.Add(preview); diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/MainPage.xaml b/Simple Icon File Maker/Simple Icon File Maker/Views/MainPage.xaml index 6b43b8f..d37ca20 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Views/MainPage.xaml +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/MainPage.xaml @@ -153,9 +153,7 @@ - + @@ -474,10 +472,29 @@ Height="32" Command="{x:Bind ViewModel.ZoomPreviewsCommand}" CommandParameter="{x:Bind ZoomPreviewToggleButton.IsChecked, Mode=OneWay}" - Content="" - FontFamily="{StaticResource SymbolThemeFontFamily}" - FontSize="16" - ToolTipService.ToolTip="Zoom in on icon previews" /> + ToolTipService.ToolTip="Zoom in on icon previews"> + + + + + + + + + + + + + + + + - + - + @@ -218,14 +214,41 @@ - + Margin="0,0,8,0" + HorizontalAlignment="Right" + Orientation="Horizontal" + Spacing="8"> + + + + + + + + + + + + + + + + + + Date: Fri, 27 Mar 2026 09:52:52 -0700 Subject: [PATCH 03/22] code style --- .../Simple Icon File Maker/Controls/PreviewImage.xaml.cs | 4 ++-- .../Simple Icon File Maker/Helpers/FilePickerHelper.cs | 2 +- .../Simple Icon File Maker/ViewModels/MultiViewModel.cs | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs index 8cf03ee..7f10733 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs @@ -185,7 +185,7 @@ private static ImageBrush CreateCheckerBrush(int size, ElementTheme theme) // Light mode: #F0F0F0 / #C4C4C4 — Dark mode: #404040 / #2A2A2A bool isDark = theme == ElementTheme.Dark; byte tileLight = isDark ? (byte)0x40 : (byte)0xF0; - byte tileDark = isDark ? (byte)0x2A : (byte)0xC4; + byte tileDark = isDark ? (byte)0x2A : (byte)0xC4; byte[] pixels = new byte[size * size * 4]; // BGRA format for (int row = 0; row < size; row++) @@ -195,7 +195,7 @@ private static ImageBrush CreateCheckerBrush(int size, ElementTheme theme) bool isLightTile = ((row / tileSize) + (col / tileSize)) % 2 == 0; byte val = isLightTile ? tileLight : tileDark; int idx = (row * size + col) * 4; - pixels[idx] = val; // B + pixels[idx] = val; // B pixels[idx + 1] = val; // G pixels[idx + 2] = val; // R pixels[idx + 3] = 255; // A diff --git a/Simple Icon File Maker/Simple Icon File Maker/Helpers/FilePickerHelper.cs b/Simple Icon File Maker/Simple Icon File Maker/Helpers/FilePickerHelper.cs index 9c03e0a..d1afb75 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Helpers/FilePickerHelper.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Helpers/FilePickerHelper.cs @@ -28,7 +28,7 @@ public static async Task CreateSavePicker(string OutputPath, str FileSavePicker savePicker = new() { SuggestedStartLocation = PickerLocationId.PicturesLibrary, - + DefaultFileExtension = ".ico", FileTypeChoices = { diff --git a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs index 1abab5d..603f76a 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs @@ -7,7 +7,6 @@ using Simple_Icon_File_Maker.Controls; using Simple_Icon_File_Maker.Helpers; using Simple_Icon_File_Maker.Models; -using Simple_Icon_File_Maker.Services; using System.Collections.ObjectModel; using Windows.Storage; using Windows.System; From 39554ce5be14333865b12ea5797a7200fa4d74ac Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 27 Mar 2026 19:23:01 -0500 Subject: [PATCH 04/22] Update NuGet package dependencies to latest versions Updated Microsoft.WindowsAppSDK, CommunityToolkit.Mvvm, Magick.NET-Q16-AnyCPU, and WindowsSdkPackageVersion to their latest versions in both .csproj and .wapproj files. No functional code changes were made. --- .../Simple Icon File Maker (Package).wapproj | 2 +- .../Simple Icon File Maker/Simple Icon File Maker.csproj | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Simple Icon File Maker/Simple Icon File Maker (Package)/Simple Icon File Maker (Package).wapproj b/Simple Icon File Maker/Simple Icon File Maker (Package)/Simple Icon File Maker (Package).wapproj index cf76f85..8372370 100644 --- a/Simple Icon File Maker/Simple Icon File Maker (Package)/Simple Icon File Maker (Package).wapproj +++ b/Simple Icon File Maker/Simple Icon File Maker (Package)/Simple Icon File Maker (Package).wapproj @@ -123,7 +123,7 @@ - + build diff --git a/Simple Icon File Maker/Simple Icon File Maker/Simple Icon File Maker.csproj b/Simple Icon File Maker/Simple Icon File Maker/Simple Icon File Maker.csproj index 76c6aa4..333c47e 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Simple Icon File Maker.csproj +++ b/Simple Icon File Maker/Simple Icon File Maker/Simple Icon File Maker.csproj @@ -2,7 +2,7 @@ WinExe net9.0-windows10.0.26100.0 - 10.0.26100.38 + 10.0.26100.56 10.0.19041.0 10.0.19041.0 Simple_Icon_File_Maker @@ -39,14 +39,14 @@ - + - + - + From ac20254e59e8d4ba333e48fbd41c21fee28338be Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 27 Mar 2026 19:57:05 -0500 Subject: [PATCH 05/22] Refactor CreateCheckerBrush for dynamic tile scaling Update CreateCheckerBrush to accept baseSideLength and scale tileSize based on image size and base side length. Update all usages to pass _sideLength as the new parameter. This improves checker pattern scaling for different image sizes. --- .../Controls/PreviewImage.xaml.cs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs index 7f10733..4c27f2c 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs @@ -35,7 +35,7 @@ public PreviewImage(StorageFile imageFile, int sideLength, string originalName, { int size = isZooming ? ZoomedWidthSpace : _sideLength; mainImageCanvas.Background = _showCheckerBackground - ? CreateCheckerBrush(size, ActualTheme) + ? CreateCheckerBrush(size, _sideLength, ActualTheme) : new SolidColorBrush(Colors.Transparent); }; } @@ -54,7 +54,7 @@ public bool ShowCheckerBackground _showCheckerBackground = value; int size = isZooming ? ZoomedWidthSpace : _sideLength; mainImageCanvas.Background = _showCheckerBackground - ? CreateCheckerBrush(size, ActualTheme) + ? CreateCheckerBrush(size, _sideLength, ActualTheme) : new SolidColorBrush(Colors.Transparent); } } @@ -131,7 +131,7 @@ private void LoadImageOnToCanvas() int size = isZooming ? ZoomedWidthSpace : _sideLength; mainImageCanvas.Background = ShowCheckerBackground - ? CreateCheckerBrush(size, ActualTheme) + ? CreateCheckerBrush(size, _sideLength, ActualTheme) : new SolidColorBrush(Colors.Transparent); // from StackOverflow @@ -177,9 +177,25 @@ private void LoadImageOnToCanvas() mainImageCanvas.Children.Add(tempGrid); } - private static ImageBrush CreateCheckerBrush(int size, ElementTheme theme) + private static ImageBrush CreateCheckerBrush(int size, int baseSideLength, ElementTheme theme) { - int tileSize = 8; + // Find the divisor of 'size' closest to the ideal zoom-scaled tile size, + // so there are never partial tiles at the edges. + double ideal = 8.0 * size / baseSideLength; + int tileSize = 1; + double bestDiff = double.MaxValue; + for (int t = 1; t <= size; t++) + { + if (size % t == 0) + { + double diff = Math.Abs(t - ideal); + if (diff < bestDiff) + { + bestDiff = diff; + tileSize = t; + } + } + } WriteableBitmap bitmap = new(size, size); // Light mode: #F0F0F0 / #C4C4C4 — Dark mode: #404040 / #2A2A2A From 2d6c34a51fcc43a6f13de99569f91b9558a05f80 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 27 Mar 2026 21:20:54 -0500 Subject: [PATCH 06/22] Update navigation, permissions, and UI behaviors - Allow Bash grep for .cs/.xaml in settings.local.json - Only load CLI args on first navigation in MainViewModel - Prevent image reload when returning from About page - Remove Dispose() call from OnNavigatedFrom - Remove About page back button - Enable NavigationCacheMode on MainPage - Simplify MultiPage layout and padding - Add TitleBar back button support in ShellPage and ViewModel --- .claude/settings.local.json | 3 ++- .../ViewModels/MainViewModel.cs | 12 +++++++++--- .../Simple Icon File Maker/Views/AboutPage.xaml | 13 +------------ .../Simple Icon File Maker/Views/MainPage.xaml | 1 + .../Simple Icon File Maker/Views/MultiPage.xaml | 7 +------ .../Simple Icon File Maker/Views/ShellPage.xaml | 6 +++++- .../Simple Icon File Maker/Views/ShellPage.xaml.cs | 5 +++++ 7 files changed, 24 insertions(+), 23 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 00fc07d..2955c66 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(dotnet build:*)" + "Bash(dotnet build:*)", + "Bash(grep -E \"\\\\.\\(cs|xaml\\)$\")" ] } } diff --git a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs index 6ecc1e5..ed3160c 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs @@ -32,6 +32,7 @@ public partial class MainViewModel : ObservableRecipient, INavigationAware, IDis private readonly System.Timers.Timer _settingsSaveTimer = new(); private readonly UndoRedo _undoRedo = new(); private CancellationTokenSource? _loadImageCancellationTokenSource; + private bool _hasNavigatedOnce; private bool _disposed; private readonly ILocalSettingsService _localSettingsService; @@ -187,12 +188,18 @@ public async void OnNavigatedTo(object parameter) ImagePath = App.SharedImagePath; App.SharedImagePath = null; } - // Load CLI args if present - else if (App.cliArgs?.Length > 1) + // Load CLI args if present on first navigation only + else if (!_hasNavigatedOnce && App.cliArgs?.Length > 1) { ImagePath = App.cliArgs[1]; } + _hasNavigatedOnce = true; + + // Skip reloading if an image is already loaded (returning from About page) + if (IsImageSelected) + return; + LoadIconSizes(); // Delayed load to allow UI to initialize @@ -206,7 +213,6 @@ public async void OnNavigatedTo(object parameter) public void OnNavigatedFrom() { _loadImageCancellationTokenSource?.Cancel(); - Dispose(); } [RelayCommand] diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml b/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml index a6cd53b..48e1f7e 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml @@ -12,18 +12,7 @@ Padding="80,20,80,20" HorizontalAlignment="Stretch" Spacing="12"> - - - - + diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/MultiPage.xaml b/Simple Icon File Maker/Simple Icon File Maker/Views/MultiPage.xaml index 1e19f5b..123a5af 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Views/MultiPage.xaml +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/MultiPage.xaml @@ -45,11 +45,6 @@ - - - - - @@ -58,7 +53,7 @@ - + - + diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/ShellPage.xaml.cs b/Simple Icon File Maker/Simple Icon File Maker/Views/ShellPage.xaml.cs index 2d561d6..2185170 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Views/ShellPage.xaml.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/ShellPage.xaml.cs @@ -25,4 +25,9 @@ public ShellPage(ShellViewModel viewModel) if (App.GetService().OwnsPro) titleBar.Subtitle += " Pro"; } + + private void TitleBar_BackRequested(TitleBar sender, object args) + { + ViewModel.BackCommand.Execute(null); + } } From 8010b4da95c39e053d1d2c18770b514c9e36c423 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 29 Mar 2026 18:40:08 -0500 Subject: [PATCH 07/22] Fix checker background blurriness and prime-width tiny-square bug Replace the exact-divisor search loop with Math.Round so prime canvas widths no longer collapse tileSize to 1 (1px noise). Bitmap is now always created at the actual canvas size so Stretch.Fill renders 1:1 with no scaling or interpolation, giving crisp edges at any zoom level. Co-Authored-By: Claude Sonnet 4.6 --- .../Controls/PreviewImage.xaml.cs | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs index 4c27f2c..b38916a 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs @@ -179,23 +179,12 @@ private void LoadImageOnToCanvas() private static ImageBrush CreateCheckerBrush(int size, int baseSideLength, ElementTheme theme) { - // Find the divisor of 'size' closest to the ideal zoom-scaled tile size, - // so there are never partial tiles at the edges. - double ideal = 8.0 * size / baseSideLength; - int tileSize = 1; - double bestDiff = double.MaxValue; - for (int t = 1; t <= size; t++) - { - if (size % t == 0) - { - double diff = Math.Abs(t - ideal); - if (diff < bestDiff) - { - bestDiff = diff; - tileSize = t; - } - } - } + // Bitmap is created at the actual canvas size (size × size) so Stretch.Fill renders + // it at exactly 1:1 — no scaling, no interpolation, always crisp edges. + // Tile count per side = baseSideLength / 8, driven purely by the icon size. + // Math.Round replaces the old exact-divisor loop that failed on prime canvas widths. + int tileSize = Math.Max(1, (int)Math.Round(8.0 * size / baseSideLength)); + WriteableBitmap bitmap = new(size, size); // Light mode: #F0F0F0 / #C4C4C4 — Dark mode: #404040 / #2A2A2A From 1a00eaeaeb90e0d4820f70e49f6e26de05c46446 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 29 Mar 2026 21:05:40 -0500 Subject: [PATCH 08/22] Add theme switcher & rating/feedback to About page Update About page with theme selection (light/dark/default) and in-app rating control. Show changelog for v1.16/v1.15. Add feedback dialog for low ratings and Store review for high ratings. Escape key now navigates back. Bump app version to 1.16.0.0. Minor refactoring and new dependencies in AboutViewModel. --- .../Package.appxmanifest | 4 +- .../Services/ThemeSelectorService.cs | 3 + .../ViewModels/AboutViewModel.cs | 16 +++- .../Views/AboutPage.xaml | 66 +++++++++++++++ .../Views/AboutPage.xaml.cs | 83 ++++++++++++++++++- 5 files changed, 166 insertions(+), 6 deletions(-) diff --git a/Simple Icon File Maker/Simple Icon File Maker (Package)/Package.appxmanifest b/Simple Icon File Maker/Simple Icon File Maker (Package)/Package.appxmanifest index c3b035f..b9ccb65 100644 --- a/Simple Icon File Maker/Simple Icon File Maker (Package)/Package.appxmanifest +++ b/Simple Icon File Maker/Simple Icon File Maker (Package)/Package.appxmanifest @@ -1,4 +1,4 @@ - + + Version="1.16.0.0" /> diff --git a/Simple Icon File Maker/Simple Icon File Maker/Services/ThemeSelectorService.cs b/Simple Icon File Maker/Simple Icon File Maker/Services/ThemeSelectorService.cs index 919c86d..6df1dd0 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Services/ThemeSelectorService.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Services/ThemeSelectorService.cs @@ -33,6 +33,9 @@ public async Task SetThemeAsync(ElementTheme theme) public async Task SetRequestedThemeAsync() { + if (App.MainWindow.Content is FrameworkElement rootElement) + rootElement.RequestedTheme = Theme; + await Task.CompletedTask; } diff --git a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/AboutViewModel.cs b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/AboutViewModel.cs index d77b256..15fe09f 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/AboutViewModel.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/AboutViewModel.cs @@ -1,11 +1,16 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.UI.Xaml; using Simple_Icon_File_Maker.Contracts.Services; namespace Simple_Icon_File_Maker.ViewModels; public partial class AboutViewModel : ObservableRecipient { + private readonly IThemeSelectorService _themeSelectorService; + + public ElementTheme Theme => _themeSelectorService.Theme; + [RelayCommand] public void GoBack() { @@ -17,8 +22,15 @@ private INavigationService NavigationService get; } - public AboutViewModel(INavigationService navigationService) + public AboutViewModel(INavigationService navigationService, IThemeSelectorService themeSelectorService) { NavigationService = navigationService; + _themeSelectorService = themeSelectorService; + } + + public async Task SwitchThemeAsync(ElementTheme theme) + { + await _themeSelectorService.SetThemeAsync(theme); + OnPropertyChanged(nameof(Theme)); } } diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml b/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml index 48e1f7e..709d768 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml @@ -13,6 +13,49 @@ HorizontalAlignment="Stretch" Spacing="12"> + + + + + + + + + + + + + + + + + + + + + + + + + + + ● Extract icons from EXE and DLL files + ● Checker background behind image previews + ● Improved navigation with TitleBar back button + ● Bug fixes + + + + + + + + ● AI-powered background removal + ● Windows Share Target support + ● Improved save file picker naming + ● Update packages + ● Bug fixes + + + diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml.cs b/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml.cs index e4026c1..f07e5a1 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml.cs @@ -1,6 +1,9 @@ -using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; using Simple_Icon_File_Maker.ViewModels; using Windows.ApplicationModel; +using Windows.System; // To learn more about WinUI, the WinUI project structure, // and more about our project templates, see: http://aka.ms/winui-project-info. @@ -16,6 +19,31 @@ public AboutPage() InitializeComponent(); ViewModel = App.GetService(); VersionNumber.Text = GetAppDescription(); + InitializeThemeSelection(); + KeyDown += AboutPage_KeyDown; + } + + private void AboutPage_KeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == VirtualKey.Escape) + ViewModel.GoBack(); + } + + private void InitializeThemeSelection() + { + Microsoft.UI.Xaml.ElementTheme currentTheme = ViewModel.Theme; + DefaultThemeButton.IsChecked = currentTheme == Microsoft.UI.Xaml.ElementTheme.Default; + LightThemeButton.IsChecked = currentTheme == Microsoft.UI.Xaml.ElementTheme.Light; + DarkThemeButton.IsChecked = currentTheme == Microsoft.UI.Xaml.ElementTheme.Dark; + } + + private async void ThemeButton_Click(object sender, RoutedEventArgs e) + { + if (sender is RadioButton button && + Enum.TryParse(button.Tag?.ToString(), out Microsoft.UI.Xaml.ElementTheme theme)) + { + await ViewModel.SwitchThemeAsync(theme); + } } private static string GetAppDescription() @@ -30,6 +58,57 @@ private static string GetAppDescription() private async void ReviewBTN_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { // NavigateUri="ms-windows-store://review/?ProductId=9NS1BM1FB99Z" - bool result = await Windows.System.Launcher.LaunchUriAsync(new Uri("ms-windows-store://review/?ProductId=9NS1BM1FB99Z")); + await Windows.System.Launcher.LaunchUriAsync(new Uri("ms-windows-store://review/?ProductId=9NS1BM1FB99Z")); + } + + private async void AppRatingControl_ValueChanged(RatingControl sender, object args) + { + double rating = sender.Value; + sender.Value = -1; + + if (rating <= 0) + return; + + if (rating >= 4) + { + await Windows.System.Launcher.LaunchUriAsync(new Uri("ms-windows-store://review/?ProductId=9NS1BM1FB99Z")); + return; + } + + TextBox feedbackBox = new() + { + PlaceholderText = "Describe the issue or suggestion...", + AcceptsReturn = true, + Height = 120, + TextWrapping = TextWrapping.Wrap, + }; + + StackPanel content = new() { Spacing = 8 }; + content.Children.Add(new TextBlock + { + Text = "We're sorry to hear that! Please describe any issues or suggestions:", + TextWrapping = TextWrapping.Wrap, + }); + content.Children.Add(feedbackBox); + + ContentDialog dialog = new() + { + Title = "Send Feedback", + Content = content, + PrimaryButtonText = "Send Email", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Primary, + XamlRoot = XamlRoot, + }; + + ContentDialogResult result = await dialog.ShowAsync(); + + if (result == ContentDialogResult.Primary) + { + string subject = Uri.EscapeDataString("Simple Icon File Maker Feedback"); + string body = Uri.EscapeDataString(feedbackBox.Text); + await Windows.System.Launcher.LaunchUriAsync( + new Uri($"mailto:joe@joefinapps.com?subject={subject}&body={body}")); + } } } From 1a06a128a960b31a81a4dc4dcf8ac843ab1b16f6 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 29 Mar 2026 21:52:37 -0500 Subject: [PATCH 09/22] Add "gh issue view" to allowed Bash commands Updated settings.local.json to permit the "Bash(gh issue view:*)" command in the permissions list. Also made a minor formatting adjustment. --- .claude/settings.local.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2955c66..8dd036a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(dotnet build:*)", - "Bash(grep -E \"\\\\.\\(cs|xaml\\)$\")" + "Bash(grep -E \"\\\\.\\(cs|xaml\\)$\")", + "Bash(gh issue view:*)" ] } } From cc3b62c8d174e4cc93eeb6a6cc0c0ac6bdac0a47 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 29 Mar 2026 21:53:03 -0500 Subject: [PATCH 10/22] Update LoadingText to reflect enabled icon sizes only Changed LoadingText to use the count of enabled icon sizes instead of all sizes, and updated the wording from "previews" to "sizes" for clarity. The logic now sets the text after enabling/disabling icon sizes to ensure accuracy. --- .../Controls/PreviewStack.xaml.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs index 90afad5..ab2a4b9 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs @@ -164,10 +164,6 @@ public async Task GeneratePreviewImagesAsync(IProgress progress, stri ImagesProgressBar.Value = 0; progress.Report(0); - if (ChosenSizes.Count == 1) - LoadingText.Text = $"Generating {ChosenSizes.Count} preview for {name}..."; - else - LoadingText.Text = $"Generating {ChosenSizes.Count} previews for {name}..."; TextAndProgressBar.Visibility = Visibility.Visible; @@ -197,6 +193,12 @@ public async Task GeneratePreviewImagesAsync(IProgress progress, stri iconSize.IsEnabled = false; } + int enabledCount = ChosenSizes.Count(x => x.IsEnabled); + if (enabledCount == 1) + LoadingText.Text = $"Generating {enabledCount} size for {name}..."; + else + LoadingText.Text = $"Generating {enabledCount} sizes for {name}..."; + if (string.IsNullOrWhiteSpace(imagePath) == true) { ClearOutputImages(); From 9a77d9494bec673d5175f5a3cdedc06c550c1d25 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 29 Mar 2026 22:45:17 -0500 Subject: [PATCH 11/22] Expand allowed permissions for web search and fetch Added "WebSearch" and "WebFetch(domain:raw.githubusercontent.com)" to the permissions list in settings.local.json to enable web search and fetching raw content from GitHub. --- .claude/settings.local.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8dd036a..2ccc5b4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,9 @@ "allow": [ "Bash(dotnet build:*)", "Bash(grep -E \"\\\\.\\(cs|xaml\\)$\")", - "Bash(gh issue view:*)" + "Bash(gh issue view:*)", + "WebSearch", + "WebFetch(domain:raw.githubusercontent.com)" ] } } From 021c9906781976276e633e830d9b326969130213 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 29 Mar 2026 22:45:53 -0500 Subject: [PATCH 12/22] Add image cropping feature with new dialog Introduced CropImageDialog using CommunityToolkit.WinUI.Controls.ImageCropper for in-app image cropping. Added CropImageCommand to MainViewModel to handle cropping, updating the main image, and managing undo/redo. Registered the dialog and package in the project file. Updated MainPage UI to include a "Crop" menu item and improved XAML formatting and UI text/icons. --- .../Simple Icon File Maker.csproj | 5 + .../ViewModels/MainViewModel.cs | 31 ++++++ .../Views/AboutPage.xaml | 2 +- .../Views/CropImageDialog.xaml | 43 +++++++++ .../Views/CropImageDialog.xaml.cs | 72 ++++++++++++++ .../Views/MainPage.xaml | 95 +++++++++++++------ 6 files changed, 220 insertions(+), 28 deletions(-) create mode 100644 Simple Icon File Maker/Simple Icon File Maker/Views/CropImageDialog.xaml create mode 100644 Simple Icon File Maker/Simple Icon File Maker/Views/CropImageDialog.xaml.cs diff --git a/Simple Icon File Maker/Simple Icon File Maker/Simple Icon File Maker.csproj b/Simple Icon File Maker/Simple Icon File Maker/Simple Icon File Maker.csproj index 333c47e..0df71ca 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Simple Icon File Maker.csproj +++ b/Simple Icon File Maker/Simple Icon File Maker/Simple Icon File Maker.csproj @@ -31,6 +31,7 @@ + @@ -40,6 +41,7 @@ + @@ -81,6 +83,9 @@ $(DefaultXamlRuntime) + + $(DefaultXamlRuntime) + $(DefaultXamlRuntime) diff --git a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs index ed3160c..1a72e9b 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs @@ -702,6 +702,37 @@ public async Task RemoveBackground() } } + [RelayCommand] + public async Task CropImage() + { + if (string.IsNullOrWhiteSpace(ImagePath)) + return; + + try + { + CropImageDialog dialog = new(ImagePath); + await NavigationService.ShowModal(dialog); + + if (dialog.ResultImagePath is not null) + { + MagickImage resultImage = new(dialog.ResultImagePath); + MainImage?.Source = resultImage.ToImageSource(); + + MagickImageUndoRedoItem undoRedoItem = new(MainImage!, ImagePath, dialog.ResultImagePath); + _undoRedo.AddUndo(undoRedoItem); + UpdateUndoRedoState(); + + ImagePath = dialog.ResultImagePath; + await RefreshPreviews(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to crop image: {ex.Message}"); + ShowError($"Failed to crop image: {ex.Message}"); + } + } + [RelayCommand] public async Task Undo() { diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml b/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml index 709d768..10d8d6a 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml @@ -13,7 +13,7 @@ HorizontalAlignment="Stretch" Spacing="12"> - + diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/CropImageDialog.xaml b/Simple Icon File Maker/Simple Icon File Maker/Views/CropImageDialog.xaml new file mode 100644 index 0000000..6f37953 --- /dev/null +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/CropImageDialog.xaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/CropImageDialog.xaml.cs b/Simple Icon File Maker/Simple Icon File Maker/Views/CropImageDialog.xaml.cs new file mode 100644 index 0000000..4d5e59c --- /dev/null +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/CropImageDialog.xaml.cs @@ -0,0 +1,72 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Windows.Storage; +using Windows.Storage.Streams; + +namespace Simple_Icon_File_Maker; + +public sealed partial class CropImageDialog : ContentDialog +{ + public string? ResultImagePath { get; private set; } + + private readonly string _imagePath; + + public CropImageDialog(string imagePath) + { + InitializeComponent(); + _imagePath = imagePath; + PrimaryButtonClick += OnPrimaryButtonClick; + } + + private async void ContentDialog_Loaded(object sender, RoutedEventArgs e) + { + try + { + StorageFile file = await StorageFile.GetFileFromPathAsync(_imagePath); + await ImageCropperControl.LoadImageFromFile(file); + + LoadingRing.IsActive = false; + LoadingRing.Visibility = Visibility.Collapsed; + ImageCropperControl.Visibility = Visibility.Visible; + IsPrimaryButtonEnabled = true; + } + catch (Exception ex) + { + LoadingRing.IsActive = false; + LoadingRing.Visibility = Visibility.Collapsed; + ErrorInfoBar.Title = "Error"; + ErrorInfoBar.Message = $"Failed to load image: {ex.Message}"; + ErrorInfoBar.IsOpen = true; + } + } + + private async void OnPrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + ContentDialogButtonClickDeferral deferral = args.GetDeferral(); + + try + { + StorageFolder cacheFolder = ApplicationData.Current.LocalCacheFolder; + string fileName = Path.GetFileNameWithoutExtension(_imagePath); + string newFileName = $"{fileName}_crop.png"; + + StorageFile outputFile = await cacheFolder.CreateFileAsync(newFileName, CreationCollisionOption.ReplaceExisting); + + using IRandomAccessStream stream = await outputFile.OpenAsync(FileAccessMode.ReadWrite); + await ImageCropperControl.SaveAsync(stream, CommunityToolkit.WinUI.Controls.BitmapFileFormat.Png); + + ResultImagePath = outputFile.Path; + } + catch (Exception ex) + { + ErrorInfoBar.Title = "Error"; + ErrorInfoBar.Message = $"Failed to save cropped image: {ex.Message}"; + ErrorInfoBar.IsOpen = true; + args.Cancel = true; + } + finally + { + deferral.Complete(); + } + } +} diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/MainPage.xaml b/Simple Icon File Maker/Simple Icon File Maker/Views/MainPage.xaml index 33c6403..a7dbf4b 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Views/MainPage.xaml +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/MainPage.xaml @@ -70,7 +70,10 @@ MinWidth="320" MinHeight="220" Margin="24,24,24,0" - Visibility="{x:Bind ViewModel.IsImageSelected, Converter={StaticResource BoolVis}, ConverterParameter=True, Mode=OneWay}"> + Visibility="{x:Bind ViewModel.IsImageSelected, + Converter={StaticResource BoolVis}, + ConverterParameter=True, + Mode=OneWay}"> + Visibility="{x:Bind ViewModel.ShowUpgradeToProButton, + Converter={StaticResource BoolVis}, + Mode=OneWay}"> @@ -188,7 +194,8 @@ Visibility="Collapsed"> @@ -199,7 +206,9 @@ HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12" - Visibility="{x:Bind ViewModel.IsLoading, Converter={StaticResource BoolVis}, Mode=OneWay}"> + Visibility="{x:Bind ViewModel.IsLoading, + Converter={StaticResource BoolVis}, + Mode=OneWay}"> + Value="{x:Bind ViewModel.LoadProgress, + Mode=OneWay}" /> + Visibility="{x:Bind ViewModel.IsImageSelected, + Converter={StaticResource BoolVis}, + Mode=OneWay}"> @@ -231,7 +243,9 @@ HorizontalAlignment="Left" VerticalAlignment="Top" Command="{x:Bind ViewModel.UpgradeToProCommand}" - Visibility="{x:Bind ViewModel.ShowUpgradeToProButton, Converter={StaticResource BoolVis}, Mode=OneWay}"> + Visibility="{x:Bind ViewModel.ShowUpgradeToProButton, + Converter={StaticResource BoolVis}, + Mode=OneWay}"> @@ -254,10 +268,10 @@ Height="32" Command="{x:Bind ViewModel.CheckForProEditColorCommand}" IsEnabled="True" - ToolTipService.ToolTip="Change color of the image"> + ToolTipService.ToolTip="Edit the source image"> - + + @@ -342,7 +364,9 @@ x:Name="SettingsCard" Grid.Row="2" Style="{StaticResource CardStyle}" - Visibility="{x:Bind ViewModel.IsImageSelected, Converter={StaticResource BoolVis}, Mode=OneWay}"> + Visibility="{x:Bind ViewModel.IsImageSelected, + Converter={StaticResource BoolVis}, + Mode=OneWay}"> @@ -370,11 +394,14 @@ FontFamily="{StaticResource SymbolThemeFontFamily}" FontSize="16" ToolTipService.ToolTip="Open Destination Folder..." - Visibility="{x:Bind ViewModel.OpenFolderButtonVisible, Converter={StaticResource BoolVis}, Mode=OneWay}" /> + Visibility="{x:Bind ViewModel.OpenFolderButtonVisible, + Converter={StaticResource BoolVis}, + Mode=OneWay}" /> + Visibility="{x:Bind ViewModel.IsLoading, + Converter={StaticResource BoolVis}, + Mode=OneWay}" /> From 19159253268cd434755d509dcde43ad91522d8de Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 29 Mar 2026 23:03:15 -0500 Subject: [PATCH 13/22] Refactor image init and UI logic in PreviewStack Move source image setup, icon size checks, and UI updates into a using block for firstPassImage. Ensure proper disposal, reduce redundant image loading, and improve code clarity by grouping related operations. --- .../Controls/PreviewStack.xaml.cs | 67 ++++++++++--------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs index ab2a4b9..17de4db 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs @@ -177,27 +177,6 @@ public async Task GeneratePreviewImagesAsync(IProgress progress, stri progress.Report(10); ImagesProgressBar.Value = 10; - SourceImageSize ??= new Size((int)mainImage.Width, (int)mainImage.Height); - - SmallerSourceSide = Math.Min((int)mainImage.Width, (int)mainImage.Height); - - int smallerSide = Math.Min(SourceImageSize.Value.Width, SourceImageSize.Value.Height); - - imagePaths.Clear(); - PreviewStackPanel.Children.Clear(); - - foreach (IconSize iconSize in ChosenSizes) - { - iconSize.IsEnabled = true; - if (iconSize.SideLength > smallerSide) - iconSize.IsEnabled = false; - } - - int enabledCount = ChosenSizes.Count(x => x.IsEnabled); - if (enabledCount == 1) - LoadingText.Text = $"Generating {enabledCount} size for {name}..."; - else - LoadingText.Text = $"Generating {enabledCount} sizes for {name}..."; if (string.IsNullOrWhiteSpace(imagePath) == true) { @@ -205,9 +184,11 @@ public async Task GeneratePreviewImagesAsync(IProgress progress, stri return false; } + int smallerSide = 0; + IMagickImage? firstPassImage; try { - _ = await imgFactory.CreateAsync(imagePath); + firstPassImage = await imgFactory.CreateAsync(imagePath); } catch (Exception) { @@ -215,18 +196,40 @@ public async Task GeneratePreviewImagesAsync(IProgress progress, stri return false; } - progress.Report(15); - ImagesProgressBar.Value = 15; - using IMagickImage firstPassImage = await imgFactory.CreateAsync(imagePath); - IMagickGeometry size = geoFactory.Create( - (uint)Math.Max(SourceImageSize.Value.Width, SourceImageSize.Value.Height)); - size.IgnoreAspectRatio = false; - size.FillArea = true; + using (firstPassImage) + { + SourceImageSize = new Size((int)firstPassImage.Width, (int)firstPassImage.Height); + SmallerSourceSide = Math.Min((int)firstPassImage.Width, (int)firstPassImage.Height); + smallerSide = SmallerSourceSide; + + imagePaths.Clear(); + PreviewStackPanel.Children.Clear(); - MagickColor transparent = new("#00000000"); - firstPassImage.Extent(size, Gravity.Center, transparent); + foreach (IconSize iconSize in ChosenSizes) + { + iconSize.IsEnabled = true; + if (iconSize.SideLength > smallerSide) + iconSize.IsEnabled = false; + } - await firstPassImage.WriteAsync(croppedImagePath, MagickFormat.Png32); + int enabledCount = ChosenSizes.Count(x => x.IsEnabled); + if (enabledCount == 1) + LoadingText.Text = $"Generating {enabledCount} size for {name}..."; + else + LoadingText.Text = $"Generating {enabledCount} sizes for {name}..."; + + progress.Report(15); + ImagesProgressBar.Value = 15; + IMagickGeometry size = geoFactory.Create( + (uint)Math.Max(SourceImageSize.Value.Width, SourceImageSize.Value.Height)); + size.IgnoreAspectRatio = false; + size.FillArea = true; + + MagickColor transparent = new("#00000000"); + firstPassImage.Extent(size, Gravity.Center, transparent); + + await firstPassImage.WriteAsync(croppedImagePath, MagickFormat.Png32); + } MagickImageCollection collection = []; From 7dc540577a6a0e3c63238d4f82cc2dfd1043d354 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 30 Mar 2026 18:30:17 -0500 Subject: [PATCH 14/22] Add SVG image support with vector preview and processing Added full support for SVG (.svg) images across the app. SVGs are now recognized as supported formats, rendered at any size for lossless quality, and previewed using WinUI 3's SvgImageSource for native vector display. Image processing and filters (grayscale, black/white, invert) now rasterize SVGs as needed and output PNGs. All relevant logic in PreviewStack and ImageHelper updated to handle SVGs appropriately. --- .../Constants/FileTypes.cs | 2 +- .../Controls/PreviewStack.xaml.cs | 174 ++++++++++++------ .../Helpers/ImageHelper.cs | 36 +++- .../ViewModels/MainViewModel.cs | 43 ++++- 4 files changed, 187 insertions(+), 68 deletions(-) diff --git a/Simple Icon File Maker/Simple Icon File Maker/Constants/FileTypes.cs b/Simple Icon File Maker/Simple Icon File Maker/Constants/FileTypes.cs index 534b274..5b1d1de 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Constants/FileTypes.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Constants/FileTypes.cs @@ -4,7 +4,7 @@ namespace Simple_Icon_File_Maker.Constants; public static class FileTypes { - public static readonly HashSet SupportedImageFormats = [".png", ".bmp", ".jpeg", ".jpg", ".ico"]; + public static readonly HashSet SupportedImageFormats = [".png", ".bmp", ".jpeg", ".jpg", ".ico", ".svg"]; public static readonly HashSet SupportedDllFormats = [".dll", ".exe", ".mun"]; diff --git a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs index 17de4db..9e18e49 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs @@ -39,7 +39,10 @@ public PreviewStack(string path, List sizes, bool showTitle = false) ChosenSizes = [.. sizes]; imagePath = path; - mainImage = new(path); + bool isSvgSource = Path.GetExtension(path).Equals(".svg", StringComparison.OrdinalIgnoreCase); + mainImage = isSvgSource + ? new(path, new MagickReadSettings { BackgroundColor = MagickColors.Transparent }) + : new(path); InitializeComponent(); @@ -184,33 +187,21 @@ public async Task GeneratePreviewImagesAsync(IProgress progress, stri return false; } + bool isSvg = Path.GetExtension(imagePath).Equals(".svg", StringComparison.OrdinalIgnoreCase); int smallerSide = 0; - IMagickImage? firstPassImage; - try - { - firstPassImage = await imgFactory.CreateAsync(imagePath); - } - catch (Exception) - { - ClearOutputImages(); - return false; - } - using (firstPassImage) + if (isSvg) { - SourceImageSize = new Size((int)firstPassImage.Width, (int)firstPassImage.Height); - SmallerSourceSide = Math.Min((int)firstPassImage.Width, (int)firstPassImage.Height); - smallerSide = SmallerSourceSide; + // SVG is vector — it can render at any size, so enable all sizes + SmallerSourceSide = int.MaxValue; + smallerSide = int.MaxValue; + SourceImageSize = null; imagePaths.Clear(); PreviewStackPanel.Children.Clear(); foreach (IconSize iconSize in ChosenSizes) - { iconSize.IsEnabled = true; - if (iconSize.SideLength > smallerSide) - iconSize.IsEnabled = false; - } int enabledCount = ChosenSizes.Count(x => x.IsEnabled); if (enabledCount == 1) @@ -220,15 +211,54 @@ public async Task GeneratePreviewImagesAsync(IProgress progress, stri progress.Report(15); ImagesProgressBar.Value = 15; - IMagickGeometry size = geoFactory.Create( - (uint)Math.Max(SourceImageSize.Value.Width, SourceImageSize.Value.Height)); - size.IgnoreAspectRatio = false; - size.FillArea = true; + } + else + { + IMagickImage? firstPassImage; + try + { + firstPassImage = await imgFactory.CreateAsync(imagePath); + } + catch (Exception) + { + ClearOutputImages(); + return false; + } - MagickColor transparent = new("#00000000"); - firstPassImage.Extent(size, Gravity.Center, transparent); + using (firstPassImage) + { + SourceImageSize = new Size((int)firstPassImage.Width, (int)firstPassImage.Height); + SmallerSourceSide = Math.Min((int)firstPassImage.Width, (int)firstPassImage.Height); + smallerSide = SmallerSourceSide; + + imagePaths.Clear(); + PreviewStackPanel.Children.Clear(); - await firstPassImage.WriteAsync(croppedImagePath, MagickFormat.Png32); + foreach (IconSize iconSize in ChosenSizes) + { + iconSize.IsEnabled = true; + if (iconSize.SideLength > smallerSide) + iconSize.IsEnabled = false; + } + + int enabledCount = ChosenSizes.Count(x => x.IsEnabled); + if (enabledCount == 1) + LoadingText.Text = $"Generating {enabledCount} size for {name}..."; + else + LoadingText.Text = $"Generating {enabledCount} sizes for {name}..."; + + progress.Report(15); + ImagesProgressBar.Value = 15; + IMagickGeometry size = geoFactory.Create( + (uint)Math.Max(SourceImageSize.Value.Width, SourceImageSize.Value.Height)); + size.IgnoreAspectRatio = false; + size.FillArea = true; + + MagickColor transparent = new("#00000000"); + firstPassImage.Extent(size, Gravity.Center, transparent); + + await firstPassImage.WriteAsync(croppedImagePath, MagickFormat.Png32); + } } MagickImageCollection collection = []; @@ -247,38 +277,76 @@ public async Task GeneratePreviewImagesAsync(IProgress progress, stri foreach (int sideLength in selectedSizes) { - using IMagickImage image = await imgFactory.CreateAsync(croppedImagePath); - if (smallerSide < sideLength) - continue; - - currentLocation++; - progress.Report(baseAtThisPoint + (currentLocation * halfChunkPerImage)); - ImagesProgressBar.Value = baseAtThisPoint + (currentLocation * halfChunkPerImage); - IMagickGeometry iconSize = geoFactory.Create((uint)sideLength, (uint)sideLength); - iconSize.IgnoreAspectRatio = false; - - if (smallerSide > sideLength) + if (isSvg) { - await Task.Run(() => + // Render the SVG fresh at each target size for lossless quality + MagickReadSettings svgSettings = new() { - image.Scale(iconSize); - image.Sharpen(); - }); + BackgroundColor = MagickColors.Transparent, + Width = (uint)sideLength, + Height = (uint)sideLength + }; + using IMagickImage image = await imgFactory.CreateAsync(imagePath, svgSettings); + + // Ensure exact square dimensions with transparent fill + IMagickGeometry squareGeo = geoFactory.Create((uint)sideLength); + MagickColor transparent = new("#00000000"); + image.Extent(squareGeo, Gravity.Center, transparent); + + // Final scale to exact size in case the SVG rendered at different dimensions + IMagickGeometry exactSize = geoFactory.Create((uint)sideLength, (uint)sideLength); + exactSize.IgnoreAspectRatio = true; + await Task.Run(() => image.Scale(exactSize)); + + currentLocation++; + progress.Report(baseAtThisPoint + (currentLocation * halfChunkPerImage)); + ImagesProgressBar.Value = baseAtThisPoint + (currentLocation * halfChunkPerImage); + + string iconPathSvg = $"{iconRootString}\\{Random.Shared.Next()}Image{sideLength}.png"; + if (File.Exists(iconPathSvg)) + File.Delete(iconPathSvg); + + await image.WriteAsync(iconPathSvg, MagickFormat.Png32); + collection.Add(iconPathSvg); + imagePaths.Add((sideLength.ToString(), iconPathSvg)); + + currentLocation++; + progress.Report(baseAtThisPoint + (currentLocation * halfChunkPerImage)); + ImagesProgressBar.Value = baseAtThisPoint + (currentLocation * halfChunkPerImage); } + else + { + using IMagickImage image = await imgFactory.CreateAsync(croppedImagePath); + if (smallerSide < sideLength) + continue; - string iconPath = $"{iconRootString}\\{Random.Shared.Next()}Image{sideLength}.png"; - - if (File.Exists(iconPath)) - File.Delete(iconPath); - - await image.WriteAsync(iconPath, MagickFormat.Png32); - - collection.Add(iconPath); - imagePaths.Add((sideLength.ToString(), iconPath)); + currentLocation++; + progress.Report(baseAtThisPoint + (currentLocation * halfChunkPerImage)); + ImagesProgressBar.Value = baseAtThisPoint + (currentLocation * halfChunkPerImage); + IMagickGeometry iconSize = geoFactory.Create((uint)sideLength, (uint)sideLength); + iconSize.IgnoreAspectRatio = false; - currentLocation++; - progress.Report(baseAtThisPoint + (currentLocation * halfChunkPerImage)); - ImagesProgressBar.Value = baseAtThisPoint + (currentLocation * halfChunkPerImage); + if (smallerSide > sideLength) + { + await Task.Run(() => + { + image.Scale(iconSize); + image.Sharpen(); + }); + } + + string iconPath = $"{iconRootString}\\{Random.Shared.Next()}Image{sideLength}.png"; + if (File.Exists(iconPath)) + File.Delete(iconPath); + + await image.WriteAsync(iconPath, MagickFormat.Png32); + collection.Add(iconPath); + imagePaths.Add((sideLength.ToString(), iconPath)); + + currentLocation++; + progress.Report(baseAtThisPoint + (currentLocation * halfChunkPerImage)); + ImagesProgressBar.Value = baseAtThisPoint + (currentLocation * halfChunkPerImage); + } } try diff --git a/Simple Icon File Maker/Simple Icon File Maker/Helpers/ImageHelper.cs b/Simple Icon File Maker/Simple Icon File Maker/Helpers/ImageHelper.cs index b84e001..6170f50 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Helpers/ImageHelper.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Helpers/ImageHelper.cs @@ -14,8 +14,25 @@ public static class ImageHelper try { MagickImage image; + string extension = Path.GetExtension(imagePath); + + // For .svg files, rasterize at 512px with transparent background for preview + if (extension.Equals(".svg", StringComparison.InvariantCultureIgnoreCase)) + { + MagickReadSettings svgSettings = new() + { + BackgroundColor = MagickColors.Transparent, + Width = 512, + Height = 512 + }; + image = new(imagePath, svgSettings); + MagickGeometry squareGeo = new(512u); + image.Extent(squareGeo, Gravity.Center, new MagickColor("#00000000")); + return image; + } + // For .ico files, load the largest frame instead of the first one - if (Path.GetExtension(imagePath).Equals(".ico", StringComparison.InvariantCultureIgnoreCase)) + if (extension.Equals(".ico", StringComparison.InvariantCultureIgnoreCase)) { MagickImageCollection collection = new(imagePath); // Find the largest frame by area (width * height) @@ -62,6 +79,10 @@ public static int GetSmallerImageSide(string imagePath) { try { + // SVG is vector and can render at any size — treat as effectively unlimited + if (Path.GetExtension(imagePath).Equals(".svg", StringComparison.OrdinalIgnoreCase)) + return int.MaxValue; + MagickImage image = new(imagePath); return (int)Math.Min(image.Width, image.Height); } @@ -76,7 +97,9 @@ public static async Task ApplyGrayscaleAsync(string imagePath, Image? di StorageFolder sf = ApplicationData.Current.LocalCacheFolder; string fileName = Path.GetFileNameWithoutExtension(imagePath); string extension = Path.GetExtension(imagePath); - string grayFilePath = Path.Combine(sf.Path, $"{fileName}_gray{extension}"); + // SVG must be rasterized before filters can be applied — save output as PNG + string outputExtension = extension.Equals(".svg", StringComparison.OrdinalIgnoreCase) ? ".png" : extension; + string grayFilePath = Path.Combine(sf.Path, $"{fileName}_gray{outputExtension}"); MagickImage image = new(imagePath); image.Grayscale(); @@ -93,7 +116,8 @@ public static async Task ApplyBlackWhiteOtsuAsync(string imagePath, Imag StorageFolder sf = ApplicationData.Current.LocalCacheFolder; string fileName = Path.GetFileNameWithoutExtension(imagePath); string extension = Path.GetExtension(imagePath); - string bwFilePath = Path.Combine(sf.Path, $"{fileName}_bw{extension}"); + string outputExtension = extension.Equals(".svg", StringComparison.OrdinalIgnoreCase) ? ".png" : extension; + string bwFilePath = Path.Combine(sf.Path, $"{fileName}_bw{outputExtension}"); MagickImage image = new(imagePath); image.Grayscale(); @@ -111,7 +135,8 @@ public static async Task ApplyBlackWhiteKapurAsync(string imagePath, Ima StorageFolder sf = ApplicationData.Current.LocalCacheFolder; string fileName = Path.GetFileNameWithoutExtension(imagePath); string extension = Path.GetExtension(imagePath); - string bwkFilePath = Path.Combine(sf.Path, $"{fileName}_bwk{extension}"); + string outputExtension = extension.Equals(".svg", StringComparison.OrdinalIgnoreCase) ? ".png" : extension; + string bwkFilePath = Path.Combine(sf.Path, $"{fileName}_bwk{outputExtension}"); MagickImage image = new(imagePath); image.Grayscale(); @@ -129,7 +154,8 @@ public static async Task ApplyInvertAsync(string imagePath, Image? displ StorageFolder sf = ApplicationData.Current.LocalCacheFolder; string fileName = Path.GetFileNameWithoutExtension(imagePath); string extension = Path.GetExtension(imagePath); - string invFilePath = Path.Combine(sf.Path, $"{fileName}_inv{extension}"); + string outputExtension = extension.Equals(".svg", StringComparison.OrdinalIgnoreCase) ? ".png" : extension; + string invFilePath = Path.Combine(sf.Path, $"{fileName}_inv{outputExtension}"); MagickImage image = new(imagePath); image.Negate(Channels.RGB); diff --git a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs index 1a72e9b..87f5a00 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs @@ -4,6 +4,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; using Simple_Icon_File_Maker.Constants; using Simple_Icon_File_Maker.Contracts.Services; using Simple_Icon_File_Maker.Contracts.ViewModels; @@ -15,6 +16,7 @@ using Windows.ApplicationModel.DataTransfer; using Windows.Storage; using Windows.Storage.Pickers; +using Windows.Storage.Streams; using Windows.System; using WinRT.Interop; @@ -990,19 +992,42 @@ private async Task LoadFromImagePathAsync(CancellationToken cancellationToken = { cancellationToken.ThrowIfCancellationRequested(); - MagickImage? image = await ImageHelper.LoadImageAsync(ImagePath); + if (Path.GetExtension(ImagePath).Equals(".svg", StringComparison.OrdinalIgnoreCase)) + { + // Use WinUI 3 native SvgImageSource for lossless vector preview + StorageFile svgFile = await StorageFile.GetFileFromPathAsync(ImagePath); + using IRandomAccessStream stream = await svgFile.OpenReadAsync(); + SvgImageSource svgSource = new(); + SvgImageSourceLoadStatus loadStatus = await svgSource.SetSourceAsync(stream); - cancellationToken.ThrowIfCancellationRequested(); + cancellationToken.ThrowIfCancellationRequested(); - if (image == null) - { - ShowError("Failed to load image"); - IsLoading = false; - IsImageSelected = false; - return; + if (loadStatus != SvgImageSourceLoadStatus.Success) + { + ShowError("Failed to load SVG image"); + IsLoading = false; + IsImageSelected = false; + return; + } + + MainImageSource = svgSource; } + else + { + MagickImage? image = await ImageHelper.LoadImageAsync(ImagePath); + + cancellationToken.ThrowIfCancellationRequested(); + + if (image == null) + { + ShowError("Failed to load image"); + IsLoading = false; + IsImageSelected = false; + return; + } - MainImageSource = image.ToImageSource(); + MainImageSource = image.ToImageSource(); + } } catch (OperationCanceledException) { From 3ba5cf46e529ca09bbb398af56d1ac7ace6f91bb Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 30 Mar 2026 19:43:05 -0500 Subject: [PATCH 15/22] Add FileGroupItem model with observable properties Introduced FileGroupItem class in the Models namespace, inheriting from ObservableObject. Includes observable properties for file extension, total count, large file count, and inclusion flag, plus a computed DisplayLabel for UI display. --- .../Models/FileGroupItem.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 Simple Icon File Maker/Simple Icon File Maker/Models/FileGroupItem.cs diff --git a/Simple Icon File Maker/Simple Icon File Maker/Models/FileGroupItem.cs b/Simple Icon File Maker/Simple Icon File Maker/Models/FileGroupItem.cs new file mode 100644 index 0000000..f308741 --- /dev/null +++ b/Simple Icon File Maker/Simple Icon File Maker/Models/FileGroupItem.cs @@ -0,0 +1,23 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Simple_Icon_File_Maker.Models; + +public partial class FileGroupItem : ObservableObject +{ + [ObservableProperty] + public partial string Extension { get; set; } = string.Empty; + + [ObservableProperty] + public partial int TotalCount { get; set; } + + [ObservableProperty] + public partial int LargeFileCount { get; set; } + + [ObservableProperty] + public partial bool IsIncluded { get; set; } = true; + + // Read-only label for CheckBox Content — built once, never mutates after creation + public string DisplayLabel => + $"{Extension} — {TotalCount} file{(TotalCount == 1 ? "" : "s")}" + + (LargeFileCount > 0 ? $" ({LargeFileCount} over 5 MB)" : string.Empty); +} From b4495a8a34dca9ed8ed6841a864de9e74d5d6b7a Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 30 Mar 2026 19:44:06 -0500 Subject: [PATCH 16/22] Add PreCheckDialog for folder image summary and selection Introduced PreCheckDialog ContentDialog to show a summary of detected image files, allow users to select file groups for processing, and provide a "don't show again" option. User preferences are saved via ILocalSettingsService. --- .../Views/PreCheckDialog.xaml | 46 +++++++++++++++++++ .../Views/PreCheckDialog.xaml.cs | 43 +++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 Simple Icon File Maker/Simple Icon File Maker/Views/PreCheckDialog.xaml create mode 100644 Simple Icon File Maker/Simple Icon File Maker/Views/PreCheckDialog.xaml.cs diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/PreCheckDialog.xaml b/Simple Icon File Maker/Simple Icon File Maker/Views/PreCheckDialog.xaml new file mode 100644 index 0000000..d58e05a --- /dev/null +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/PreCheckDialog.xaml @@ -0,0 +1,46 @@ + + + + + + + + + File groups found: + + + + + + + + + + + + + + + diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/PreCheckDialog.xaml.cs b/Simple Icon File Maker/Simple Icon File Maker/Views/PreCheckDialog.xaml.cs new file mode 100644 index 0000000..2c90f1c --- /dev/null +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/PreCheckDialog.xaml.cs @@ -0,0 +1,43 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Simple_Icon_File_Maker.Contracts.Services; +using Simple_Icon_File_Maker.Models; +using System.Collections.ObjectModel; + +namespace Simple_Icon_File_Maker; + +public sealed partial class PreCheckDialog : ContentDialog +{ + public const string SkipPreCheckSettingKey = "SkipPreCheckDialog"; + + // Set by caller before ShowModal + public int TotalImageCount { get; set; } + public ObservableCollection FileGroups { get; } = []; + + // Read by caller after ShowModal returns + public bool IsConfirmed { get; private set; } + + public PreCheckDialog() + { + InitializeComponent(); + } + + private void ContentDialog_Loaded(object sender, RoutedEventArgs e) + { + SummaryTextBlock.Text = + $"{TotalImageCount} image file{(TotalImageCount == 1 ? "" : "s")} found in this folder."; + } + + private async void ContentDialog_PrimaryButtonClick( + ContentDialog sender, + ContentDialogButtonClickEventArgs args) + { + IsConfirmed = true; + + if (DontShowAgainCheckBox.IsChecked == true) + { + await App.GetService() + .SaveSettingAsync(SkipPreCheckSettingKey, true); + } + } +} From 989453c53c599c66a8cd02aa0f1f14f0633b01d0 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 30 Mar 2026 19:44:52 -0500 Subject: [PATCH 17/22] Add pre-check dialog for bulk image folder loading Introduce a pre-check dialog to review and filter image file types before loading large or numerous files from a folder. Users can exclude extensions (e.g., .ico), see file counts, and optionally skip future pre-checks. Updated ViewModel to manage pre-check state and excluded extensions. UI now shows a cancel button and progress ring during folder assessment. Added PreCheckDialog XAML to the project. --- .../Controls/PreviewStack.xaml.cs | 4 +- .../Simple Icon File Maker.csproj | 4 + .../ViewModels/MultiViewModel.cs | 92 ++++++++++++++++++- .../Views/MultiPage.xaml | 24 +++-- 4 files changed, 112 insertions(+), 12 deletions(-) diff --git a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs index 9e18e49..e1a89be 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewStack.xaml.cs @@ -35,7 +35,7 @@ public sealed partial class PreviewStack : UserControl public PreviewStack(string path, List sizes, bool showTitle = false) { StorageFolder sf = ApplicationData.Current.LocalCacheFolder; - iconRootString = sf.Path; + iconRootString = Path.Combine(sf.Path, Guid.NewGuid().ToString("N")); ChosenSizes = [.. sizes]; imagePath = path; @@ -378,6 +378,8 @@ private async Task OpenIconFile(IProgress progress) imagePaths.Clear(); PreviewStackPanel.Children.Clear(); + Directory.CreateDirectory(iconRootString); + MagickImageCollection collection = new(imagePath); List<(string, string)> iconImages = []; diff --git a/Simple Icon File Maker/Simple Icon File Maker/Simple Icon File Maker.csproj b/Simple Icon File Maker/Simple Icon File Maker/Simple Icon File Maker.csproj index 0df71ca..b54b21a 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Simple Icon File Maker.csproj +++ b/Simple Icon File Maker/Simple Icon File Maker/Simple Icon File Maker.csproj @@ -32,6 +32,7 @@ + @@ -86,6 +87,9 @@ $(DefaultXamlRuntime) + + $(DefaultXamlRuntime) + $(DefaultXamlRuntime) diff --git a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs index 603f76a..74cb314 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs @@ -30,6 +30,12 @@ public partial class MultiViewModel : ObservableRecipient, INavigationAware public SizesControl? SizesControl { get; set; } private bool folderLoadCancelled = false; + private bool _skipPreCheck = false; + private HashSet _excludedExtensions = []; + + private const int PreCheckFileCountThreshold = 100; + private const int PreCheckLargeFileCountThreshold = 5; + private const ulong PreCheckLargeFileSizeBytes = 5 * 1024 * 1024; // 5 MB [ObservableProperty] public partial int FileLoadProgress { get; set; } = 0; @@ -49,6 +55,9 @@ public partial class MultiViewModel : ObservableRecipient, INavigationAware [ObservableProperty] public partial int CurrentImageRendering { get; set; } = 0; + [ObservableProperty] + public partial bool IsAssessingFolder { get; set; } = false; + [ObservableProperty] public partial bool ArePreviewsZoomed { get; set; } = false; @@ -277,6 +286,9 @@ public async void OnNavigatedTo(object parameter) OpenSourceTooltip = "Open folder..."; } + _skipPreCheck = await _localSettingsService + .ReadSettingAsync(PreCheckDialog.SkipPreCheckSettingKey); + LoadIconSizes(); await LoadFiles(); } @@ -335,12 +347,42 @@ private void CheckIfRefreshIsNeeded() SizesControl.ViewModel.SizeDisabledWarningIsOpen = smallestSource < largestSize; } + private static async Task> BuildFileGroupsAsync( + IReadOnlyList files) + { + StorageFile[] imageFiles = [.. files.Where(f => f.IsSupportedImageFormat())]; + Task[] propTasks = + imageFiles.Select(f => f.GetBasicPropertiesAsync().AsTask()).ToArray(); + Windows.Storage.FileProperties.BasicProperties[] allProps = + await Task.WhenAll(propTasks); + + Dictionary groups = []; + for (int i = 0; i < imageFiles.Length; i++) + { + string ext = imageFiles[i].FileType.ToLowerInvariant(); + if (!groups.TryGetValue(ext, out FileGroupItem? group)) + { + group = new FileGroupItem { Extension = ext }; + groups[ext] = group; + } + group.TotalCount++; + if (allProps[i].Size > PreCheckLargeFileSizeBytes) + group.LargeFileCount++; + } + return [.. groups.Values.OrderBy(g => g.Extension)]; + } + + private static bool ShouldShowPreCheck(List groups) + => groups.Sum(g => g.TotalCount) > PreCheckFileCountThreshold + || groups.Sum(g => g.LargeFileCount) > PreCheckLargeFileCountThreshold; + private async Task LoadFiles() { if (_folder is null || SizesControl == null) return; LoadingImages = true; + IsAssessingFolder = true; Previews.Clear(); Progress progress = new(); @@ -350,6 +392,50 @@ private async Task LoadFiles() FileLoadProgress = 0; CurrentImageRendering = 0; + IsAssessingFolder = false; + _excludedExtensions = []; + bool dialogWasShown = false; + + if (!_skipPreCheck) + { + List groups = await BuildFileGroupsAsync(tempFiles); + + if (ShouldShowPreCheck(groups)) + { + // Seed .ico default from the current SkipIcoFiles preference + FileGroupItem? icoGroup = groups.FirstOrDefault(g => + g.Extension.Equals(".ico", StringComparison.OrdinalIgnoreCase)); + if (icoGroup is not null) + icoGroup.IsIncluded = !SkipIcoFiles; + + PreCheckDialog dialog = new() { TotalImageCount = NumberOfImageFiles }; + foreach (FileGroupItem group in groups) + dialog.FileGroups.Add(group); + + _ = await NavigationService.ShowModal(dialog); + dialogWasShown = true; + + if (!dialog.IsConfirmed) + { + LoadingImages = false; + GoBack(); + return; + } + + _excludedExtensions = [.. dialog.FileGroups + .Where(g => !g.IsIncluded) + .Select(g => g.Extension)]; + } + } + + // If the dialog was not shown, honour SkipIcoFiles via the exclusion set + if (!dialogWasShown && SkipIcoFiles) + _excludedExtensions = [.. _excludedExtensions.Append(".ico")]; + + // Recalculate total to match what will actually be processed + NumberOfImageFiles = tempFiles.Count(f => + f.IsSupportedImageFormat() && + !_excludedExtensions.Contains(f.FileType.ToLowerInvariant())); List sizes = [.. SizesControl.ViewModel.IconSizes.Where(x => x.IsSelected && x.IsEnabled && !x.IsHidden)]; @@ -362,11 +448,11 @@ private async Task LoadFiles() if (!file.IsSupportedImageFormat() || folderLoadCancelled) continue; - CurrentImageRendering++; - - if (SkipIcoFiles && file.FileType == ".ico") + if (_excludedExtensions.Contains(file.FileType.ToLowerInvariant())) continue; + CurrentImageRendering++; + PreviewStack preview = new(file.Path, sizes, true) { MaxWidth = 600, diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/MultiPage.xaml b/Simple Icon File Maker/Simple Icon File Maker/Views/MultiPage.xaml index 123a5af..756c433 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Views/MultiPage.xaml +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/MultiPage.xaml @@ -279,19 +279,27 @@ Fill="{StaticResource CardStrokeColorDefaultSolid}" /> - - - - - + Spacing="4"> + + + + + + + From 333d94ba190a16426273ae9c95a24f2d85b2c37b Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 4 Apr 2026 10:26:08 -0500 Subject: [PATCH 18/22] Enable conditional execution for CropImage command Added CanCropImage method to control CropImage command availability based on ImagePath value and extension. Updated ImagePath property to notify command state changes and set CanExecute for the command accordingly. --- .../Simple Icon File Maker/ViewModels/MainViewModel.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs index 87f5a00..d87b068 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MainViewModel.cs @@ -54,6 +54,7 @@ public partial class MainViewModel : ObservableRecipient, INavigationAware, IDis public partial bool IsCountdownActive { get; set; } = false; [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(CropImageCommand))] public partial string ImagePath { get; set; } = ""; [ObservableProperty] @@ -704,7 +705,11 @@ public async Task RemoveBackground() } } - [RelayCommand] + private bool CanCropImage() => + !string.IsNullOrWhiteSpace(ImagePath) && + !Path.GetExtension(ImagePath).Equals(".svg", StringComparison.OrdinalIgnoreCase); + + [RelayCommand(CanExecute = nameof(CanCropImage))] public async Task CropImage() { if (string.IsNullOrWhiteSpace(ImagePath)) From 643cd2d6a5cc8c449b8ef72c5b688bf6529e0b6f Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 4 Apr 2026 11:08:55 -0500 Subject: [PATCH 19/22] Improve checkerboard tiling to avoid partial edge tiles Refactored checkerboard brush generation to use idealTileSize, ensuring tile size is always an exact divisor of the canvas. This prevents partial tiles or slivers at the edges and maintains crisp, consistent tiling regardless of canvas size or DPI. --- .../Controls/PreviewImage.xaml.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs index b38916a..dd4bd05 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/Controls/PreviewImage.xaml.cs @@ -179,11 +179,11 @@ private void LoadImageOnToCanvas() private static ImageBrush CreateCheckerBrush(int size, int baseSideLength, ElementTheme theme) { - // Bitmap is created at the actual canvas size (size × size) so Stretch.Fill renders - // it at exactly 1:1 — no scaling, no interpolation, always crisp edges. - // Tile count per side = baseSideLength / 8, driven purely by the icon size. - // Math.Round replaces the old exact-divisor loop that failed on prime canvas widths. - int tileSize = Math.Max(1, (int)Math.Round(8.0 * size / baseSideLength)); + // idealTileSize targets ~8 px at the baseSideLength and scales proportionally for + // zoomed views. NearestDivisor then rounds it to the closest exact divisor of + // 'size', guaranteeing size % tileSize == 0 — no partial tile, no sliver at the + // edge regardless of the canvas size or DPI scale. + int idealTileSize = Math.Max(1, (int)Math.Round(8.0 * size / baseSideLength)); WriteableBitmap bitmap = new(size, size); @@ -197,10 +197,10 @@ private static ImageBrush CreateCheckerBrush(int size, int baseSideLength, Eleme { for (int col = 0; col < size; col++) { - bool isLightTile = ((row / tileSize) + (col / tileSize)) % 2 == 0; + bool isLightTile = ((row / idealTileSize) + (col / idealTileSize)) % 2 == 0; byte val = isLightTile ? tileLight : tileDark; int idx = (row * size + col) * 4; - pixels[idx] = val; // B + pixels[idx] = val; // B pixels[idx + 1] = val; // G pixels[idx + 2] = val; // R pixels[idx + 3] = 255; // A From 7cd9a36afc69cf24bf71d2a55852396601aa8cb6 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 4 Apr 2026 11:29:13 -0500 Subject: [PATCH 20/22] Add CancelLoading command and refactor file group logic Added CancelLoading command to set folderLoadCancelled flag. Refactored BuildFileGroupsAsync to use array spread syntax for property tasks. Simplified .ico group IsIncluded assignment with null-conditional operator. --- .../ViewModels/MultiViewModel.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs index 74cb314..50c9c5f 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs +++ b/Simple Icon File Maker/Simple Icon File Maker/ViewModels/MultiViewModel.cs @@ -130,6 +130,13 @@ public async Task EditIconSizes() } } + [RelayCommand] + public void CancelLoading() + { + folderLoadCancelled = true; + + } + [RelayCommand] public async Task RegenPreviews() { @@ -352,7 +359,7 @@ private static async Task> BuildFileGroupsAsync( { StorageFile[] imageFiles = [.. files.Where(f => f.IsSupportedImageFormat())]; Task[] propTasks = - imageFiles.Select(f => f.GetBasicPropertiesAsync().AsTask()).ToArray(); + [.. imageFiles.Select(f => f.GetBasicPropertiesAsync().AsTask())]; Windows.Storage.FileProperties.BasicProperties[] allProps = await Task.WhenAll(propTasks); @@ -405,8 +412,7 @@ private async Task LoadFiles() // Seed .ico default from the current SkipIcoFiles preference FileGroupItem? icoGroup = groups.FirstOrDefault(g => g.Extension.Equals(".ico", StringComparison.OrdinalIgnoreCase)); - if (icoGroup is not null) - icoGroup.IsIncluded = !SkipIcoFiles; + icoGroup?.IsIncluded = !SkipIcoFiles; PreCheckDialog dialog = new() { TotalImageCount = NumberOfImageFiles }; foreach (FileGroupItem group in groups) From ed699a7138f948f0251748e2f8d977eb23499eaf Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 4 Apr 2026 11:29:28 -0500 Subject: [PATCH 21/22] Reformat XAML bindings and update Cancel button command Reformat XAML bindings for readability by splitting properties onto new lines. Remove PlaceholderValue from RatingControl in AboutPage.xaml. Change Cancel button command from GoBackCommand to CancelLoadingCommand in MultiPage.xaml. No functional changes except for the updated command. --- .../Views/AboutPage.xaml | 5 +-- .../Views/MultiPage.xaml | 33 +++++++++++++------ 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml b/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml index 10d8d6a..2291193 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/AboutPage.xaml @@ -17,10 +17,7 @@ - + diff --git a/Simple Icon File Maker/Simple Icon File Maker/Views/MultiPage.xaml b/Simple Icon File Maker/Simple Icon File Maker/Views/MultiPage.xaml index 756c433..67133ad 100644 --- a/Simple Icon File Maker/Simple Icon File Maker/Views/MultiPage.xaml +++ b/Simple Icon File Maker/Simple Icon File Maker/Views/MultiPage.xaml @@ -63,11 +63,14 @@ + Visibility="{x:Bind ViewModel.IsFromDllExtraction, + Converter={StaticResource BoolVis}, + Mode=OneWay}" /> + ToolTipService.ToolTip="{x:Bind ViewModel.OpenSourceTooltip, + Mode=OneWay}">