Browse Source

Navigation wip

Krzysztof Krysiński 1 year ago
parent
commit
6acbf9eaca

+ 2 - 1
src/PixiEditor.AvaloniaUI/Data/Localization/Languages/en.json

@@ -586,5 +586,6 @@
   "ADD_PRIMARY_COLOR_TO_PALETTE": "Add primary color to palette",
   "ADD_PRIMARY_COLOR_TO_PALETTE_DESCRIPTIVE": "Add primary color to current palette",
 
-  "EXPORT_SAVE_TITLE": "Choose a location to save the image"
+  "EXPORT_SAVE_TITLE": "Choose a location to save the image",
+  "PREVIEW_BACKGROUND": "Preview background"
 }

+ 32 - 0
src/PixiEditor.AvaloniaUI/Helpers/Converters/FormattedColorConverter.cs

@@ -0,0 +1,32 @@
+using System.Collections.Generic;
+using System.Globalization;
+using Avalonia.Media;
+
+namespace PixiEditor.AvaloniaUI.Helpers.Converters;
+
+internal class FormattedColorConverter
+    : SingleInstanceMultiValueConverter<FormattedColorConverter>
+{
+    public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+    {
+        return Convert(new[] { value }, targetType, parameter, culture);
+    }
+
+    public override object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
+    {
+        if (values == null ||
+            values.Count <= 1 ||
+            values[0] is not Color color ||
+            values[1] is not string format)
+        {
+            return "";
+        }
+
+        return format.ToLowerInvariant() switch
+        {
+            "hex" => color.ToString(),
+            "rgba" => $"({color.R}, {color.G}, {color.B}, {color.A})",
+            _ => "",
+        };
+    }
+}

+ 4 - 0
src/PixiEditor.AvaloniaUI/PixiEditor.AvaloniaUI.csproj

@@ -74,6 +74,10 @@
       <Compile Remove="Views\Buttons\**" />
       <EmbeddedResource Remove="Views\Buttons\**" />
       <None Remove="Views\Buttons\**" />
+      <Compile Update="Views\Main\ViewportControls\Viewport.axaml.cs">
+        <DependentUpon>Viewport.axaml</DependentUpon>
+        <SubType>Code</SubType>
+      </Compile>
     </ItemGroup>
   
     <ItemGroup>

+ 24 - 2
src/PixiEditor.AvaloniaUI/ViewModels/Dock/DockFactory.cs

@@ -17,6 +17,7 @@ internal class DockFactory : Factory
     private ToolDock toolDock;
     private ToolDock layersDock;
     private ToolDock colorPickerDock;
+    private ToolDock navigationDock;
 
     private FileViewModel fileVm;
     private ColorsViewModel colorsVm;
@@ -106,6 +107,7 @@ internal class DockFactory : Factory
     {
         layersDock = BuildLayersDock();
         colorPickerDock = BuildColorPickerDock();
+        navigationDock = BuildNavigationDock();
         return new ProportionalDock()
         {
             Proportion = 0.20,
@@ -113,12 +115,32 @@ internal class DockFactory : Factory
             VisibleDockables = new List<IDockable>()
             {
                 colorPickerDock,
-                layersDock
+                layersDock,
+                navigationDock,
             },
             ActiveDockable = layersDock,
         };
     }
 
+    private ToolDock BuildNavigationDock()
+    {
+        NavigationDockViewModel navigationVm = new(colorsVm, fileVm.Owner.DocumentManagerSubViewModel)
+        {
+            Id = "NavigationPane",
+            Title = "NavigationPane",
+        };
+
+        ToolDock navigation = new()
+        {
+            Id = "NavigationPane",
+            Title = "NavigationPane",
+            VisibleDockables = new List<IDockable>() { navigationVm },
+            ActiveDockable = navigationVm,
+        };
+
+        return navigation;
+    }
+
     private ToolDock BuildColorPickerDock()
     {
         ColorPickerDockViewModel colorPickerVm = new(colorsVm)
@@ -137,7 +159,7 @@ internal class DockFactory : Factory
         {
             Id = "ColorPickerPane",
             Title = "ColorPickerPane",
-            VisibleDockables = new List<IDockable>() { colorPickerVm, paletteViewerVm},
+            VisibleDockables = new List<IDockable>() { colorPickerVm, paletteViewerVm },
             ActiveDockable = colorPickerVm,
         };
 

+ 33 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Dock/NavigationDockViewModel.cs

@@ -0,0 +1,33 @@
+using Avalonia;
+using Dock.Model.Avalonia.Controls;
+using PixiEditor.AvaloniaUI.ViewModels.Document;
+using PixiEditor.AvaloniaUI.ViewModels.SubViewModels;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Dock;
+
+internal class NavigationDockViewModel : Tool
+{
+    public static readonly StyledProperty<ColorsViewModel> ColorsViewModelProperty = AvaloniaProperty.Register<ColorPickerDockViewModel, ColorsViewModel>(
+        nameof(ColorsSubViewModel));
+
+    public ColorsViewModel ColorsSubViewModel
+    {
+        get => GetValue(ColorsViewModelProperty);
+        set => SetValue(ColorsViewModelProperty, value);
+    }
+
+    public static readonly StyledProperty<DocumentManagerViewModel> DocumentManagerSubViewModelProperty = AvaloniaProperty.Register<PaletteViewerDockViewModel, DocumentManagerViewModel>(
+        "DocumentManagerSubViewModel");
+
+    public DocumentManagerViewModel DocumentManagerSubViewModel
+    {
+        get => GetValue(DocumentManagerSubViewModelProperty);
+        set => SetValue(DocumentManagerSubViewModelProperty, value);
+    }
+
+    public NavigationDockViewModel(ColorsViewModel colorsSubViewModel, DocumentManagerViewModel documentManagerViewModel)
+    {
+        ColorsSubViewModel = colorsSubViewModel;
+        DocumentManagerSubViewModel = documentManagerViewModel;
+    }
+}

+ 11 - 0
src/PixiEditor.AvaloniaUI/Views/Dock/NavigationDockView.axaml

@@ -0,0 +1,11 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             xmlns:main="clr-namespace:PixiEditor.AvaloniaUI.Views.Main"
+             xmlns:converters="clr-namespace:PixiEditor.AvaloniaUI.Helpers.Converters"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:Class="PixiEditor.AvaloniaUI.Views.Dock.NavigationDockView">
+    <main:Navigation Document="{Binding DocumentManagerSubViewModel.ActiveDocument}"
+                     PrimaryColor="{Binding ColorsSubViewModel.PrimaryColor, Mode=TwoWay, Converter={converters:GenericColorToMediaColorConverter}}"/>
+</UserControl>

+ 14 - 0
src/PixiEditor.AvaloniaUI/Views/Dock/NavigationDockView.axaml.cs

@@ -0,0 +1,14 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PixiEditor.AvaloniaUI.Views.Dock;
+
+public partial class NavigationDockView : UserControl
+{
+    public NavigationDockView()
+    {
+        InitializeComponent();
+    }
+}
+

+ 69 - 0
src/PixiEditor.AvaloniaUI/Views/Input/ListSwitchButton.cs

@@ -0,0 +1,69 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+
+namespace PixiEditor.AvaloniaUI.Views.Input;
+
+internal class ListSwitchButton : Button
+{
+    public static readonly StyledProperty<ObservableCollection<SwitchItem>> ItemsProperty =
+        AvaloniaProperty.Register<ListSwitchButton, ObservableCollection<SwitchItem>>(nameof(Items));
+
+    public static readonly StyledProperty<SwitchItem> ActiveItemProperty =
+        AvaloniaProperty.Register<ListSwitchButton, SwitchItem>(nameof(ActiveItem), new SwitchItem(new SolidColorBrush(Brushes.Transparent.Color), "", null));
+
+    public event PropertyChangedEventHandler? PropertyChanged;
+
+    public ObservableCollection<SwitchItem> Items
+    {
+        get => GetValue(ItemsProperty);
+        set => SetValue(ItemsProperty, value);
+    }
+
+    public SwitchItem ActiveItem
+    {
+        get => GetValue(ActiveItemProperty);
+        set => SetValue(ActiveItemProperty, value);
+    }
+
+    static ListSwitchButton()
+    {
+        //TODO: Validate it is not needed
+        //DefaultStyleKeyProperty.OverrideMetadata(typeof(ListSwitchButton), new FrameworkPropertyMetadata(typeof(ListSwitchButton)));
+    }
+
+    public ListSwitchButton()
+    {
+        Click += ListSwitchButton_Click;
+    }
+
+    private static void CollChanged(AvaloniaPropertyChangedEventArgs<ObservableCollection<SwitchItem>> e)
+    {
+        ListSwitchButton button = (ListSwitchButton)e.Sender;
+
+        ObservableCollection<SwitchItem> oldVal = e.OldValue.Value;
+        ObservableCollection<SwitchItem> newVal = e.NewValue.Value;
+        if ((oldVal == null || oldVal.Count == 0) && newVal != null && newVal.Count > 0)
+        {
+            button.ActiveItem = newVal[0];
+        }
+    }
+
+    private void ListSwitchButton_Click(object sender, RoutedEventArgs e)
+    {
+        if (!Items.Contains(ActiveItem))
+        {
+            throw new ArgumentException("Items doesn't contain specified Item.");
+        }
+
+        int index = Items.IndexOf(ActiveItem) + 1;
+        if (index > Items.Count - 1)
+        {
+            index = 0;
+        }
+        ActiveItem = Items[Math.Clamp(index, 0, Items.Count - 1)];
+    }
+}

+ 24 - 0
src/PixiEditor.AvaloniaUI/Views/Input/SwitchItem.cs

@@ -0,0 +1,24 @@
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+
+namespace PixiEditor.AvaloniaUI.Views.Input;
+
+internal class SwitchItem
+{
+    public string Content { get; set; } = "";
+    public IBrush? Background { get; set; }
+    public object Value { get; set; }
+
+    public BitmapInterpolationMode ScalingMode { get; set; } = BitmapInterpolationMode.HighQuality;
+
+    public SwitchItem(IBrush? background, object value, string content, BitmapInterpolationMode scalingMode = BitmapInterpolationMode.HighQuality)
+    {
+        Background = background;
+        Value = value;
+        ScalingMode = scalingMode;
+        Content = content;
+    }
+    public SwitchItem()
+    {
+    }
+}

+ 5 - 0
src/PixiEditor.AvaloniaUI/Views/Input/SwitchItemObservableCollection.xaml.cs

@@ -0,0 +1,5 @@
+using System.Collections.ObjectModel;
+
+namespace PixiEditor.AvaloniaUI.Views.Input;
+
+internal class SwitchItemObservableCollection : ObservableCollection<SwitchItem> { }

+ 98 - 0
src/PixiEditor.AvaloniaUI/Views/Main/Navigation.axaml

@@ -0,0 +1,98 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             xmlns:viewportControls="clr-namespace:PixiEditor.AvaloniaUI.Views.Main.ViewportControls"
+             xmlns:converters="clr-namespace:PixiEditor.AvaloniaUI.Helpers.Converters"
+             xmlns:input="clr-namespace:PixiEditor.AvaloniaUI.Views.Input"
+             xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
+             xmlns:ui1="clr-namespace:PixiEditor.AvaloniaUI.Helpers.UI"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             Name="uc"
+             x:Class="PixiEditor.AvaloniaUI.Views.Main.Navigation">
+     <Grid>
+        <Grid.RowDefinitions>
+            <RowDefinition Height="*"/>
+            <RowDefinition Height="5"/>
+            <RowDefinition Height="Auto"/>
+        </Grid.RowDefinitions>
+
+        <Grid x:Name="imageGrid" RenderOptions.BitmapInterpolationMode="None"
+              IsVisible="{Binding !!Document, ElementName=uc}"
+              Margin="10" Background="Transparent"
+              d:Width="8" d:Height="8">
+            <viewportControls:FixedViewport
+                Delayed="True"
+                x:Name="viewport"
+                Document="{Binding Document, ElementName=uc}"
+                Background="{Binding ActiveItem.Value, ElementName=backgroundButton}"/>
+            <Border 
+                    x:Name="colorCursor" Width="1" Height="1"
+                    Margin="{Binding ColorCursorPosition, ElementName=uc}"
+                    HorizontalAlignment="Left" VerticalAlignment="Top"
+                    BorderBrush="Black" BorderThickness=".1"
+                    IsVisible="{Binding IsMouseOver, ElementName=imageGrid}">
+                <Border BorderThickness=".1" BorderBrush="White"/>
+            </Border>
+        </Grid>
+
+        <Grid Grid.Row="1">
+            <Grid.Background>
+                <SolidColorBrush Color="{Binding ColorCursorColor, ElementName=uc, FallbackValue=Black}"/>
+            </Grid.Background>
+        </Grid>
+        <StackPanel Grid.Row="2" Orientation="Horizontal" MinHeight="30"
+                    Background="{DynamicResource ThemeBackgroundBrush}" MaxHeight="60">
+            <StackPanel.Styles>
+                <Style Selector="TextBlock">
+                    <Setter Property="VerticalAlignment" Value="Center"/>
+                </Style>
+            </StackPanel.Styles>
+
+            <TextBlock Text="{Binding ColorCursorPosition.Left, ElementName=uc, StringFormat='X: {0}'}"/>
+            <TextBlock Text="{Binding ColorCursorPosition.Top, ElementName=uc, StringFormat='Y: {0}'}"/>
+
+            <TextBlock VerticalAlignment="Center" Margin="10, 0, 0, 0">
+                <TextBlock.Text>
+                    <MultiBinding Converter="{converters:FormattedColorConverter}">
+                        <Binding Path="ColorCursorColor" ElementName="uc"/>
+                        <Binding Path="ActiveItem.Value" ElementName="formatButton"/>
+                    </MultiBinding>
+                </TextBlock.Text>
+            </TextBlock>
+        </StackPanel>
+        <Grid Grid.Row="2" HorizontalAlignment="Right" Margin="0,0,5,0" ui1:RenderOptionsBindable.BitmapInterpolationMode="{Binding ElementName=backgroundButton, Path=ActiveItem.ScalingMode}">
+            <StackPanel Orientation="Horizontal">
+                <input:ListSwitchButton x:Name="formatButton" Margin="0,0,5,0" Height="20" Width="40" BorderBrush="Black">
+                    <input:ListSwitchButton.Items>
+                        <input:SwitchItemObservableCollection>
+                            <input:SwitchItem Content="RGBA" Background="#353535" Value="RGBA"/>
+                            <input:SwitchItem Content="HEX" Background="#353535" Value="HEX"/>
+                        </input:SwitchItemObservableCollection>
+                    </input:ListSwitchButton.Items>
+                </input:ListSwitchButton>
+                <input:ListSwitchButton BorderBrush="{StaticResource DarkerAccentColor}" Width="25" Height="20" x:Name="backgroundButton" ui:Translator.TooltipKey="PREVIEW_BACKGROUND">
+                    <input:ListSwitchButton.Items>
+                        <input:SwitchItemObservableCollection>
+                            <input:SwitchItem ScalingMode="None">
+                                <input:SwitchItem.Background>
+                                    <ImageBrush Source="/Images/CheckerTile.png" TileMode="Tile" DestinationRect="0, 0, 1, 1"/>
+                                </input:SwitchItem.Background>
+                                <input:SwitchItem.Value>
+                                    <ImageBrush DestinationRect="0, 10, 10, 10" Source="/Images/CheckerTile.png" TileMode="Tile"/>
+                                </input:SwitchItem.Value>
+                            </input:SwitchItem>
+                            <input:SwitchItem Value="Transparent">
+                                <input:SwitchItem.Background>
+                                    <ImageBrush Source="/Images/DiagonalRed.png"/>
+                                </input:SwitchItem.Background>
+                            </input:SwitchItem>
+                            <input:SwitchItem Background="White" Value="White"/>
+                            <input:SwitchItem Background="Black" Value="Black"/>
+                        </input:SwitchItemObservableCollection>
+                    </input:ListSwitchButton.Items>
+                </input:ListSwitchButton>
+            </StackPanel>
+        </Grid>
+    </Grid>
+</UserControl>

+ 165 - 0
src/PixiEditor.AvaloniaUI/Views/Main/Navigation.axaml.cs

@@ -0,0 +1,165 @@
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using Hardware.Info;
+using PixiEditor.AvaloniaUI.Helpers;
+using PixiEditor.AvaloniaUI.Models.Controllers.InputDevice;
+using PixiEditor.AvaloniaUI.Models.Tools;
+using PixiEditor.AvaloniaUI.ViewModels;
+using PixiEditor.AvaloniaUI.ViewModels.Document;
+using PixiEditor.DrawingApi.Core.Numerics;
+
+namespace PixiEditor.AvaloniaUI.Views.Main;
+
+internal partial class Navigation : UserControl
+{
+    public static readonly StyledProperty<DocumentViewModel> DocumentProperty =
+        AvaloniaProperty.Register<Navigation, DocumentViewModel>(nameof(Document));
+
+    public static readonly StyledProperty<Thickness> ColorCursorPositionProperty =
+        AvaloniaProperty.Register<Navigation, Thickness>(nameof(ColorCursorPosition));
+
+    public static readonly StyledProperty<Color> ColorCursorColorProperty =
+        AvaloniaProperty.Register<Navigation, Color>(nameof(ColorCursorColor));
+
+    public static readonly StyledProperty<Color> PrimaryColorProperty =
+        AvaloniaProperty.Register<Navigation, Color>(nameof(PrimaryColor));
+
+    public DocumentViewModel Document
+    {
+        get => GetValue(DocumentProperty);
+        set => SetValue(DocumentProperty, value);
+    }
+
+    public Thickness ColorCursorPosition
+    {
+        get => GetValue(ColorCursorPositionProperty);
+        private set => SetValue(ColorCursorPositionProperty, value);
+    }
+
+    public Color ColorCursorColor
+    {
+        get => GetValue(ColorCursorColorProperty);
+        set => SetValue(ColorCursorColorProperty, value);
+    }
+
+    public Color PrimaryColor
+    {
+        get => GetValue(PrimaryColorProperty);
+        set => SetValue(PrimaryColorProperty, value);
+    }
+    
+    private MouseUpdateController mouseUpdateController;
+
+    public Navigation()
+    {
+        InitializeComponent();
+        
+        imageGrid.PointerPressed += ImageGrid_MouseRightButtonDown;
+        imageGrid.PointerEntered += ImageGrid_MouseEnter;
+        imageGrid.PointerExited += ImageGrid_MouseLeave;
+        
+        imageGrid.Loaded += OnGridLoaded;
+        imageGrid.Unloaded += OnGridUnloaded;
+    }
+
+    private void OnGridUnloaded(object sender, RoutedEventArgs e)
+    {
+        mouseUpdateController?.Dispose();
+    }
+
+    private void OnGridLoaded(object sender, RoutedEventArgs e)
+    {
+        mouseUpdateController = new MouseUpdateController(imageGrid, ImageGrid_MouseMove);
+    }
+
+    private void ImageGrid_MouseLeave(object sender, PointerEventArgs e)
+    {
+        if (ViewModelMain.Current != null)
+        {
+            ViewModelMain.Current.ActionDisplays[nameof(Navigation)] = null;
+        }
+    }
+
+    private void ImageGrid_MouseEnter(object sender, PointerEventArgs e)
+    {
+        if (ViewModelMain.Current != null)
+        {
+            ViewModelMain.Current.ActionDisplays[nameof(Navigation)] = "NAVIGATOR_PICK_ACTION_DISPLAY";
+        }
+    }
+
+    private async void ImageGrid_MouseRightButtonDown(object sender, PointerPressedEventArgs e)
+    {
+        if(e.GetMouseButton(this) != MouseButton.Right) return;
+        
+        if (e.KeyModifiers.HasFlag(KeyModifiers.Shift))
+        {
+            await CopyColorToClipboard();
+        }
+        else
+        {
+            CopyColorToPrimary();
+        }
+    }
+
+    private void CopyColorToPrimary()
+    {
+        PrimaryColor = ColorCursorColor;
+    }
+
+    private async Task CopyColorToClipboard()
+    {
+        var topLevel = TopLevel.GetTopLevel(this);
+        if (topLevel is null || topLevel.Clipboard == null)
+        {
+            return;
+        }
+
+        if ((string)formatButton.ActiveItem.Value == "HEX")
+        {
+            await topLevel.Clipboard.SetTextAsync(ColorCursorColor.A == 255
+                ? $"#{ColorCursorColor.R:X2}{ColorCursorColor.G:X2}{ColorCursorColor.B:X2}"
+                : ColorCursorColor.ToString());
+        }
+        else
+        {
+            await topLevel.Clipboard.SetTextAsync(ColorCursorColor.A == 255
+                ? $"rgb({ColorCursorColor.R},{ColorCursorColor.G},{ColorCursorColor.B})"
+                : $"rgba({ColorCursorColor.R},{ColorCursorColor.G},{ColorCursorColor.B},{ColorCursorColor.A})");
+        }
+    }
+
+    private void ImageGrid_MouseMove(PointerEventArgs e)
+    {
+        if (Document is null)
+        {
+            return;
+        }
+
+        Point mousePos = e.GetPosition(viewport);
+        VecD mousePosConverted =
+            new VecD(mousePos.X, mousePos.Y)
+                .Divide(new VecD(viewport.Bounds.Width, viewport.Bounds.Height))
+                .Multiply(Document.SizeBindable);
+
+        int x = (int)mousePosConverted.X;
+        int y = (int)mousePosConverted.Y;
+
+        Thickness newPos = new Thickness(x, y, 0, 0);
+
+        if (ColorCursorPosition == newPos)
+        {
+            return;
+        }
+
+        ColorCursorPosition = newPos;
+
+        var color = Document.PickColor(new(x, y), DocumentScope.AllLayers, false, true);
+        ColorCursorColor = Color.FromArgb(color.A, color.R, color.G, color.B);
+    }
+}
+

+ 30 - 0
src/PixiEditor.AvaloniaUI/Views/Main/ViewportControls/FixedViewport.axaml

@@ -0,0 +1,30 @@
+<UserControl x:Class="PixiEditor.AvaloniaUI.Views.Main.ViewportControls.FixedViewport"
+             x:ClassModifier="internal"
+             xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             xmlns:local="clr-namespace:PixiEditor.Views.UserControls"
+             xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
+             xmlns:converters1="clr-namespace:PixiEditor.AvaloniaUI.Helpers.Converters"
+             xmlns:ui="clr-namespace:PixiEditor.AvaloniaUI.Helpers.UI"
+             mc:Ignorable="d"
+             x:Name="uc"
+             HorizontalAlignment="Center"
+             VerticalAlignment="Center"
+             d:DesignHeight="450" d:DesignWidth="800">
+    <Image
+        x:Name="mainImage"
+        Focusable="True"
+        Source="{Binding TargetBitmap, ElementName=uc}"
+        HorizontalAlignment="Stretch"
+        Stretch="Uniform"
+        SizeChanged="OnImageSizeChanged">
+        <ui:RenderOptionsBindable.BitmapInterpolationMode>
+            <MultiBinding Converter="{converters1:WidthToBitmapScalingModeConverter}">
+                <Binding ElementName="uc" Path="TargetBitmap.PixelSize.Width"/>
+                <Binding ElementName="mainImage" Path="Bounds.Width"/>
+            </MultiBinding>
+        </ui:RenderOptionsBindable.BitmapInterpolationMode>
+    </Image>
+</UserControl>

+ 143 - 0
src/PixiEditor.AvaloniaUI/Views/Main/ViewportControls/FixedViewport.axaml.cs

@@ -0,0 +1,143 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Data.Core;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using Avalonia.Media.Imaging;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.AvaloniaUI.Models.Position;
+using PixiEditor.AvaloniaUI.ViewModels.Document;
+using PixiEditor.DrawingApi.Core.Numerics;
+
+namespace PixiEditor.AvaloniaUI.Views.Main.ViewportControls;
+
+internal partial class FixedViewport : UserControl
+{
+    public static readonly StyledProperty<DocumentViewModel> DocumentProperty =
+        AvaloniaProperty.Register<FixedViewport, DocumentViewModel>(nameof(Document), null);
+
+    private static readonly StyledProperty<Dictionary<ChunkResolution, WriteableBitmap>> BitmapsProperty =
+        AvaloniaProperty.Register<FixedViewport, Dictionary<ChunkResolution, WriteableBitmap>>(nameof(Bitmaps), null);
+
+    public static readonly StyledProperty<bool> DelayedProperty =
+        AvaloniaProperty.Register<FixedViewport, bool>(nameof(Delayed), false);
+
+    public event PropertyChangedEventHandler? PropertyChanged;
+
+    public bool Delayed
+    {
+        get => GetValue(DelayedProperty);
+        set => SetValue(DelayedProperty, value);
+    }
+
+    public Dictionary<ChunkResolution, WriteableBitmap>? Bitmaps
+    {
+        get => GetValue(BitmapsProperty);
+        set => SetValue(BitmapsProperty, value);
+    }
+
+    public DocumentViewModel? Document
+    {
+        get => GetValue(DocumentProperty);
+        set => SetValue(DocumentProperty, value);
+    }
+
+    public WriteableBitmap? TargetBitmap
+    {
+        get
+        {
+            if (Document?.LazyBitmaps.TryGetValue(CalculateResolution(), out WriteableBitmap? value) == true)
+                return value;
+            return null;
+        }
+    }
+
+    public Guid GuidValue { get; } = Guid.NewGuid();
+
+    static FixedViewport()
+    {
+        DocumentProperty.Changed.Subscribe(OnDocumentChange);
+        BitmapsProperty.Changed.Subscribe(OnBitmapsChange);
+    }
+
+    public FixedViewport()
+    {
+        InitializeComponent();
+        Binding binding = new Binding { Source = this, Path = $"{nameof(Document)}.{nameof(Document.LazyBitmaps)}" };
+        this.Bind(BitmapsProperty, binding);
+        Loaded += OnLoad;
+        Unloaded += OnUnload;
+    }
+
+    private void OnUnload(object sender, RoutedEventArgs e)
+    {
+        Document?.Operations.RemoveViewport(GuidValue);
+    }
+
+    private void OnLoad(object sender, RoutedEventArgs e)
+    {
+        Document?.Operations.AddOrUpdateViewport(GetLocation());
+    }
+
+    private ChunkResolution CalculateResolution()
+    {
+        if (Document is null)
+            return ChunkResolution.Full;
+        double density = Document.Width / mainImage.Bounds.Width;
+        if (density > 8.01)
+            return ChunkResolution.Eighth;
+        else if (density > 4.01)
+            return ChunkResolution.Quarter;
+        else if (density > 2.01)
+            return ChunkResolution.Half;
+        return ChunkResolution.Full;
+    }
+
+    private void ForceRefreshFinalImage()
+    {
+        mainImage.InvalidateVisual();
+    }
+
+    private ViewportInfo GetLocation()
+    {
+        VecD docSize = new VecD(1);
+        if (Document is not null)
+            docSize = Document.SizeBindable;
+
+        return new ViewportInfo(
+            0,
+            docSize / 2,
+            new VecD(mainImage.Bounds.Width, mainImage.Bounds.Height),
+            docSize,
+            CalculateResolution(),
+            GuidValue,
+            Delayed,
+            ForceRefreshFinalImage);
+    }
+
+    private static void OnDocumentChange(AvaloniaPropertyChangedEventArgs<DocumentViewModel> args)
+    {
+        DocumentViewModel? oldDoc = args.OldValue.Value;
+        DocumentViewModel? newDoc = args.NewValue.Value;
+        FixedViewport? viewport = (FixedViewport)args.Sender;
+        oldDoc?.Operations.RemoveViewport(viewport.GuidValue);
+        newDoc?.Operations.AddOrUpdateViewport(viewport.GetLocation());
+    }
+
+    private static void OnBitmapsChange(AvaloniaPropertyChangedEventArgs<Dictionary<ChunkResolution, WriteableBitmap>> args)
+    {
+        FixedViewport? viewport = (FixedViewport)args.Sender;
+        viewport.PropertyChanged?.Invoke(viewport, new(nameof(TargetBitmap)));
+        viewport.Document?.Operations.AddOrUpdateViewport(viewport.GetLocation());
+    }
+
+    private void OnImageSizeChanged(object sender, SizeChangedEventArgs e)
+    {
+        PropertyChanged?.Invoke(this, new(nameof(TargetBitmap)));
+        Document?.Operations.AddOrUpdateViewport(GetLocation());
+    }
+}
+

+ 0 - 0
src/PixiEditor.AvaloniaUI/Views/Main/Viewport.axaml → src/PixiEditor.AvaloniaUI/Views/Main/ViewportControls/Viewport.axaml


+ 0 - 0
src/PixiEditor.AvaloniaUI/Views/Main/Viewport.axaml.cs → src/PixiEditor.AvaloniaUI/Views/Main/ViewportControls/Viewport.axaml.cs