Sfoglia il codice sorgente

Merge commit '3423e072295f1f0bb87b5615b881591f0e557e1a' into frank

Frank van den Bos 1 settimana fa
parent
commit
771df6cb68

+ 270 - 56
InABox.Avalonia/Components/ImageEditor/ImageEditor.axaml

@@ -4,63 +4,277 @@
              xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
 			 xmlns:components="using:InABox.Avalonia.Components"
 			 xmlns:converters="using:InABox.Avalonia.Converters"
+			 xmlns:system="using:System"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              x:Class="InABox.Avalonia.Components.ImageEditor">
-	<Grid>
-		<Grid.RowDefinitions>
-			<RowDefinition Height="*"/>
-			<RowDefinition Height="Auto"/>
-		</Grid.RowDefinitions>
-		<Image Grid.Row="0" Source="{Binding $parent[components:ImageEditor].Source}"/>
-		<Canvas Name="Canvas"
-				Grid.Row="0"
-				Background="Transparent"
-				PointerPressed="Canvas_PointerPressed"
-				PointerMoved="Canvas_PointerMoved"
-				PointerReleased="Canvas_PointerReleased"/>
-		<Border Grid.Row="1" Classes="Standard">
-			<Grid>
-				<Grid.ColumnDefinitions>
-					<ColumnDefinition Width="*"/>
-					<ColumnDefinition Width="Auto"/>
-					<ColumnDefinition Width="*"/>
-				</Grid.ColumnDefinitions>
-				<UniformGrid Grid.Column="1" Margin="-10" Rows="1">
-					<UniformGrid.Styles>
-						<Style Selector="FlyoutPresenter">
-							<Setter Property="Padding" Value="{StaticResource PrsControlSpacing}"/>
-							<Setter Property="CornerRadius" Value="{StaticResource PrsCornerRadius}"/>
-							<Setter Property="BorderBrush" Value="Black"/>
-							<Setter Property="MinWidth" Value="0"/>
+	<UserControl.Resources>
+		<converters:DoubleCalculator x:Key="Add1Calculator" Constants="1.0" Type="Sum"/>
+	</UserControl.Resources>
+	<Border CornerRadius="{Binding $parent[components:ImageEditor].CornerRadius}" ClipToBounds="True">
+		<Grid>
+			<Grid.RowDefinitions>
+				<RowDefinition Height="*"/>
+				<RowDefinition Height="Auto"/>
+			</Grid.RowDefinitions>
+			<Canvas Name="OuterCanvas" Background="White">
+				<Border Name="ImageBorder" Background="White"
+						BoxShadow="0 0 10 Gray">
+					<Grid>
+						<Image Name="Image" Source="{Binding $parent[components:ImageEditor].Source}"/>
+						<Canvas Name="Canvas"
+								Background="Transparent"
+								PointerPressed="Canvas_PointerPressed"
+								PointerMoved="Canvas_PointerMoved"
+								PointerReleased="Canvas_PointerReleased"
+								ClipToBounds="True"/>
+					</Grid>
+				</Border>
+			</Canvas>
+			<Border Grid.Row="1"
+					BoxShadow="0 0 10 Gray"
+					IsVisible="{Binding $parent[components:ImageEditor].ShowButtons}">
+				<Border ClipToBounds="True"
+						Background="White">
+					<Border.Styles>
+						<Style Selector="Button">
+							<Setter Property="Background"
+									Value="Transparent"/>
+							<Setter Property="ClipToBounds"
+									Value="False"/>
 						</Style>
-					</UniformGrid.Styles>
-					<Button Name="ShapeButton"
-							Width="40" Height="40"
-							Margin="10">
-						<Button.Flyout>
-							<Flyout Placement="Top" VerticalOffset="-5">
-								<UniformGrid Margin="-10" Rows="1">
-									<Button Width="40" Height="40" Margin="10"
-											CommandParameter="{x:Static components:ImageEditingMode.Polyline}"
-											Command="{Binding $parent[components:ImageEditor].SetModeCommand}"/>
-								</UniformGrid>
-							</Flyout>
-						</Button.Flyout>
-					</Button>
-					<Button Name="PrimaryColour" Width="40" Height="40">
-						<Ellipse Width="25" Height="25"
-								 Margin="10"
-								 HorizontalAlignment="Center" VerticalAlignment="Center"
-								 Fill="{Binding $parent[components:ImageEditor].PrimaryBrush}"
-								 Stroke="Black" StrokeThickness="1"/>
-						<Button.Flyout>
-							<Flyout Placement="Top" VerticalOffset="-5">
-								<ColorView Color="{Binding $parent[components:ImageEditor].PrimaryBrush,Converter={x:Static converters:BrushToColorConverter.Instance}}"/>
-							</Flyout>
-						</Button.Flyout>
-					</Button>
-				</UniformGrid>
-			</Grid>
-		</Border>
-	</Grid>
+						<Style Selector="Button:flyout-open /template/ ContentPresenter">
+							<Setter Property="BoxShadow"
+									Value="0 0 10 DarkGray"/>
+						</Style>
+						<Style Selector="Button:disabled">
+							<Setter Property="Foreground"
+									Value="#909090"/>
+						</Style>
+						<Style Selector="Button:disabled /template/ ContentPresenter">
+							<Setter Property="Background"
+									Value="Transparent"/>
+						</Style>
+						<Style Selector="Button:pointerover /template/ ContentPresenter#PART_ContentPresenter">
+							<Setter Property="BorderBrush" Value="{Binding $parent[Button].BorderBrush}" />
+							<Setter Property="BorderThickness" Value="{Binding $parent[Button].BorderThickness}" />
+							<Setter Property="Background" Value="{Binding $parent[Button].Background}" />
+							<Setter Property="Foreground" Value="{Binding $parent[Button].Foreground}" />
+						</Style>
+					</Border.Styles>
+					<Grid>
+						<Grid.ColumnDefinitions>
+							<ColumnDefinition Width="*"/>
+							<ColumnDefinition Width="Auto"/>
+							<ColumnDefinition Width="*"/>
+						</Grid.ColumnDefinitions>
+						<StackPanel Grid.Column="1" Margin="-10"
+									Orientation="Horizontal">
+							<StackPanel.Styles>
+								<!--
+								<Style Selector="FlyoutPresenter">
+									<Setter Property="Padding" Value="{StaticResource PrsControlSpacing}"/>
+									<Setter Property="CornerRadius" Value="{StaticResource PrsCornerRadius}"/>
+									<Setter Property="BorderBrush" Value="Black"/>
+									<Setter Property="MinWidth" Value="0"/>
+									<Setter Property="Background" Value="White"/>
+								</Style>
+								-->
+								<Style Selector="FlyoutPresenter">
+									<Setter Property="Padding" Value="5"/>
+									<Setter Property="Background" Value="Transparent"/>
+									<Setter Property="BorderThickness" Value="0"/>
+									<Setter Property="CornerRadius" Value="0"/>
+									<Setter Property="ClipToBounds" Value="False"/>
+								</Style>
+								<Style Selector="FlyoutPresenter > Border">
+									<Setter Property="Padding" Value="{StaticResource PrsControlSpacing}"/>
+									<Setter Property="BorderBrush" Value="Black"/>
+									<Setter Property="BorderThickness" Value="0"/>
+									<Setter Property="Background" Value="White"/>
+									<Setter Property="BoxShadow" Value="0 0 10 Gray"/>
+								</Style>
+								<Style Selector="Rectangle.Separator">
+									<Setter Property="Fill" Value="Gray"/>
+									<Setter Property="Width" Value="1"/>
+									<Setter Property="VerticalAlignment" Value="Stretch"/>
+									<Setter Property="Margin" Value="0,10"/>
+								</Style>
+							</StackPanel.Styles>
+							<Button Name="ShapeButton"
+									Width="40" Height="40"
+									Margin="10">
+								<Button.Flyout>
+									<Flyout Placement="Top" VerticalOffset="0">
+										<Border>
+											<ItemsControl ItemsSource="{Binding $parent[components:ImageEditor].ModeButtons}">
+												<ItemsControl.ItemTemplate>
+													<DataTemplate DataType="components:ImageEditorModeButton">
+														<Button Width="40" Height="40" Margin="10"
+																CommandParameter="{Binding Mode}"
+																Command="{Binding $parent[components:ImageEditor].SetModeCommand}"
+																Content="{Binding Canvas}"/>
+													</DataTemplate>
+												</ItemsControl.ItemTemplate>
+												<ItemsControl.ItemsPanel>
+													<ItemsPanelTemplate>
+														<UniformGrid Margin="-10" Rows="1"/>
+													</ItemsPanelTemplate>
+												</ItemsControl.ItemsPanel>
+											</ItemsControl>
+										</Border>
+									</Flyout>
+								</Button.Flyout>
+							</Button>
+							
+							<Rectangle Classes="Separator"/>
+							
+							<Button Name="LineThicknessButton" Width="40" Height="40"
+									Margin="10">
+								<Canvas Width="25" Height="21"
+										HorizontalAlignment="Center" VerticalAlignment="Center">
+									<Line StrokeThickness="0.5"
+										  Stroke="Black"
+										  StartPoint="0,0"
+										  EndPoint="25,0"/>
+									<Line StrokeThickness="1"
+										  Stroke="Black"
+										  StartPoint="0,3"
+										  EndPoint="25,3"/>
+									<Line StrokeThickness="2"
+										  Stroke="Black"
+										  StartPoint="0,7"
+										  EndPoint="25,7"/>
+									<Line StrokeThickness="3"
+										  Stroke="Black"
+										  StartPoint="0,12.5"
+										  EndPoint="25,12.5"/>
+									<Line StrokeThickness="5"
+										  Stroke="Black"
+										  StartPoint="0,19.5"
+										  EndPoint="25,19.5"/>
+								</Canvas>
+								<Button.Flyout>
+									<Flyout Placement="Top" VerticalOffset="0">
+										<Border>
+											<Grid>
+												<Grid.RowDefinitions>
+													<RowDefinition Height="20"/>
+													<RowDefinition Height="Auto"/>
+												</Grid.RowDefinitions>
+												<Line Grid.Row="0"
+													  Margin="20"
+													  StartPoint="0,0"
+													  EndPoint="150,0"
+													  StrokeLineCap="Round"
+													  Stroke="Black">
+													<Line.StrokeThickness>
+														<MultiBinding Converter="{StaticResource Add1Calculator}">
+															<Binding Path="$parent[components:ImageEditor].LineThickness"/>
+														</MultiBinding>
+													</Line.StrokeThickness>
+												</Line>
+												<Line Grid.Row="0"
+													  Margin="20"
+													  StartPoint="0,0"
+													  EndPoint="150,0"
+													  StrokeThickness="{Binding $parent[components:ImageEditor].LineThickness}"
+													  StrokeLineCap="Round"
+													  Stroke="{Binding $parent[components:ImageEditor].PrimaryBrush}"/>
+												<Slider Value="{Binding $parent[components:ImageEditor].LineThickness}"
+														Grid.Row="1"
+														Minimum="1.0"
+														Maximum="30.0"
+														Width="150"/>
+											</Grid>
+										</Border>
+									</Flyout>
+								</Button.Flyout>
+							</Button>
+							<Button Name="PrimaryColour" Width="40" Height="40"
+									Margin="10">
+								<Ellipse Width="25" Height="25"
+										 Margin="10"
+										 HorizontalAlignment="Center" VerticalAlignment="Center"
+										 Fill="{Binding $parent[components:ImageEditor].PrimaryBrush,Converter={x:Static components:ImageEditorTransparentImageBrushConverter.Instance}}"
+										 Stroke="Black" StrokeThickness="1"/>
+								<Button.Flyout>
+									<Flyout Placement="Top" VerticalOffset="0">
+										<Border>
+											<ColorView Color="{Binding $parent[components:ImageEditor].PrimaryBrush,Converter={x:Static converters:BrushToColorConverter.Instance}}"/>
+										</Border>
+									</Flyout>
+								</Button.Flyout>
+							</Button>
+							<Button Name="SecondaryColour" Width="40" Height="40"
+									Margin="10">
+								<Ellipse Width="25" Height="25"
+										 Margin="10"
+										 HorizontalAlignment="Center" VerticalAlignment="Center"
+										 Fill="{Binding $parent[components:ImageEditor].SecondaryBrush,Converter={x:Static components:ImageEditorTransparentImageBrushConverter.Instance}}"
+										 Stroke="Black" StrokeThickness="1"/>
+								<Button.Flyout>
+									<Flyout Placement="Top" VerticalOffset="0">
+										<Border>
+											<ColorView Color="{Binding $parent[components:ImageEditor].SecondaryBrush,Converter={x:Static converters:BrushToColorConverter.Instance}}"/>
+										</Border>
+									</Flyout>
+								</Button.Flyout>
+							</Button>
+
+							<Rectangle Classes="Separator"/>
+
+							<Button Name="UndoButton" Width="40" Height="40"
+									Margin="10"
+									Command="{Binding $parent[components:ImageEditor].UndoCommand}"
+									IsEnabled="False">
+								<Canvas Width="25" Height="25">
+									<Path Stroke="{Binding $parent[Button].Foreground}" StrokeThickness="2">
+										<Path.Data>
+											<PathGeometry>
+												<PathFigure StartPoint="4,9" IsClosed="False">
+													<LineSegment Point="10,3"/>
+													<ArcSegment Point="18,17" Size="8,8"/>
+													<LineSegment Point="11,24"/>
+												</PathFigure>
+											</PathGeometry>
+										</Path.Data>
+									</Path>
+									<Line StartPoint="4,10" EndPoint="13,10"
+										  Stroke="{Binding $parent[Button].Foreground}" StrokeThickness="2"
+										  StrokeLineCap="Round"/>
+									<Line StartPoint="3,10" EndPoint="3,1"
+										  Stroke="{Binding $parent[Button].Foreground}" StrokeThickness="2"
+										  StrokeLineCap="Round"/>
+								</Canvas>
+							</Button>
+							<Button Name="RedoButton" Width="40" Height="40"
+									Margin="10"
+									Command="{Binding $parent[components:ImageEditor].RedoCommand}"
+									IsEnabled="False">
+								<Canvas Width="25" Height="25">
+									<Path Stroke="{Binding $parent[Button].Foreground}" StrokeThickness="2">
+										<Path.Data>
+											<PathGeometry>
+												<PathFigure StartPoint="21,9" IsClosed="False">
+													<LineSegment Point="15,3"/>
+													<ArcSegment Point="7,17" Size="8,8" SweepDirection="CounterClockwise"/>
+													<LineSegment Point="14,24"/>
+												</PathFigure>
+											</PathGeometry>
+										</Path.Data>
+									</Path>
+									<Line StartPoint="21,10" EndPoint="12,10"
+										  Stroke="{Binding $parent[Button].Foreground}" StrokeThickness="2"
+										  StrokeLineCap="Round"/>
+									<Line StartPoint="22,10" EndPoint="22,1"
+										  Stroke="{Binding $parent[Button].Foreground}" StrokeThickness="2"
+										  StrokeLineCap="Round"/>
+								</Canvas>
+							</Button>
+						</StackPanel>
+					</Grid>
+				</Border>
+			</Border>
+		</Grid>
+	</Border>
 </UserControl>

+ 421 - 16
InABox.Avalonia/Components/ImageEditor/ImageEditor.axaml.cs

@@ -3,17 +3,86 @@ using Avalonia.Controls;
 using Avalonia.Controls.Shapes;
 using Avalonia.Data;
 using Avalonia.Input;
+using Avalonia.Interactivity;
 using Avalonia.Markup.Xaml;
 using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Skia.Helpers;
 using CommunityToolkit.Mvvm.Input;
+using FluentResults;
 using InABox.Avalonia.Components.ImageEditing;
+using InABox.Avalonia.Converters;
+using InABox.Core;
+using SkiaSharp;
 using System.Collections.ObjectModel;
 
 namespace InABox.Avalonia.Components;
 
 public enum ImageEditingMode
 {
-    Polyline
+    Polyline,
+    Rectangle,
+    Ellipse
+}
+
+public class ImageEditorModeButton(ImageEditingMode mode, Canvas? canvas)
+{
+    public ImageEditingMode Mode { get; set; } = mode;
+
+    public Canvas? Canvas { get; set; } = canvas;
+}
+
+public class ImageEditorTransparentImageBrushConverter : AbstractConverter<IBrush?, IBrush?>
+{
+    public static readonly ImageEditorTransparentImageBrushConverter Instance = new ImageEditorTransparentImageBrushConverter();
+
+    protected override IBrush? Convert(IBrush? value, object? parameter = null)
+    {
+        if (value is SolidColorBrush solid && solid.Color.A == 255) return solid;
+
+        var brush = new VisualBrush
+        {
+            TileMode = TileMode.Tile,
+            DestinationRect = new(0, 0, 10, 10, RelativeUnit.Absolute)
+        };
+        var canvas = new Canvas
+        {
+            Width = 10,
+            Height = 10
+        };
+        var rect1 = new Rectangle { Width = 5, Height = 5 };
+        var rect2 = new Rectangle { Width = 5, Height = 5 };
+
+        Canvas.SetLeft(rect2, 5);
+        Canvas.SetTop(rect2, 5);
+
+        rect1.Fill = new SolidColorBrush(Colors.LightGray);
+        rect2.Fill = new SolidColorBrush(Colors.LightGray);
+
+        var rect3 = new Rectangle { Width = 10, Height = 10 };
+        rect3.Fill = value;
+
+        canvas.Children.Add(rect1);
+        canvas.Children.Add(rect2);
+        canvas.Children.Add(rect3);
+
+        brush.Visual = canvas;
+        return brush;
+    }
+}
+
+public class ImageEditorRemoveOpacityConverter : AbstractConverter<IBrush?, IBrush?>
+{
+    public static readonly ImageEditorRemoveOpacityConverter Instance = new();
+    
+    protected override IBrush? Convert(IBrush? value, object? parameter = null)
+    {
+        if (value is SolidColorBrush solid)
+        {
+            return new SolidColorBrush(new Color(255, solid.Color.R, solid.Color.G, solid.Color.B));
+        }
+        return value;
+    }
 }
 
 // TODO: Make it so we don't re-render everything everytime 'Objects' changes.
@@ -26,15 +95,55 @@ public partial class ImageEditor : UserControl
     public static readonly StyledProperty<IBrush?> PrimaryBrushProperty =
         AvaloniaProperty.Register<ImageEditor, IBrush?>(nameof(PrimaryBrush), new SolidColorBrush(Colors.Black));
 
+    public static readonly StyledProperty<IBrush?> SecondaryBrushProperty =
+        AvaloniaProperty.Register<ImageEditor, IBrush?>(nameof(SecondaryBrush), new SolidColorBrush(Colors.White));
+
+    public static readonly StyledProperty<double> LineThicknessProperty =
+        AvaloniaProperty.Register<ImageEditor, double>(nameof(LineThickness), 3.0);
+
+    public static readonly StyledProperty<int> ImageWidthProperty =
+        AvaloniaProperty.Register<ImageEditor, int>(nameof(ImageWidth), 100);
+
+    public static readonly StyledProperty<int> ImageHeightProperty =
+        AvaloniaProperty.Register<ImageEditor, int>(nameof(ImageHeight), 100);
+
+    public static readonly StyledProperty<ImageEditingMode> ModeProperty =
+        AvaloniaProperty.Register<ImageEditor, ImageEditingMode>(nameof(Mode), ImageEditingMode.Polyline);
+
+    public static readonly StyledProperty<bool> ShowButtonsProperty =
+        AvaloniaProperty.Register<ImageEditor, bool>(nameof(ShowButtons), true);
+
     public IImage? Source
     {
         get => GetValue(SourceProperty);
         set => SetValue(SourceProperty, value);
     }
 
+    public int ImageWidth
+    {
+        get => GetValue(ImageWidthProperty);
+        set => SetValue(ImageWidthProperty, value);
+    }
+
+    public int ImageHeight
+    {
+        get => GetValue(ImageHeightProperty);
+        set => SetValue(ImageHeightProperty, value);
+    }
+
+    public bool ShowButtons
+    {
+        get => GetValue(ShowButtonsProperty);
+        set => SetValue(ShowButtonsProperty, value);
+    }
+
     #region Editing Properties
 
-    public ImageEditingMode Mode { get; set; } = ImageEditingMode.Polyline;
+    public ImageEditingMode Mode
+    {
+        get => GetValue(ModeProperty);
+        set => SetValue(ModeProperty, value);
+    }
 
     public IBrush? PrimaryBrush
     {
@@ -42,50 +151,173 @@ public partial class ImageEditor : UserControl
         set => SetValue(PrimaryBrushProperty, value);
     }
 
-    public double LineThickness { get; set; } = 1.0;
+    public IBrush? SecondaryBrush
+    {
+        get => GetValue(SecondaryBrushProperty);
+        set => SetValue(SecondaryBrushProperty, value);
+    }
+
+    public double LineThickness
+    {
+        get => GetValue(LineThicknessProperty);
+        set => SetValue(LineThicknessProperty, value);
+    }
+
+    #endregion
+
+    #region Events
+
+    public event EventHandler? Changed;
 
     #endregion
 
+    #region Private Properties
+
+    public ObservableCollection<ImageEditorModeButton> ModeButtons { get; set; } = new();
+
     private ObservableCollection<IImageEditorObject> Objects = new();
 
     private IImageEditorObject? CurrentObject;
 
     private Stack<IImageEditorObject> RedoStack = new();
 
+    private double ScaleFactor = 1.0;
+
+    #endregion
+
+    static ImageEditor()
+    {
+        SourceProperty.Changed.AddClassHandler<ImageEditor>(Source_Changed);
+    }
+
+    private static void Source_Changed(ImageEditor editor, AvaloniaPropertyChangedEventArgs args)
+    {
+        if(editor.Source is not null)
+        {
+            editor.ImageWidth = (int)Math.Floor(editor.Source.Size.Width);
+            editor.ImageHeight = (int)Math.Floor(editor.Source.Size.Height);
+            editor.PositionImage();
+        }
+    }
+
     public ImageEditor()
     {
         InitializeComponent();
 
         Objects.CollectionChanged += Objects_CollectionChanged;
 
+        AddModeButtons();
+
         SetMode(Mode);
+
+        OuterCanvas.LayoutUpdated += OuterCanvas_LayoutUpdated;
     }
 
+    #region Layout
+
+    private void OuterCanvas_LayoutUpdated(object? sender, EventArgs e)
+    {
+        PositionImage();
+    }
+
+    protected override void OnLoaded(RoutedEventArgs e)
+    {
+        base.OnLoaded(e);
+        PositionImage();
+    }
+
+    private void PositionImage()
+    {
+        var canvasWidth = OuterCanvas.Bounds.Width;
+        var canvasHeight = OuterCanvas.Bounds.Height;
+
+        var scaleFactor = Math.Min(canvasWidth / ImageWidth, canvasHeight / ImageHeight);
+        ScaleFactor = scaleFactor;
+
+        var imageWidth = ImageWidth * scaleFactor;
+        var imageHeight = ImageHeight * scaleFactor;
+
+        ImageBorder.Width = imageWidth;
+        ImageBorder.Height = imageHeight;
+
+        Canvas.SetLeft(ImageBorder, OuterCanvas.Bounds.Width / 2 - imageWidth / 2);
+        Canvas.SetTop(ImageBorder, OuterCanvas.Bounds.Height / 2 - imageHeight / 2);
+
+        Canvas.RenderTransform = new ScaleTransform(ScaleFactor, ScaleFactor);
+        Canvas.Width = ImageWidth;
+        Canvas.Height = ImageHeight;
+    }
+
+    #endregion
+
     #region Editing Commands
 
+    private void UpdateUndoRedoButtons()
+    {
+        UndoButton.IsEnabled = Objects.Count > 0;
+        RedoButton.IsEnabled = RedoStack.Count > 0;
+    }
+
+    [RelayCommand]
     private void Undo()
     {
+        if (Objects.Count == 0) return;
+
         RedoStack.Push(Objects[^1]);
         Objects.RemoveAt(Objects.Count - 1);
+        UpdateUndoRedoButtons();
+        Changed?.Invoke(this, new EventArgs());
     }
 
+    [RelayCommand]
     private void Redo()
     {
         if (!RedoStack.TryPop(out var top)) return;
 
         Objects.Add(top);
+        UpdateUndoRedoButtons();
+        Changed?.Invoke(this, new EventArgs());
     }
 
     private void AddObject(IImageEditorObject obj)
     {
         Objects.Add(obj);
         RedoStack.Clear();
+        UpdateUndoRedoButtons();
+        Changed?.Invoke(this, new EventArgs());
     }
 
     [RelayCommand]
     private void SetMode(ImageEditingMode mode)
     {
         Mode = mode;
+        ShapeButton.Content = CreateModeButtonContent(mode);
+        SecondaryColour.IsVisible = HasSecondaryColour();
+    }
+
+    private bool HasSecondaryColour()
+    {
+        return Mode == ImageEditingMode.Rectangle || Mode == ImageEditingMode.Ellipse;
+    }
+
+    #endregion
+
+    #region Mode Buttons
+
+    private void AddModeButtons()
+    {
+        AddModeButton(ImageEditingMode.Polyline);
+        AddModeButton(ImageEditingMode.Rectangle);
+        AddModeButton(ImageEditingMode.Ellipse);
+    }
+
+    private void AddModeButton(ImageEditingMode mode)
+    {
+        ModeButtons.Add(new(mode, CreateModeButtonContent(mode)));
+    }
+
+    private Canvas? CreateModeButtonContent(ImageEditingMode mode, bool bindColour = false)
+    {
         switch (mode)
         {
             case ImageEditingMode.Polyline:
@@ -95,18 +327,139 @@ public partial class ImageEditor : UserControl
                 var line2 = new Polyline { Points = points, Width = 25, Height = 25 };
                 line1.StrokeThickness = 4;
                 line1.StrokeLineCap = PenLineCap.Round;
-                line2.StrokeThickness = 1.5;
+                line1.StrokeJoin = PenLineJoin.Round;
                 line1.Stroke = new SolidColorBrush(Colors.Black);
-                line2.Bind(Polyline.StrokeProperty, new Binding(nameof(PrimaryBrush)) { Source = this });
                 canvas.Children.Add(line1);
-                canvas.Children.Add(line2);
-                ShapeButton.Content = canvas;
-                break;
+
+                if (bindColour)
+                {
+                    line1.StrokeThickness = 5;
+                    line2.StrokeThickness = 4;
+                    line2.StrokeLineCap = PenLineCap.Round;
+                    line2.StrokeJoin = PenLineJoin.Round;
+                    line2.Bind(Polyline.StrokeProperty, new Binding(nameof(PrimaryBrush))
+                    {
+                        Source = this,
+                        Converter = ImageEditorRemoveOpacityConverter.Instance
+                    });
+                    canvas.Children.Add(line2);
+                }
+                return canvas;
+            case ImageEditingMode.Rectangle:
+                canvas = new Canvas();
+                canvas.Width = 25;
+                canvas.Height = 25;
+
+                var rectangle = new Rectangle();
+                if (bindColour)
+                {
+                    rectangle.Bind(Rectangle.StrokeProperty, new Binding(nameof(PrimaryBrush))
+                    {
+                        Source = this,
+                        Converter = ImageEditorRemoveOpacityConverter.Instance
+                    });
+                    rectangle.Bind(Rectangle.FillProperty, new Binding(nameof(SecondaryBrush))
+                    {
+                        Source = this,
+                        Converter = ImageEditorTransparentImageBrushConverter.Instance
+                    });
+                }
+                else
+                {
+                    rectangle.Stroke = new SolidColorBrush(Colors.Black);
+                    rectangle.Fill = new SolidColorBrush(Colors.White);
+                }
+                rectangle.StrokeThickness = 1.0;
+                rectangle.Width = 25;
+                rectangle.Height = 25;
+                canvas.Children.Add(rectangle);
+
+                return canvas;
+            case ImageEditingMode.Ellipse:
+                canvas = new Canvas();
+                canvas.Width = 25;
+                canvas.Height = 25;
+
+                var ellipse = new Ellipse();
+                if (bindColour)
+                {
+                    ellipse.Bind(Rectangle.StrokeProperty, new Binding(nameof(PrimaryBrush))
+                    {
+                        Source = this,
+                        Converter = ImageEditorRemoveOpacityConverter.Instance
+                    });
+                    ellipse.Bind(Rectangle.FillProperty, new Binding(nameof(SecondaryBrush))
+                    {
+                        Source = this,
+                        Converter = ImageEditorTransparentImageBrushConverter.Instance
+                    });
+                }
+                else
+                {
+                    ellipse.Stroke = new SolidColorBrush(Colors.Black);
+                    ellipse.Fill = new SolidColorBrush(Colors.White);
+                }
+                ellipse.StrokeThickness = 1.0;
+                ellipse.Width = 25;
+                ellipse.Height = 25;
+                canvas.Children.Add(ellipse);
+
+                return canvas;
+            default:
+                return null;
         }
     }
 
     #endregion
 
+    #region Public Interface
+
+    public void Reset()
+    {
+        Objects.Clear();
+        RedoStack.Clear();
+        UpdateUndoRedoButtons();
+        Changed?.Invoke(this, new EventArgs());
+    }
+
+    public Bitmap GetImage()
+    {
+        var renderBitmap = new RenderTargetBitmap(new PixelSize(ImageWidth, ImageHeight));
+        renderBitmap.Render(Image);
+        using var context = renderBitmap.CreateDrawingContext();
+        if(Source is not null)
+        {
+            context.DrawImage(Source, new(0, 0, ImageWidth, ImageHeight));
+        }
+
+        foreach (var obj in Objects)
+        {
+            var control = obj.GetControl();
+            var left = Canvas.GetLeft(control);
+            var top = Canvas.GetTop(control);
+            if (double.IsNaN(left)) left = 0;
+            if (double.IsNaN(top)) top = 0;
+            using (context.PushTransform(Matrix.CreateTranslation(new(left, top))))
+            {
+                control.Render(context);
+            }
+        }
+        return renderBitmap;
+    }
+
+    public byte[] SaveImage()
+    {
+        var bitmap = GetImage();
+
+        var stream = new MemoryStream();
+        bitmap.Save(stream);
+        return stream.ToArray();
+    }
+
+    #endregion
+
+    #region Editing
+
     private void Objects_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
     {
         Canvas.Children.Clear();
@@ -119,7 +472,7 @@ public partial class ImageEditor : UserControl
 
     Point ConvertToImageCoordinates(Point canvasCoordinates)
     {
-        return canvasCoordinates;
+        return canvasCoordinates;// new(canvasCoordinates.X / ScaleFactor, canvasCoordinates.Y / ScaleFactor);
     }
 
     private void Canvas_PointerPressed(object? sender, PointerPressedEventArgs e)
@@ -136,28 +489,80 @@ public partial class ImageEditor : UserControl
                 };
                 AddObject(CurrentObject);
                 break;
+            case ImageEditingMode.Rectangle:
+                CurrentObject = new RectangleObject
+                {
+                    Point1 = position,
+                    Point2 = position,
+                    PrimaryBrush = PrimaryBrush,
+                    SecondaryBrush = SecondaryBrush,
+                    Thickness = LineThickness
+                };
+                AddObject(CurrentObject);
+                break;
+            case ImageEditingMode.Ellipse:
+                CurrentObject = new EllipseObject
+                {
+                    Point1 = position,
+                    Point2 = position,
+                    PrimaryBrush = PrimaryBrush,
+                    SecondaryBrush = SecondaryBrush,
+                    Thickness = LineThickness
+                };
+                AddObject(CurrentObject);
+                break;
         }
     }
 
     private void Canvas_PointerMoved(object? sender, PointerEventArgs e)
     {
         var position = ConvertToImageCoordinates(e.GetPosition(Canvas));
-        if(CurrentObject is PolylineObject polyline)
+        switch (CurrentObject)
         {
-            polyline.Points.Add(position);
-            polyline.Update();
+            case PolylineObject polyline:
+                polyline.Points.Add(position);
+                polyline.Update();
+                Changed?.Invoke(this, new EventArgs());
+                break;
+            case RectangleObject rectangle:
+                rectangle.Point2 = position;
+                rectangle.Update();
+                Changed?.Invoke(this, new EventArgs());
+                break;
+            case EllipseObject ellipse:
+                ellipse.Point2 = position;
+                ellipse.Update();
+                Changed?.Invoke(this, new EventArgs());
+                break;
         }
     }
 
     private void Canvas_PointerReleased(object? sender, PointerReleasedEventArgs e)
     {
         var position = ConvertToImageCoordinates(e.GetPosition(Canvas));
-        if(CurrentObject is PolylineObject polyline)
+        switch (CurrentObject)
         {
-            polyline.Points.Add(position);
-            polyline.Update();
-            CurrentObject = null;
+            case PolylineObject polyline:
+                polyline.Points.Add(position);
+                polyline.Update();
+                CurrentObject = null;
+                Changed?.Invoke(this, new EventArgs());
+                break;
+            case RectangleObject rectangle:
+                rectangle.Point2 = position;
+                rectangle.Update();
+                CurrentObject = null;
+                Changed?.Invoke(this, new EventArgs());
+                break;
+            case EllipseObject ellipse:
+                ellipse.Point2 = position;
+                ellipse.Update();
+                CurrentObject = null;
+                Changed?.Invoke(this, new EventArgs());
+                break;
         }
 
     }
+
+    #endregion
 }

+ 44 - 0
InABox.Avalonia/Components/ImageEditor/Objects/EllipseObject.cs

@@ -0,0 +1,44 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Media;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace InABox.Avalonia.Components.ImageEditing;
+
+internal class EllipseObject : IImageEditorObject
+{
+    public IBrush? PrimaryBrush { get; set; }
+
+    public IBrush? SecondaryBrush { get; set; }
+
+    public double Thickness { get; set; } = 1.0;
+
+    public Point Point1 { get; set; }
+
+    public Point Point2 { get; set; }
+
+    private Ellipse Control = new();
+
+    public Control GetControl() => Control;
+
+    public void Update()
+    {
+        Control.Stroke = PrimaryBrush;
+        Control.Fill = SecondaryBrush;
+        Control.StrokeThickness = Thickness;
+
+        var topLeft = new Point(
+            Math.Min(Point1.X, Point2.X),
+            Math.Min(Point1.Y, Point2.Y));
+
+        Canvas.SetLeft(Control, topLeft.X);
+        Canvas.SetTop(Control, topLeft.Y);
+        Control.Width = Math.Abs(Point2.X - Point1.X);
+        Control.Height = Math.Abs(Point2.Y - Point1.Y);
+    }
+}

+ 2 - 0
InABox.Avalonia/Components/ImageEditor/Objects/Polyline.cs

@@ -25,5 +25,7 @@ internal class PolylineObject : IImageEditorObject
         Control.Points = Points.ToList();
         Control.Stroke = PrimaryBrush;
         Control.StrokeThickness = Thickness;
+        Control.StrokeLineCap = PenLineCap.Round;
+        Control.StrokeJoin = PenLineJoin.Round;
     }
 }

+ 44 - 0
InABox.Avalonia/Components/ImageEditor/Objects/RectangleObject.cs

@@ -0,0 +1,44 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Media;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace InABox.Avalonia.Components.ImageEditing;
+
+internal class RectangleObject : IImageEditorObject
+{
+    public IBrush? PrimaryBrush { get; set; }
+
+    public IBrush? SecondaryBrush { get; set; }
+
+    public double Thickness { get; set; } = 1.0;
+
+    public Point Point1 { get; set; }
+
+    public Point Point2 { get; set; }
+
+    private Rectangle Control = new();
+
+    public Control GetControl() => Control;
+
+    public void Update()
+    {
+        Control.Stroke = PrimaryBrush;
+        Control.Fill = SecondaryBrush;
+        Control.StrokeThickness = Thickness;
+
+        var topLeft = new Point(
+            Math.Min(Point1.X, Point2.X),
+            Math.Min(Point1.Y, Point2.Y));
+
+        Canvas.SetLeft(Control, topLeft.X);
+        Canvas.SetTop(Control, topLeft.Y);
+        Control.Width = Math.Abs(Point2.X - Point1.X);
+        Control.Height = Math.Abs(Point2.Y - Point1.Y);
+    }
+}

+ 13 - 0
InABox.Avalonia/Components/InkCanvas/InkCanvas.axaml

@@ -0,0 +1,13 @@
+<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"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:Class="InABox.Avalonia.Components.InkCanvas">
+	<Canvas Name="Canvas"
+			Background="Transparent"
+			PointerPressed="Canvas_PointerPressed"
+			PointerMoved="Canvas_PointerMoved"
+			PointerReleased="Canvas_PointerReleased"
+			ClipToBounds="True"/>
+</UserControl>

+ 119 - 0
InABox.Avalonia/Components/InkCanvas/InkCanvas.axaml.cs

@@ -0,0 +1,119 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Input;
+using Avalonia.Markup.Xaml;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using System.Collections.ObjectModel;
+
+namespace InABox.Avalonia.Components;
+
+public partial class InkCanvas : UserControl
+{
+    public static readonly StyledProperty<double> LineThicknessProperty =
+        AvaloniaProperty.Register<InkCanvas, double>(nameof(LineThickness), 3.0);
+
+    public static readonly StyledProperty<Color> LineColourProperty =
+        AvaloniaProperty.Register<InkCanvas, Color>(nameof(LineColour), Colors.Black);
+
+    public double LineThickness
+    {
+        get => GetValue(LineThicknessProperty);
+        set => SetValue(LineThicknessProperty, value);
+    }
+
+    public Color LineColour
+    {
+        get => GetValue(LineColourProperty);
+        set => SetValue(LineColourProperty, value);
+    }
+
+    private ObservableCollection<Polyline> Lines = new();
+
+    private Polyline? CurrentLine;
+
+    public event EventHandler? Changed;
+
+    public InkCanvas()
+    {
+        InitializeComponent();
+
+        Lines.CollectionChanged += Lines_CollectionChanged;
+    }
+
+    public void Clear()
+    {
+        Canvas.Children.Clear();
+        Lines.Clear();
+        Changed?.Invoke(this, EventArgs.Empty);
+    }
+
+    public byte[] SaveImage()
+    {
+        var width = (int)Math.Floor(Canvas.Bounds.Width);
+        var height = (int)Math.Floor(Canvas.Bounds.Height);
+
+        var renderBitmap = new RenderTargetBitmap(new PixelSize(width, height));
+        using var context = renderBitmap.CreateDrawingContext();
+        foreach(var line in Lines)
+        {
+            line.Render(context);
+        }
+
+        using var stream = new MemoryStream();
+        renderBitmap.Save(stream);
+        return stream.ToArray();
+    }
+
+    private void Lines_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
+    {
+        Canvas.Children.Clear();
+        foreach(var line in Lines)
+        {
+            if (!Canvas.Children.Contains(line))
+            {
+                Canvas.Children.Add(line);
+            }
+        }
+    }
+
+    Point ConvertToImageCoordinates(Point canvasCoordinates)
+    {
+        return canvasCoordinates;
+    }
+
+    private void Canvas_PointerPressed(object? sender, PointerPressedEventArgs e)
+    {
+        var position = ConvertToImageCoordinates(e.GetPosition(Canvas));
+        CurrentLine = new Polyline
+        {
+            Points = new ObservableCollection<Point>(new Point[] { position }),
+            StrokeThickness = LineThickness,
+            Stroke = new SolidColorBrush(LineColour),
+            StrokeJoin = PenLineJoin.Round,
+            StrokeLineCap = PenLineCap.Round
+        };
+        Lines.Add(CurrentLine);
+    }
+
+    private void Canvas_PointerMoved(object? sender, PointerEventArgs e)
+    {
+        if(CurrentLine is not null)
+        {
+            var position = ConvertToImageCoordinates(e.GetPosition(Canvas));
+            CurrentLine.Points.Add(position);
+        }
+    }
+
+    private void Canvas_PointerReleased(object? sender, PointerReleasedEventArgs e)
+    {
+        if(CurrentLine is not null)
+        {
+            var position = ConvertToImageCoordinates(e.GetPosition(Canvas));
+            CurrentLine.Points.Add(position);
+            CurrentLine = null;
+            Changed?.Invoke(this, new());
+        }
+    }
+}

+ 7 - 3
InABox.Avalonia/Converters/ByteArrayToImageSourceConverter.cs

@@ -13,8 +13,8 @@ public class ByteArrayToImageSourceConverter : AbstractConverter<byte[]?, IImage
     public static ByteArrayToImageSourceConverter Instance { get; } = new();
     
     public bool Transparent { get; set; } = false;
-    
-    protected override IImage? Convert(byte[]? value, object? parameter = null)
+
+    public IImage? Convert(byte[]? value)
     {
         Bitmap? result = null;
         if (value?.Any() == true)
@@ -35,7 +35,11 @@ public class ByteArrayToImageSourceConverter : AbstractConverter<byte[]?, IImage
                 result = new Bitmap(stream);
             }
         }
-
         return result;
     }
+    
+    protected override IImage? Convert(byte[]? value, object? parameter = null)
+    {
+        return Convert(value);
+    }
 }

+ 4 - 4
InABox.Avalonia/DataModels/ShellColumns.cs

@@ -9,19 +9,19 @@ namespace InABox.Avalonia
     public class ShellColumns<TParent,TEntity> : IShellColumns<TEntity> where TEntity : Entity
     {
         
-        private static Dictionary<string, Tuple<int,Expression<Func<TEntity, object>>>> _columns = new Dictionary<string, Tuple<int,Expression<Func<TEntity, object>>>> ();
+        private static Dictionary<string, Tuple<int,Expression<Func<TEntity, object?>>>> _columns = new Dictionary<string, Tuple<int,Expression<Func<TEntity, object?>>>> ();
 
         public int IndexOf(string name) => _columns[name].Item1;
         
-        public Expression<Func<TEntity, object>> this[string name] => _columns[name].Item2;
+        public Expression<Func<TEntity, object?>> this[string name] => _columns[name].Item2;
 
-        public ShellColumns<TParent,TEntity> Map(string property, Expression<Func<TEntity, object>> expression)
+        public ShellColumns<TParent,TEntity> Map(string property, Expression<Func<TEntity, object?>> expression)
         {
             int iCol = _columns.TryGetValue(property, out var column)
                 ? column.Item1
                 : _columns.Keys.Count;
             
-            _columns[property] = new Tuple<int, Expression<Func<TEntity, object>>>(iCol, expression);
+            _columns[property] = new Tuple<int, Expression<Func<TEntity, object?>>>(iCol, expression);
             //_columns[property] = new Tuple<int, Expression<Func<TEntity, object>>>(_columns.Keys.Count, expression);
             return this;
         }

+ 1 - 0
InABox.Avalonia/Theme/Classes/TabItem.axaml

@@ -68,6 +68,7 @@
 	
 	<Style Selector="TabControl.Standard TabItem:selected">
 		<Setter Property="Background" Value="{DynamicResource PrsTileBackground}"/>
+		<Setter Property="TextElement.Foreground" Value="Black"/>
 	</Style>
 	
 	<Style Selector="TabControl.Standard TabItem:selected:pointerover /template/ Border#PART_LayoutRoot">

+ 2 - 1
InABox.Avalonia/Theme/Classes/TabStrip.axaml

@@ -33,6 +33,7 @@
 	
 	<Style Selector="TabStrip.Standard TabStripItem:selected">
 		<Setter Property="Background" Value="{DynamicResource PrsTileBackground}"/>
+		<Setter Property="TextElement.Foreground" Value="Black"/>
 	</Style>
 	<Style Selector="TabStrip.Standard TabStripItem:selected:pointerover /template/ Border#PART_LayoutRoot">
 		<Setter Property="Background" Value="{DynamicResource PrsTileBackground}"/>
@@ -61,7 +62,7 @@
 	
 	<Style Selector="TabStrip.ButtonsList TabStripItem:selected">
 		<Setter Property="Background" Value="{DynamicResource PrsTileBackground}"/>
-		<Setter Property="Foreground" Value="{DynamicResource PrsTileForeground}"/>
+		<Setter Property="TextElement.Foreground" Value="{DynamicResource PrsTileForeground}"/>
 	</Style>
 	<Style Selector="TabStrip.ButtonsList TabStripItem:selected:pointerover /template/ Border#PART_LayoutRoot">
 		<Setter Property="Background" Value="{DynamicResource PrsTileBackground}"/>