Przeglądaj źródła

Resizing and file conversion updates

Ruben 1 rok temu
rodzic
commit
7ca92f35db

+ 13 - 10
src/PicView.Avalonia/FileSystem/FilePicker.cs

@@ -163,6 +163,18 @@ public static class FilePicker
     };
 
     public static async Task PickAndSaveFileAsAsync(string? fileName, MainViewModel vm)
+    {
+        var file = await PickFileForSavingAsync(fileName, vm);
+        if (file is null)
+        {
+            return;
+        }
+
+        var destination = file.Path.LocalPath; // TODO: Handle macOS
+        await FileSaverHelper.SaveFileAsync(fileName, destination, vm);
+    }
+    
+    public static async Task<IStorageFile?> PickFileForSavingAsync(string? fileName, MainViewModel vm)
     {
         if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop ||
             desktop.MainWindow?.StorageProvider is not { } provider)
@@ -188,15 +200,6 @@ public static class FilePicker
             SuggestedStartLocation = await desktop.MainWindow.StorageProvider.TryGetFolderFromPathAsync(fileName)
             
         };
-        var file = await provider.SaveFilePickerAsync(options);
-
-        if (file is null)
-        {
-            // User exited
-            return;
-        }
-
-        var destination = file.Path.LocalPath; // TODO: Handle macOS
-        await FileSaverHelper.SaveFileAsync(fileName, destination, vm);
+        return await provider.SaveFilePickerAsync(options);
     }
 }

+ 72 - 0
src/PicView.Avalonia/Input/InputHelper.cs

@@ -0,0 +1,72 @@
+using Avalonia.Input;
+
+namespace PicView.Avalonia.Input;
+
+public static class InputHelper
+{
+    public static async Task OnKeyDownVerifyInput(KeyEventArgs e, Func<bool> focus, Task saveImage)
+    {
+        switch (e.Key)
+        {
+            case Key.D0:
+            case Key.D1:
+            case Key.D2:
+            case Key.D3:
+            case Key.D4:
+            case Key.D5:
+            case Key.D6:
+            case Key.D7:
+            case Key.D8:
+            case Key.D9:
+            case Key.NumPad0:
+            case Key.NumPad1:
+            case Key.NumPad2:
+            case Key.NumPad3:
+            case Key.NumPad4:
+            case Key.NumPad5:
+            case Key.NumPad6:
+            case Key.NumPad7:
+            case Key.NumPad8:
+            case Key.NumPad9:
+            case Key.Back:
+            case Key.Delete:
+                break; // Allow numbers and basic operations
+
+            case Key.Left:
+            case Key.Right:
+            case Key.Tab:
+            case Key.OemBackTab:
+                break; // Allow navigation keys
+
+            case Key.A:
+            case Key.C:
+            case Key.X:
+            case Key.V:
+                if (e.KeyModifiers == KeyModifiers.Control)
+                {
+                    // Allow Ctrl + A, Ctrl + C, Ctrl + X, and Ctrl + V (paste)
+                    break;
+                }
+
+                e.Handled = true; // Only allow with Ctrl
+                return;
+
+            case Key.Oem5: // Key for `%` symbol (may vary based on layout)
+                break; // Allow the percentage symbol (%)
+
+            case Key.Escape: // Handle Escape key
+                focus();
+                e.Handled = true;
+                return;
+
+            case Key.Enter: // Handle Enter key for function
+
+                await saveImage.ConfigureAwait(false);
+                return;
+
+            default:
+                e.Handled = true; // Block all other inputs
+                return;
+        }
+    }
+}

+ 1 - 1
src/PicView.Avalonia/Navigation/ImageIterator.cs

@@ -578,7 +578,7 @@ public sealed class ImageIterator : IDisposable
                         .ConfigureAwait(false);
                 }
 
-                await AddAsync(index, preloadValue.ImageModel).ConfigureAwait(false);
+                await AddAsync(index, preloadValue?.ImageModel).ConfigureAwait(false);
 
                 // Add recent files, except when browsing archive
                 if (string.IsNullOrWhiteSpace(TempFileHelper.TempFilePath) && ImagePaths.Count > index)

+ 0 - 1
src/PicView.Avalonia/PicView.Avalonia.csproj

@@ -149,7 +149,6 @@
 
   <ItemGroup>
     <Folder Include="Assets\" />
-    <Folder Include="Resizing\" />
   </ItemGroup>
 
   <ItemGroup>

+ 67 - 0
src/PicView.Avalonia/Resizing/AspectRatioHelper.cs

@@ -0,0 +1,67 @@
+using System.Globalization;
+using Avalonia.Controls;
+using PicView.Avalonia.ViewModels;
+using PicView.Core.Extensions;
+
+namespace PicView.Avalonia.Resizing;
+
+public static class AspectRatioHelper
+{
+    public static void SetAspectRatioForTextBox(TextBox widthTextBox, TextBox heightTextBox, bool isWidth, double aspectRatio, MainViewModel vm)
+    {
+        var percentage = isWidth ? widthTextBox.Text.GetPercentage() : heightTextBox.Text.GetPercentage();
+        if (percentage > 0)
+        {
+            var newWidth = vm.PixelWidth * (percentage / 100);
+            var newHeight = vm.PixelHeight * (percentage / 100);
+
+            widthTextBox.Text = newWidth.ToString("# ", CultureInfo.CurrentCulture);
+            heightTextBox.Text = newHeight.ToString("# ", CultureInfo.CurrentCulture);
+            
+            if (isWidth)
+            {
+                heightTextBox.Text = newHeight.ToString(CultureInfo.CurrentCulture);
+            }
+            else
+            {
+                widthTextBox.Text = newWidth.ToString(CultureInfo.CurrentCulture);
+            }
+        }
+        else
+        {
+            if (!uint.TryParse(widthTextBox.Text, out var width) || !uint.TryParse(heightTextBox.Text, out var height))
+            {
+                // Invalid input, delete last character
+                try
+                {
+                    if (isWidth && widthTextBox.Text.Length > 1)
+                    {
+                        widthTextBox.Text = widthTextBox.Text[..^1];
+                    }
+                    else if (heightTextBox.Text.Length > 1)
+                    {
+                        heightTextBox.Text = heightTextBox.Text[..^1];
+                    }
+                    
+                }
+                catch (Exception e)
+                {
+#if DEBUG
+                    Console.WriteLine(e);
+#endif
+                }
+                return;
+            }
+            if (isWidth)
+            {
+                var newHeight = Math.Round(width / aspectRatio);
+                heightTextBox.Text = newHeight.ToString(CultureInfo.CurrentCulture);
+            }
+            else
+            {
+                var newWidth = Math.Round(height * aspectRatio);
+                widthTextBox.Text = newWidth.ToString(CultureInfo.CurrentCulture);
+            }
+        }
+    }
+}

+ 111 - 54
src/PicView.Avalonia/Views/ExifView.axaml.cs

@@ -1,6 +1,7 @@
 using Avalonia.Controls;
 using Avalonia.Input;
 using PicView.Avalonia.Converters;
+using PicView.Avalonia.Resizing;
 using PicView.Avalonia.ViewModels;
 
 namespace PicView.Avalonia.Views;
@@ -10,77 +11,133 @@ public partial class ExifView : UserControl
     public ExifView()
     {
         InitializeComponent();
-        PixelHeightTextBox.KeyDown += async (s, e) => await PixelHeightTextBox_OnKeyDown(s, e);
-        PixelWidthTextBox.KeyDown += async (s, e) => await PixelWidthTextBox_OnKeyDown(s, e);
-    }
+        Loaded += (_, _) =>
+        {
+            PixelWidthTextBox.KeyDown += async (s, e) => await OnKeyDownVerifyInput(s,e);
+            PixelHeightTextBox.KeyDown += async (s, e) => await OnKeyDownVerifyInput(s,e);
 
-    private async Task PixelHeightTextBox_OnKeyDown(object? sender, KeyEventArgs e)
+            PixelWidthTextBox.KeyUp += delegate { AdjustAspectRatio(PixelWidthTextBox); };
+            PixelHeightTextBox.KeyUp += delegate { AdjustAspectRatio(PixelHeightTextBox); };
+            
+        };
+    }
+    
+    private void AdjustAspectRatio(TextBox sender)
     {
-        if (DataContext is null)
-        {
-            return;
-        }
-        var vm = (MainViewModel)DataContext;
-        var textBox = (TextBox)sender!;
-        if (textBox is null)
-        {
-            return;
-        }
-        var text = ((TextBox)sender!).Text;
-        if (e.Key != Key.Enter)
+        if (DataContext is not MainViewModel vm)
         {
             return;
         }
+        var aspectRatio = (double)vm.PixelWidth / vm.PixelHeight;
+        AspectRatioHelper.SetAspectRatioForTextBox(PixelWidthTextBox, PixelHeightTextBox, sender == PixelWidthTextBox,
+            aspectRatio, DataContext as MainViewModel);
+    }
 
-        if (!double.TryParse(text, out var height))
+    private static async Task DoResize(MainViewModel vm, bool isWidth, object width, object height)
+    {
+        if (isWidth)
         {
-            return;
+            if (!double.TryParse((string?)width, out var widthValue))
+            {
+                return;
+            }
+            if (widthValue > 0)
+            {
+                var success = await ConversionHelper.ResizeByWidth(vm.FileInfo, widthValue).ConfigureAwait(false);
+                if (success)
+                {
+                    if (vm.ImageIterator is not null)
+                    {
+                        await vm.ImageIterator.QuickReload().ConfigureAwait(false);
+                    }
+                }
+            }
         }
-
-        if (height > 0)
+        else
         {
-            var success = await ConversionHelper.ResizeByHeight(vm.FileInfo, height).ConfigureAwait(false);
-            if (success)
+            if (!double.TryParse((string?)height, out var heightValue))
             {
-                vm.ImageIterator?.RemoveCurrentItemFromPreLoader();
-                await vm.ImageIterator?.IterateToIndex(vm.ImageIterator.CurrentIndex);
+                return;
+            }
+            if (heightValue > 0)
+            {
+                var success = await ConversionHelper.ResizeByHeight(vm.FileInfo, heightValue).ConfigureAwait(false);
+                if (success)
+                {
+                    vm.ImageIterator?.RemoveCurrentItemFromPreLoader();
+                    await vm.ImageIterator?.IterateToIndex(vm.ImageIterator.CurrentIndex);
+                }
             }
         }
     }
-
-    private async Task PixelWidthTextBox_OnKeyDown(object? sender, KeyEventArgs e)
+    
+    private async Task OnKeyDownVerifyInput(object? sender, KeyEventArgs? e)
     {
-        if (DataContext is null)
-        {
-            return;
-        }
-        var vm = (MainViewModel)DataContext;
-        var textBox = (TextBox)sender!;
-        if (textBox is null)
+        switch (e.Key)
         {
-            return;
-        }
-        var text = ((TextBox)sender!).Text;
-        if (e.Key != Key.Enter)
-        {
-            return;
-        }
-
-        if (!double.TryParse(text, out var width))
-        {
-            return;
-        }
-
-        if (width > 0)
-        {
-            var success = await ConversionHelper.ResizeByWidth(vm.FileInfo, width).ConfigureAwait(false);
-            if (success)
-            {
-                if (vm.ImageIterator is not null)
+            case Key.D0:
+            case Key.D1:
+            case Key.D2:
+            case Key.D3:
+            case Key.D4:
+            case Key.D5:
+            case Key.D6:
+            case Key.D7:
+            case Key.D8:
+            case Key.D9:
+            case Key.NumPad0:
+            case Key.NumPad1:
+            case Key.NumPad2:
+            case Key.NumPad3:
+            case Key.NumPad4:
+            case Key.NumPad5:
+            case Key.NumPad6:
+            case Key.NumPad7:
+            case Key.NumPad8:
+            case Key.NumPad9:
+            case Key.Back:
+            case Key.Delete:
+                break; // Allow numbers and basic operations
+    
+            case Key.Left:
+            case Key.Right:
+            case Key.Tab:
+            case Key.OemBackTab:
+                break; // Allow navigation keys
+    
+            case Key.A:
+            case Key.C:
+            case Key.X:
+            case Key.V:
+                if (e.KeyModifiers == KeyModifiers.Control)
                 {
-                    await vm.ImageIterator.QuickReload().ConfigureAwait(false);
+                    // Allow Ctrl + A, Ctrl + C, Ctrl + X, and Ctrl + V (paste)
+                    break;
                 }
-            }
+    
+                e.Handled = true; // Only allow with Ctrl
+                return;
+    
+            case Key.Oem5: // Key for `%` symbol (may vary based on layout)
+                break; // Allow the percentage symbol (%)
+    
+            case Key.Escape: // Handle Escape key
+                Focus();
+                e.Handled = true;
+                return;
+    
+            case Key.Enter: // Handle Enter key for saving
+                if (DataContext is not MainViewModel vm)
+                {
+                    return;
+                }
+
+                await DoResize(vm, Equals(sender, PixelWidthTextBox), PixelWidthTextBox.Text, PixelHeightTextBox.Text).ConfigureAwait(false);
+                return;
+    
+            default:
+                e.Handled = true; // Block all other inputs
+                return;
         }
     }
 }

+ 185 - 179
src/PicView.Avalonia/Views/SingleImageResizeView.axaml

@@ -8,199 +8,205 @@
     xmlns:customControls="clr-namespace:PicView.Avalonia.CustomControls"
     xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
     xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+    xmlns:uc="clr-namespace:PicView.Avalonia.Views.UC"
     xmlns:viewModels="clr-namespace:PicView.Avalonia.ViewModels;assembly=PicView.Avalonia"
     xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
-    <StackPanel>
-        <StackPanel Margin="30">
+    <Panel>
 
-            <StackPanel Orientation="Horizontal">
-                <RadioButton
-                    HorizontalAlignment="Center"
-                    IsChecked="True"
-                    Width="255"
-                    x:Name="PixelsTabItem">
-                    <TextBlock
-                        Classes="txt"
-                        FontFamily="/Assets/Fonts/Roboto-Medium.ttf#Roboto"
-                        FontSize="14"
-                        Text="{CompiledBinding Pixels,
-                                               Mode=OneWay}" />
-                </RadioButton>
-                <RadioButton
-                    HorizontalAlignment="Center"
-                    IsEnabled="False"
-                    x:Name="PercentageTabItem">
-                    <TextBlock
-                        Classes="txt"
-                        FontFamily="/Assets/Fonts/Roboto-Medium.ttf#Roboto"
-                        FontSize="14"
-                        Text="{CompiledBinding Percentage,
-                                               Mode=OneWay}" />
-                </RadioButton>
-            </StackPanel>
+        <uc:SpinWaiter IsVisible="False" x:Name="SpinWaiter" />
 
-            <StackPanel
-                IsVisible="{Binding Path=IsChecked, ElementName=PixelsTabItem}"
-                Margin="0,25,0,30"
-                Orientation="Horizontal">
-                <StackPanel>
-                    <TextBlock
-                        Classes="txt"
-                        FontFamily="/Assets/Fonts/Roboto-Medium.ttf#Roboto"
-                        FontSize="14"
-                        Margin="4,0,0,5"
-                        Text="{CompiledBinding Width,
-                                               Mode=OneWay}" />
-                    <customControls:FuncTextBox
-                        Background="{DynamicResource SecondaryBackgroundColor}"
-                        Classes="hover TStyle"
-                        CornerRadius="4"
-                        Foreground="{StaticResource SecondaryTextColor}"
-                        Height="35"
-                        Margin="0"
-                        Text="{CompiledBinding PixelWidth,
-                                               Mode=OneWay}"
-                        ToolTip.Tip="{CompiledBinding SizeTooltip,
-                                                      Mode=OneWay}"
-                        Width="195"
-                        x:Name="PixelWidthTextBox" />
+        <StackPanel x:Name="ParentContainer">
+            <StackPanel Margin="30">
+
+                <StackPanel Orientation="Horizontal">
+                    <RadioButton
+                        HorizontalAlignment="Center"
+                        IsChecked="True"
+                        Width="255"
+                        x:Name="PixelsTabItem">
+                        <TextBlock
+                            Classes="txt"
+                            FontFamily="/Assets/Fonts/Roboto-Medium.ttf#Roboto"
+                            FontSize="14"
+                            Text="{CompiledBinding Pixels,
+                                                   Mode=OneWay}" />
+                    </RadioButton>
+                    <RadioButton
+                        HorizontalAlignment="Center"
+                        IsEnabled="False"
+                        x:Name="PercentageTabItem">
+                        <TextBlock
+                            Classes="txt"
+                            FontFamily="/Assets/Fonts/Roboto-Medium.ttf#Roboto"
+                            FontSize="14"
+                            Text="{CompiledBinding Percentage,
+                                                   Mode=OneWay}" />
+                    </RadioButton>
                 </StackPanel>
 
-                <customControls:IconButton
-                    Foreground="{StaticResource SecondaryTextColor}"
-                    Height="16"
-                    Icon="{StaticResource LinkChainImage}"
-                    IconHeight="16"
-                    IconWidth="16"
-                    IsEnabled="False"
-                    Margin="20,20,20,0"
-                    Width="16" />
+                <StackPanel
+                    IsVisible="{Binding Path=IsChecked, ElementName=PixelsTabItem}"
+                    Margin="0,25,0,30"
+                    Orientation="Horizontal">
+                    <StackPanel>
+                        <TextBlock
+                            Classes="txt"
+                            FontFamily="/Assets/Fonts/Roboto-Medium.ttf#Roboto"
+                            FontSize="14"
+                            Margin="4,0,0,5"
+                            Text="{CompiledBinding Width,
+                                                   Mode=OneWay}" />
+                        <customControls:FuncTextBox
+                            Background="{DynamicResource SecondaryBackgroundColor}"
+                            Classes="hover TStyle"
+                            CornerRadius="4"
+                            Foreground="{StaticResource SecondaryTextColor}"
+                            Height="35"
+                            Margin="0"
+                            Text="{CompiledBinding PixelWidth,
+                                                   Mode=OneWay}"
+                            ToolTip.Tip="{CompiledBinding SizeTooltip,
+                                                          Mode=OneWay}"
+                            Width="195"
+                            x:Name="PixelWidthTextBox" />
+                    </StackPanel>
 
-                <StackPanel Margin="5,0,0,0">
-                    <TextBlock
-                        Classes="txt"
-                        FontFamily="/Assets/Fonts/Roboto-Medium.ttf#Roboto"
-                        FontSize="14"
-                        Margin="4,0,0,5"
-                        Text="{CompiledBinding Height,
-                                               Mode=OneWay}" />
-                    <customControls:FuncTextBox
-                        Background="{DynamicResource SecondaryBackgroundColor}"
-                        Classes="hover TStyle"
-                        CornerRadius="4"
+                    <customControls:IconButton
                         Foreground="{StaticResource SecondaryTextColor}"
-                        Height="35"
-                        Margin="0"
-                        Text="{CompiledBinding PixelHeight,
-                                               Mode=OneWay}"
-                        ToolTip.Tip="{CompiledBinding SizeTooltip,
-                                                      Mode=OneWay}"
-                        Width="195"
-                        x:Name="PixelHeightTextBox" />
+                        Height="21"
+                        Icon="{StaticResource LinkChainImage}"
+                        IconHeight="21"
+                        IconWidth="21"
+                        IsEnabled="False"
+                        Margin="20,20,20,0"
+                        Width="21" />
+
+
+                    <StackPanel>
+                        <TextBlock
+                            Classes="txt"
+                            FontFamily="/Assets/Fonts/Roboto-Medium.ttf#Roboto"
+                            FontSize="14"
+                            Margin="4,0,0,5"
+                            Text="{CompiledBinding Height,
+                                                   Mode=OneWay}" />
+                        <customControls:FuncTextBox
+                            Background="{DynamicResource SecondaryBackgroundColor}"
+                            Classes="hover TStyle"
+                            CornerRadius="4"
+                            Foreground="{StaticResource SecondaryTextColor}"
+                            Height="35"
+                            Margin="0"
+                            Text="{CompiledBinding PixelHeight,
+                                                   Mode=OneWay}"
+                            ToolTip.Tip="{CompiledBinding SizeTooltip,
+                                                          Mode=OneWay}"
+                            Width="195"
+                            x:Name="PixelHeightTextBox" />
+                    </StackPanel>
                 </StackPanel>
-            </StackPanel>
 
-            <StackPanel Orientation="Horizontal">
-                <StackPanel>
-                    <TextBlock
-                        Classes="txt"
-                        FontFamily="/Assets/Fonts/Roboto-Medium.ttf#Roboto"
-                        FontSize="14"
-                        Margin="5,0,0,2"
-                        Text="{CompiledBinding Quality,
-                                               Mode=OneWay}" />
-                    <customControls:CustomSlider
-                        IsSnapToTickEnabled="True"
-                        Margin="5,0,0,2"
-                        Maximum="100"
-                        Minimum="10"
-                        TickFrequency="1"
-                        Value="90"
-                        Width="190"
-                        x:Name="QualitySlider" />
-                    <TextBlock
-                        Classes="txt"
-                        Margin="8,0,0,3"
-                        Text="{Binding Path=Value, ElementName=QualitySlider}" />
+                <StackPanel Orientation="Horizontal">
+                    <StackPanel>
+                        <TextBlock
+                            Classes="txt"
+                            FontFamily="/Assets/Fonts/Roboto-Medium.ttf#Roboto"
+                            FontSize="14"
+                            Margin="5,0,0,2"
+                            Text="{CompiledBinding Quality,
+                                                   Mode=OneWay}" />
+                        <customControls:CustomSlider
+                            IsSnapToTickEnabled="True"
+                            Margin="5,0,0,2"
+                            Maximum="100"
+                            Minimum="10"
+                            TickFrequency="1"
+                            Value="90"
+                            Width="190"
+                            x:Name="QualitySlider" />
+                        <TextBlock
+                            Classes="txt"
+                            Margin="8,0,0,3"
+                            Text="{Binding Path=Value, ElementName=QualitySlider}" />
+                    </StackPanel>
+                    <StackPanel Margin="60,0,0,0">
+                        <TextBlock
+                            Classes="txt"
+                            FontFamily="/Assets/Fonts/Roboto-Medium.ttf#Roboto"
+                            FontSize="14"
+                            Margin="5,0,0,5"
+                            Text="{CompiledBinding ConvertTo,
+                                                   Mode=OneWay}" />
+                        <ComboBox
+                            Background="{DynamicResource SecondaryBackgroundColor}"
+                            BorderBrush="{DynamicResource MainBorderColor}"
+                            BorderThickness="1"
+                            CornerRadius="4"
+                            FontFamily="/Assets/Fonts/Roboto-Medium.ttf#Roboto"
+                            Height="30"
+                            HorizontalAlignment="Right"
+                            Margin="2,0,0,0"
+                            Padding="5,7,0,7"
+                            SelectedIndex="0"
+                            Width="195"
+                            x:Name="ConversionComboBox">
+                            <ComboBoxItem Content="{CompiledBinding NoConversion, Mode=OneWay}" x:Name="NoConversion" />
+                            <ComboBoxItem Content=".png" x:Name="PngItem" />
+                            <ComboBoxItem Content=".jpg" x:Name="JpgItem" />
+                            <ComboBoxItem Content=".webp" x:Name="WebpItem" />
+                            <ComboBoxItem Content=".avif" x:Name="AvifItem" />
+                            <ComboBoxItem Content=".heic" x:Name="HeicItem" />
+                            <ComboBoxItem Content=".jxl" x:Name="JxlItem" />
+                        </ComboBox>
+                    </StackPanel>
                 </StackPanel>
-                <StackPanel Margin="60,0,0,0">
+
+            </StackPanel>
+            <StackPanel
+                Background="#277A7A7A"
+                Height="80"
+                Orientation="Horizontal">
+                <Button
+                    Classes="BorderStyle altHover mainBtn"
+                    CornerRadius="4"
+                    Margin="30,0,60,0"
+                    Width="195">
                     <TextBlock
                         Classes="txt"
-                        FontFamily="/Assets/Fonts/Roboto-Medium.ttf#Roboto"
-                        FontSize="14"
-                        Margin="5,0,0,5"
-                        Text="{CompiledBinding ConvertTo,
-                                               Mode=OneWay}" />
-                    <ComboBox
-                        Background="{DynamicResource SecondaryBackgroundColor}"
-                        BorderBrush="{DynamicResource MainBorderColor}"
-                        BorderThickness="1"
-                        CornerRadius="4"
-                        FontFamily="/Assets/Fonts/Roboto-Medium.ttf#Roboto"
-                        Height="30"
-                        HorizontalAlignment="Right"
-                        Margin="2,0,0,0"
-                        Padding="5,7,0,7"
-                        SelectedIndex="0"
-                        Width="195"
-                        x:Name="ConversionComboBox">
-                        <ComboBoxItem Content="{CompiledBinding NoConversion, Mode=OneWay}" x:Name="NoConversion" />
-                        <ComboBoxItem Content=".png" x:Name="PngItem" />
-                        <ComboBoxItem Content=".jpg" x:Name="JpgItem" />
-                        <ComboBoxItem Content=".webp" x:Name="WebpItem" />
-                        <ComboBoxItem Content=".avif" x:Name="AvifItem" />
-                        <ComboBoxItem Content=".heic" x:Name="HeicItem" />
-                        <ComboBoxItem Content=".jxl" x:Name="JxlItem" />
-                    </ComboBox>
-                </StackPanel>
-            </StackPanel>
-
-        </StackPanel>
-        <StackPanel
-            Background="#277A7A7A"
-            Height="80"
-            Orientation="Horizontal">
-            <Button
-                Classes="BorderStyle altHover mainBtn"
-                CornerRadius="4"
-                Margin="30,0,60,0"
-                Width="195">
-                <TextBlock
-                    Classes="txt"
-                    Text="{CompiledBinding ResetButtonText,
-                                           Mode=OneWay}"
-                    TextAlignment="Center" />
-            </Button>
+                        Text="{CompiledBinding ResetButtonText,
+                                               Mode=OneWay}"
+                        TextAlignment="Center" />
+                </Button>
 
-            <SplitButton
-                Background="{DynamicResource AccentColor}"
-                Classes="BorderStyle accentHover mainBtn"
-                CornerRadius="4"
-                Width="195"
-                x:Name="SaveButton">
-                <TextBlock
-                    Classes="txt"
-                    Text="{CompiledBinding Save,
-                                           Mode=OneWay}"
-                    TextAlignment="Center" />
-                <SplitButton.Flyout>
-                    <MenuFlyout FlyoutPresenterClasses="noCornerRadius" Placement="Bottom">
-                        <Button
-                            Background="Transparent"
-                            IsEnabled="False"
-                            Width="130"
-                            x:Name="SaveAsButton">
-                            <TextBlock
-                                Classes="txt"
-                                Foreground="{DynamicResource MainTextColor}"
-                                Text="{CompiledBinding SaveAs,
-                                                       Mode=OneWay}"
-                                TextAlignment="Center" />
-                        </Button>
-                    </MenuFlyout>
-                </SplitButton.Flyout>
+                <SplitButton
+                    Background="{DynamicResource AccentColor}"
+                    Classes="BorderStyle accentHover mainBtn"
+                    CornerRadius="4"
+                    Width="195"
+                    x:Name="SaveButton">
+                    <TextBlock
+                        Classes="txt"
+                        Text="{CompiledBinding Save,
+                                               Mode=OneWay}"
+                        TextAlignment="Center" />
+                    <SplitButton.Flyout>
+                        <MenuFlyout FlyoutPresenterClasses="noCornerRadius" Placement="Bottom">
+                            <Button
+                                Background="Transparent"
+                                Width="130"
+                                x:Name="SaveAsButton">
+                                <TextBlock
+                                    Classes="txt"
+                                    Foreground="{DynamicResource MainTextColor}"
+                                    Text="{CompiledBinding SaveAs,
+                                                           Mode=OneWay}"
+                                    TextAlignment="Center" />
+                            </Button>
+                        </MenuFlyout>
+                    </SplitButton.Flyout>
 
-            </SplitButton>
+                </SplitButton>
+            </StackPanel>
         </StackPanel>
-    </StackPanel>
+    </Panel>
 </UserControl>

+ 211 - 35
src/PicView.Avalonia/Views/SingleImageResizeView.axaml.cs

@@ -1,106 +1,277 @@
-using Avalonia.Controls;
+using System.Reactive.Linq;
+using System.Runtime.InteropServices;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Input;
+using Avalonia.Platform.Storage;
+using Avalonia.Threading;
 using PicView.Avalonia.Navigation;
+using PicView.Avalonia.Resizing;
+using PicView.Avalonia.UI;
 using PicView.Avalonia.ViewModels;
 using PicView.Core.ImageDecoding;
+using PicView.Core.Localization;
+using ReactiveUI;
 
 namespace PicView.Avalonia.Views;
 
 public partial class SingleImageResizeView : UserControl
 {
+    private double _aspectRatio;
+
+    private IDisposable? _imageUpdateSubscription;
+
+    // TODO: allow users to be able to disable aspect ratio locking if they want to stretch the image
+    private readonly bool _isKeepingAspectRatio = true;
+
     public SingleImageResizeView()
     {
         InitializeComponent();
         Loaded += delegate
         {
             if (DataContext is not MainViewModel vm)
+            {
                 return;
-            
+            }
+
+            _aspectRatio = (double)vm.PixelWidth / vm.PixelHeight;
+
+            SetIsQualitySliderEnabled();
             SaveButton.Click += async (_, _) => await SaveImage(vm).ConfigureAwait(false);
             SaveAsButton.Click += async (_, _) => await SaveImageAs(vm).ConfigureAwait(false);
+
+            PixelWidthTextBox.KeyDown += async (_, e) => await OnKeyDownVerifyInput(e);
+            PixelHeightTextBox.KeyDown += async (_, e) => await OnKeyDownVerifyInput(e);
+
+            PixelWidthTextBox.KeyUp += delegate { AdjustAspectRatio(PixelWidthTextBox); };
+            PixelHeightTextBox.KeyUp += delegate { AdjustAspectRatio(PixelHeightTextBox); };
+
+            ConversionComboBox.SelectionChanged += delegate { SetIsQualitySliderEnabled(); };
+
+            _imageUpdateSubscription = vm.WhenAnyValue(x => x.FileInfo).Select(x => x is not null).Subscribe(_ =>
+            {
+                Dispatcher.UIThread.Post(SetIsQualitySliderEnabled);
+            });
         };
     }
 
-    private async Task SaveImageAs(MainViewModel vm)
+    private void AdjustAspectRatio(TextBox sender)
     {
-        throw new NotImplementedException();
+        if (!_isKeepingAspectRatio)
+        {
+            return;
+        }
+
+        AspectRatioHelper.SetAspectRatioForTextBox(PixelWidthTextBox, PixelHeightTextBox, sender == PixelWidthTextBox,
+            _aspectRatio, DataContext as MainViewModel);
     }
 
-    private async Task SaveImage(MainViewModel vm)
+    private void SetIsQualitySliderEnabled()
     {
-        await DoSaveImage(vm, vm.FileInfo.FullName).ConfigureAwait(false);
+        if (DataContext is not MainViewModel vm)
+        {
+            return;
+        }
+
+        if (JpgItem.IsSelected)
+        {
+            QualitySlider.IsEnabled = true;
+        }
+        else if (vm.FileInfo.Extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) ||
+                 vm.FileInfo.Extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase))
+        {
+            QualitySlider.IsEnabled = true;
+        }
+        else
+        {
+            QualitySlider.IsEnabled = false;
+        }
     }
-    
-    private async Task DoSaveImage(MainViewModel vm, string destination)
+
+    private async Task OnKeyDownVerifyInput(KeyEventArgs e)
     {
-        if (!uint.TryParse(PixelWidthTextBox.Text, out var width) || !uint.TryParse(PixelHeightTextBox.Text, out var height))
+        switch (e.Key)
         {
-            return;
+            case Key.D0:
+            case Key.D1:
+            case Key.D2:
+            case Key.D3:
+            case Key.D4:
+            case Key.D5:
+            case Key.D6:
+            case Key.D7:
+            case Key.D8:
+            case Key.D9:
+            case Key.NumPad0:
+            case Key.NumPad1:
+            case Key.NumPad2:
+            case Key.NumPad3:
+            case Key.NumPad4:
+            case Key.NumPad5:
+            case Key.NumPad6:
+            case Key.NumPad7:
+            case Key.NumPad8:
+            case Key.NumPad9:
+            case Key.Back:
+            case Key.Delete:
+                break; // Allow numbers and basic operations
+
+            case Key.Left:
+            case Key.Right:
+            case Key.Tab:
+            case Key.OemBackTab:
+                break; // Allow navigation keys
+
+            case Key.A:
+            case Key.C:
+            case Key.X:
+            case Key.V:
+                if (e.KeyModifiers == KeyModifiers.Control)
+                {
+                    // Allow Ctrl + A, Ctrl + C, Ctrl + X, and Ctrl + V (paste)
+                    break;
+                }
+
+                e.Handled = true; // Only allow with Ctrl
+                return;
+
+            case Key.Oem5: // Key for `%` symbol (may vary based on layout)
+                break; // Allow the percentage symbol (%)
+
+            case Key.Escape: // Handle Escape key
+                Focus();
+                e.Handled = true;
+                return;
+
+            case Key.Enter: // Handle Enter key for saving
+                if (DataContext is not MainViewModel vm)
+                {
+                    return;
+                }
+
+                await SaveImage(vm).ConfigureAwait(false);
+                return;
+
+            default:
+                e.Handled = true; // Block all other inputs
+                return;
         }
+    }
 
-        if (width == vm.PixelWidth)
+    private async Task SaveImageAs(MainViewModel vm)
+    {
+        if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop ||
+            desktop.MainWindow?.StorageProvider is not { } provider)
         {
-            if (height == vm.PixelHeight)
-            {
-                
-            }
-            else
-            {
-                width = 0;
-            }
+            return;
         }
 
-        if (height == vm.PixelHeight)
+        var options = new FilePickerSaveOptions
         {
-            if (width == vm.PixelWidth)
-            {
-                
-            }
-            else
-            {
-                height = 0;
-            }
+            Title = $"{TranslationHelper.Translation.OpenFileDialog} - PicView",
+            SuggestedFileName = Path.GetFileNameWithoutExtension(vm.FileInfo.Name),
+            SuggestedStartLocation =
+                await desktop.MainWindow.StorageProvider.TryGetFolderFromPathAsync(vm.FileInfo.FullName)
+        };
+        var file = await provider.SaveFilePickerAsync(options);
+        if (file is null)
+        {
+            return;
         }
 
-        var file = vm.FileInfo.FullName;
-        uint? quality = null;
-        if (QualitySlider.IsEnabled)
+        var destination = RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
+            ? file.Path.AbsolutePath
+            : file.Path.LocalPath;
+        await DoSaveImage(vm, destination).ConfigureAwait(false);
+    }
+
+    private async Task SaveImage(MainViewModel vm)
+    {
+        await DoSaveImage(vm, vm.FileInfo.FullName).ConfigureAwait(false);
+    }
+
+    private async Task DoSaveImage(MainViewModel vm, string destination)
+    {
+        if (!uint.TryParse(PixelWidthTextBox.Text, out var width) ||
+            !uint.TryParse(PixelHeightTextBox.Text, out var height))
         {
-            quality = (uint)QualitySlider.Value;
+            return;
         }
+
+        //Set loading and prevent user from interacting with UI
+        ParentContainer.Opacity = .1;
+        ParentContainer.IsHitTestVisible = false;
+        SpinWaiter.IsVisible = true;
+
+        var rotationAngle = 0; // TODO make a control for adjusting rotation
+
+        var file = vm.FileInfo.FullName;
+        var sameFile = file.Equals(destination, StringComparison.OrdinalIgnoreCase);
         var ext = vm.FileInfo.Extension;
         if (!NoConversion.IsSelected)
         {
             if (PngItem.IsSelected)
             {
                 ext = ".png";
+                destination = Path.ChangeExtension(destination, ".png");
             }
             else if (JpgItem.IsSelected)
             {
                 ext = ".jpg";
+                destination = Path.ChangeExtension(destination, ".jpg");
             }
             else if (WebpItem.IsSelected)
             {
                 ext = ".webp";
+                destination = Path.ChangeExtension(destination, ".webp");
             }
             else if (AvifItem.IsSelected)
             {
                 ext = ".avif";
+                destination = Path.ChangeExtension(destination, ".avif");
             }
             else if (HeicItem.IsSelected)
             {
                 ext = ".heic";
+                destination = Path.ChangeExtension(destination, ".heic");
             }
             else if (JxlItem.IsSelected)
             {
                 ext = ".jxl";
+                destination = Path.ChangeExtension(destination, ".jxl");
+            }
+        }
+
+        uint? quality = null;
+        if (QualitySlider.IsEnabled)
+        {
+            if (ext == ".jpg" || Path.GetExtension(destination) == ".jpg" || Path.GetExtension(destination) == ".jpeg")
+            {
+                quality = (uint)QualitySlider.Value;
             }
         }
-        var success = await SaveImageFileHelper.SaveImageAsync(null, file, destination, width, height, quality, ext, vm.RotationAngle).ConfigureAwait(false);
+
+        var success = await SaveImageFileHelper.SaveImageAsync(null,
+            file,
+            sameFile ? null : destination,
+            width,
+            height,
+            quality,
+            ext,
+            rotationAngle).ConfigureAwait(false);
+        await Dispatcher.UIThread.InvokeAsync(() =>
+        {
+            SpinWaiter.IsVisible = false;
+            ParentContainer.IsHitTestVisible = true;
+            ParentContainer.Opacity = 1;
+        });
         if (!success)
         {
-            // TODO: show error
+            await TooltipHelper.ShowTooltipMessageAsync(TranslationHelper.Translation.SavingFileFailed);
             return;
         }
+
         if (destination == file)
         {
             if (!NavigationHelper.CanNavigate(vm))
@@ -121,4 +292,9 @@ public partial class SingleImageResizeView : UserControl
             }
         }
     }
-}
+
+    ~SingleImageResizeView()
+    {
+        _imageUpdateSubscription?.Dispose();
+    }
+}

+ 4 - 4
src/PicView.Core/ImageDecoding/SaveImageFileHelper.cs

@@ -46,9 +46,9 @@ public static class SaveImageFileHelper
             {
                 if (height is not null)
                 {
-                    if (height >= 0)
+                    if (height > 0)
                     {
-                        magickImage.Resize(0, height.Value);
+                        magickImage.Resize(width > 0 ? width.Value : 0, height.Value);
                     }
                 }
                 else
@@ -60,9 +60,9 @@ public static class SaveImageFileHelper
             {
                 if (width is not null)
                 {
-                    if (width >= 0)
+                    if (width > 0)
                     {
-                        magickImage.Resize(width.Value, 0);
+                        magickImage.Resize(width.Value, height > 0 ? height.Value : 0);
                     }
                 }
                 else