Meta - Replace WPF projects with Avalonia projects

This commit is contained in:
Robert committed 2022-03-27 11:55:26 +02:00
1 parent fcf0376a9a
commit d7f3ee1190
1110 files changed
+10872 -52240

No files matched your search

@@ -0,0 +1,83 @@
<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:visualScripting="clr-namespace:Artemis.UI.Screens.VisualScripting"
xmlns:converters="clr-namespace:Artemis.UI.Converters"
xmlns:skiaSharp="clr-namespace:SkiaSharp;assembly=SkiaSharp"
xmlns:shared="clr-namespace:Artemis.UI.Shared.Converters;assembly=Artemis.UI.Shared"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.VisualScripting.CableView"
x:DataType="visualScripting:CableViewModel"
ClipToBounds="False"
IsVisible="{CompiledBinding Connected}">
<UserControl.Resources>
<converters:ColorToSolidColorBrushConverter x:Key="ColorToSolidColorBrushConverter" />
<shared:SKColorToStringConverter x:Key="SKColorToStringConverter" />
<shared:SKColorToColorConverter x:Key="SKColorToColorConverter" />
</UserControl.Resources>
<Canvas>
<Path Name="CablePath"
Stroke="{CompiledBinding CableColor, Converter={StaticResource ColorToSolidColorBrushConverter}}"
StrokeThickness="4"
StrokeLineCap="Round">
<Path.Transitions>
<Transitions>
<ThicknessTransition Property="Margin" Duration="200"></ThicknessTransition>
</Transitions>
</Path.Transitions>
<Path.Data>
<PathGeometry>
<PathGeometry.Figures>
<PathFigure IsClosed="False">
<PathFigure.Segments>
<BezierSegment />
</PathFigure.Segments>
</PathFigure>
</PathGeometry.Figures>
</PathGeometry>
</Path.Data>
</Path>
<Border Name="ValueBorder"
Background="{DynamicResource ContentDialogBackground}"
BorderBrush="{CompiledBinding CableColor, Converter={StaticResource ColorToSolidColorBrushConverter}}"
BorderThickness="2"
CornerRadius="3"
Padding="4"
Canvas.Left="{CompiledBinding ValuePoint.X}"
Canvas.Top="{CompiledBinding ValuePoint.Y}"
IsVisible="{CompiledBinding FromViewModel.Pin.PinValue, Converter={x:Static ObjectConverters.IsNotNull}}">
<ContentControl Content="{CompiledBinding FromViewModel.Pin.PinValue}">
<ContentControl.DataTemplates>
<DataTemplate DataType="skiaSharp:SKColor">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<TextBlock x:Name="HexDisplay"
Text="{CompiledBinding Converter={StaticResource SKColorToStringConverter}}"
VerticalAlignment="Center"
HorizontalAlignment="Stretch" />
<Border Margin="5 0 0 0"
VerticalAlignment="Center"
HorizontalAlignment="Right"
BorderThickness="1"
MinWidth="18"
MinHeight="18"
Background="{DynamicResource CheckerboardBrush}"
BorderBrush="{DynamicResource ColorPickerButtonOutline}"
CornerRadius="2">
<Ellipse Stroke="{DynamicResource NormalBorderBrush}">
<Ellipse.Fill>
<SolidColorBrush Color="{Binding Converter={StaticResource SKColorToColorConverter}}" />
</Ellipse.Fill>
</Ellipse>
</Border>
</StackPanel>
</DataTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" />
</DataTemplate>
</ContentControl.DataTemplates>
</ContentControl>
</Border>
</Canvas>
</UserControl>
@@ -0,0 +1,54 @@
using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Mixins;
using Avalonia.Controls.Shapes;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.ReactiveUI;
using ReactiveUI;
namespace Artemis.UI.Screens.VisualScripting;
public class CableView : ReactiveUserControl<CableViewModel>
{
private const double CABLE_OFFSET = 24 * 4;
private readonly Path _cablePath;
private readonly Border _valueBorder;
public CableView()
{
InitializeComponent();
_cablePath = this.Get<Path>("CablePath");
_valueBorder = this.Get<Border>("ValueBorder");
// Not using bindings here to avoid a warnings
this.WhenActivated(d =>
{
_valueBorder.GetObservable(BoundsProperty).Subscribe(rect => _valueBorder.RenderTransform = new TranslateTransform(rect.Width / 2 * -1, rect.Height / 2 * -1)).DisposeWith(d);
ViewModel.WhenAnyValue(vm => vm.FromPoint).Subscribe(_ => Update()).DisposeWith(d);
ViewModel.WhenAnyValue(vm => vm.ToPoint).Subscribe(_ => Update()).DisposeWith(d);
Update();
});
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void Update()
{
// Workaround for https://github.com/AvaloniaUI/Avalonia/issues/4748
_cablePath.Margin = _cablePath.Margin != new Thickness(0, 0, 0, 0) ? new Thickness(0, 0, 0, 0) : new Thickness(1, 1, 0, 0);
PathFigure pathFigure = ((PathGeometry) _cablePath.Data).Figures.First();
BezierSegment segment = (BezierSegment) pathFigure.Segments!.First();
pathFigure.StartPoint = ViewModel!.FromPoint;
segment.Point1 = new Point(ViewModel.FromPoint.X + CABLE_OFFSET, ViewModel.FromPoint.Y);
segment.Point2 = new Point(ViewModel.ToPoint.X - CABLE_OFFSET, ViewModel.ToPoint.Y);
segment.Point3 = new Point(ViewModel.ToPoint.X, ViewModel.ToPoint.Y);
}
}
@@ -0,0 +1,84 @@
using System;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.UI.Exceptions;
using Artemis.UI.Screens.VisualScripting.Pins;
using Artemis.UI.Shared;
using Avalonia;
using Avalonia.Media;
using DynamicData;
using DynamicData.Binding;
using ReactiveUI;
namespace Artemis.UI.Screens.VisualScripting;
public class CableViewModel : ActivatableViewModelBase
{
private readonly ObservableAsPropertyHelper<bool> _connected;
private readonly ObservableAsPropertyHelper<Point> _fromPoint;
private readonly ObservableAsPropertyHelper<Point> _toPoint;
private readonly ObservableAsPropertyHelper<Point> _valuePoint;
private ObservableAsPropertyHelper<Color>? _cableColor;
private PinViewModel? _fromViewModel;
private PinViewModel? _toViewModel;
public CableViewModel(NodeScriptViewModel nodeScriptViewModel, IPin from, IPin to)
{
if (from.Direction != PinDirection.Output)
throw new ArtemisUIException("Can only create cables originating from an output pin");
if (to.Direction != PinDirection.Input)
throw new ArtemisUIException("Can only create cables targeted to an input pin");
this.WhenActivated(d =>
{
nodeScriptViewModel.PinViewModels.ToObservableChangeSet().Filter(p => ReferenceEquals(p.Pin, from)).Transform(model => FromViewModel = model).Subscribe().DisposeWith(d);
nodeScriptViewModel.PinViewModels.ToObservableChangeSet().Filter(p => ReferenceEquals(p.Pin, to)).Transform(model => ToViewModel = model).Subscribe().DisposeWith(d);
_cableColor = this.WhenAnyValue(vm => vm.FromViewModel, vm => vm.ToViewModel)
.Select(tuple => tuple.Item1?.WhenAnyValue(p => p.PinColor) ?? tuple.Item2?.WhenAnyValue(p => p.PinColor) ?? Observable.Never<Color>())
.Switch()
.ToProperty(this, vm => vm.CableColor)
.DisposeWith(d);
});
_fromPoint = this.WhenAnyValue(vm => vm.FromViewModel)
.Select(p => p != null ? p.WhenAnyValue(pvm => pvm.Position) : Observable.Never<Point>())
.Switch()
.ToProperty(this, vm => vm.FromPoint);
_toPoint = this.WhenAnyValue(vm => vm.ToViewModel)
.Select(p => p != null ? p.WhenAnyValue(pvm => pvm.Position) : Observable.Never<Point>())
.Switch()
.ToProperty(this, vm => vm.ToPoint);
_valuePoint = this.WhenAnyValue(vm => vm.FromPoint, vm => vm.ToPoint).Select(tuple => new Point(
tuple.Item1.X + (tuple.Item2.X - tuple.Item1.X) / 2,
tuple.Item1.Y + (tuple.Item2.Y - tuple.Item1.Y) / 2
)).ToProperty(this, vm => vm.ValuePoint);
// Not a perfect solution but this makes sure the cable never renders at 0,0 (can happen when the cable spawns before the pin ever rendered)
_connected = this.WhenAnyValue(vm => vm.FromPoint, vm => vm.ToPoint)
.Select(tuple => tuple.Item1 != new Point(0, 0) && tuple.Item2 != new Point(0, 0))
.ToProperty(this, vm => vm.Connected);
}
public PinViewModel? FromViewModel
{
get => _fromViewModel;
set => RaiseAndSetIfChanged(ref _fromViewModel, value);
}
public PinViewModel? ToViewModel
{
get => _toViewModel;
set => RaiseAndSetIfChanged(ref _toViewModel, value);
}
public bool Connected => _connected.Value;
public Point FromPoint => _fromPoint.Value;
public Point ToPoint => _toPoint.Value;
public Point ValuePoint => _valuePoint.Value;
public Color CableColor => _cableColor?.Value ?? new Color(255, 255, 255, 255);
}
@@ -0,0 +1,30 @@
<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:visualScripting="clr-namespace:Artemis.UI.Screens.VisualScripting"
xmlns:converters="clr-namespace:Artemis.UI.Converters"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.VisualScripting.DragCableView"
x:DataType="visualScripting:DragCableViewModel"
ClipToBounds="False">
<UserControl.Resources>
<converters:ColorToSolidColorBrushConverter x:Key="ColorToSolidColorBrushConverter" />
</UserControl.Resources>
<Path Name="CablePath"
Stroke="{CompiledBinding PinViewModel.PinColor, Converter={StaticResource ColorToSolidColorBrushConverter}}"
StrokeThickness="4"
StrokeLineCap="Round">
<Path.Data>
<PathGeometry>
<PathGeometry.Figures>
<PathFigure IsClosed="False">
<PathFigure.Segments>
<BezierSegment />
</PathFigure.Segments>
</PathFigure>
</PathGeometry.Figures>
</PathGeometry>
</Path.Data>
</Path>
</UserControl>
@@ -0,0 +1,47 @@
using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Mixins;
using Avalonia.Controls.Shapes;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.ReactiveUI;
using ReactiveUI;
namespace Artemis.UI.Screens.VisualScripting;
public class DragCableView : ReactiveUserControl<DragCableViewModel>
{
private const double CABLE_OFFSET = 24 * 4;
private readonly Path _cablePath;
public DragCableView()
{
InitializeComponent();
_cablePath = this.Get<Path>("CablePath");
// Not using bindings here to avoid warnings
this.WhenActivated(d =>
{
ViewModel.WhenAnyValue(vm => vm.FromPoint).Subscribe(_ => Update()).DisposeWith(d);
ViewModel.WhenAnyValue(vm => vm.ToPoint).Subscribe(_ => Update()).DisposeWith(d);
Update();
});
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void Update()
{
PathFigure pathFigure = ((PathGeometry) _cablePath.Data).Figures.First();
BezierSegment segment = (BezierSegment) pathFigure.Segments!.First();
pathFigure.StartPoint = ViewModel!.FromPoint;
segment.Point1 = new Point(ViewModel.FromPoint.X + CABLE_OFFSET, ViewModel.FromPoint.Y);
segment.Point2 = new Point(ViewModel.ToPoint.X - CABLE_OFFSET, ViewModel.ToPoint.Y);
segment.Point3 = new Point(ViewModel.ToPoint.X, ViewModel.ToPoint.Y);
}
}
@@ -0,0 +1,51 @@
using System.Reactive.Disposables;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.UI.Screens.VisualScripting.Pins;
using Artemis.UI.Shared;
using Avalonia;
using ReactiveUI;
namespace Artemis.UI.Screens.VisualScripting;
public class DragCableViewModel : ActivatableViewModelBase
{
private Point _dragPoint;
private ObservableAsPropertyHelper<Point>? _fromPoint;
private ObservableAsPropertyHelper<Point>? _toPoint;
public DragCableViewModel(PinViewModel pinViewModel)
{
PinViewModel = pinViewModel;
// If the pin is output, the pin is the from-point and the drag position is the to-point
if (PinViewModel.Pin.Direction == PinDirection.Output)
{
this.WhenActivated(d =>
{
_fromPoint = PinViewModel.WhenAnyValue(vm => vm.Position).ToProperty(this, vm => vm.FromPoint).DisposeWith(d);
});
_toPoint = this.WhenAnyValue(vm => vm.DragPoint).ToProperty(this, vm => vm.ToPoint);
}
// If the pin is input, the pin is the to-point and the drag position is the from-point;
else
{
this.WhenActivated(d =>
{
_toPoint = PinViewModel.WhenAnyValue(vm => vm.Position).ToProperty(this, vm => vm.ToPoint).DisposeWith(d);
});
_fromPoint = this.WhenAnyValue(vm => vm.DragPoint).ToProperty(this, vm => vm.FromPoint);
}
}
public PinViewModel PinViewModel { get; }
public Point FromPoint => _fromPoint?.Value ?? new Point(0, 0);
public Point ToPoint => _toPoint?.Value ?? new Point(0, 0);
public Point DragPoint
{
get => _dragPoint;
set => RaiseAndSetIfChanged(ref _dragPoint, value);
}
}
@@ -0,0 +1,65 @@
<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:visualScripting="clr-namespace:Artemis.UI.Screens.VisualScripting"
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:core="clr-namespace:Artemis.Core;assembly=Artemis.Core"
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
mc:Ignorable="d" d:DesignWidth="650" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.VisualScripting.NodePickerView"
x:DataType="visualScripting:NodePickerViewModel"
Width="600"
Height="400">
<UserControl.Styles>
<Style Selector="TextBox#SearchBox">
<Setter Property="VerticalAlignment" Value="Top"></Setter>
<Setter Property="InnerRightContent">
<Template>
<StackPanel Orientation="Horizontal">
<controls:Button Content="&#xE8BB;"
FontFamily="{StaticResource SymbolThemeFontFamily}"
Classes="AppBarButton"
Command="{Binding $parent[TextBox].Clear}"
IsVisible="{Binding Text, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type TextBox}}, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<controls:Button Content="&#xE721;"
FontFamily="{StaticResource SymbolThemeFontFamily}"
Classes="AppBarButton"
IsHitTestVisible="False" />
</StackPanel>
</Template>
</Setter>
</Style>
<Style Selector="TreeView#NodeTree">
</Style>
</UserControl.Styles>
<Border Classes="picker-container">
<Grid RowDefinitions="Auto,*">
<TextBox Name="SearchBox" Text="{CompiledBinding SearchText}" Margin="0 0 0 15" Watermark="Search"></TextBox>
<TreeView Name="NodeTree" Grid.Row="1" Items="{CompiledBinding Categories}" IsVisible="{CompiledBinding Categories.Count}" SelectedItem="{CompiledBinding SelectedNode}">
<TreeView.Styles>
<Style Selector="TreeViewItem">
<Setter Property="IsExpanded" Value="True" />
</Style>
</TreeView.Styles>
<TreeView.DataTemplates>
<TreeDataTemplate DataType="{x:Type core:NodeData}">
<StackPanel Margin="-15 1 0 1">
<TextBlock Classes="BodyStrongTextBlockStyle" Text="{Binding Name}"></TextBlock>
<TextBlock Foreground="{DynamicResource TextFillColorSecondary}" Text="{Binding Description}"></TextBlock>
</StackPanel>
</TreeDataTemplate>
<TreeDataTemplate ItemsSource="{Binding Items}">
<TextBlock Text="{Binding Key}"></TextBlock>
</TreeDataTemplate>
</TreeView.DataTemplates>
</TreeView>
<StackPanel Grid.Row="1" VerticalAlignment="Center" Spacing="20" IsVisible="{CompiledBinding !Categories.Count}">
<avalonia:MaterialIcon Kind="CloseCircle" Width="64" Height="64"></avalonia:MaterialIcon>
<TextBlock Classes="h4" TextAlignment="Center">None of the nodes match your search</TextBlock>
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,28 @@
using System;
using System.Reactive.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Mixins;
using Avalonia.LogicalTree;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
using ReactiveUI;
namespace Artemis.UI.Screens.VisualScripting;
public class NodePickerView : ReactiveUserControl<NodePickerViewModel>
{
public NodePickerView()
{
InitializeComponent();
this.WhenActivated(d =>
{
ViewModel?.WhenAnyValue(vm => vm.IsVisible).Where(visible => !visible).Subscribe(_ => this.FindLogicalAncestorOfType<Grid>()?.ContextFlyout?.Hide()).DisposeWith(d);
this.Get<TextBox>("SearchBox").SelectAll();
});
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
@@ -0,0 +1,140 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.NodeEditor;
using Artemis.UI.Shared.Services.NodeEditor.Commands;
using Avalonia;
using DynamicData;
using ReactiveUI;
namespace Artemis.UI.Screens.VisualScripting;
public class NodePickerViewModel : ActivatableViewModelBase
{
private readonly INodeEditorService _nodeEditorService;
private readonly NodeScript _nodeScript;
private bool _isVisible;
private Point _position;
private DateTime _closed;
private string? _searchText;
private object? _selectedNode;
private IPin? _targetPin;
public NodePickerViewModel(NodeScript nodeScript, INodeService nodeService, INodeEditorService nodeEditorService)
{
_nodeScript = nodeScript;
_nodeEditorService = nodeEditorService;
SourceList<NodeData> nodeSourceList = new();
IObservable<Func<NodeData, bool>> nodeFilter = this.WhenAnyValue(vm => vm.SearchText, vm => vm.TargetPin).Select(v => CreatePredicate(v.Item1, v.Item2));
nodeSourceList.Connect()
.Filter(nodeFilter)
.GroupWithImmutableState(n => n.Category)
.Bind(out ReadOnlyObservableCollection<DynamicData.List.IGrouping<NodeData, string>> categories)
.Subscribe();
Categories = categories;
this.WhenActivated(d =>
{
if (DateTime.Now - _closed > TimeSpan.FromSeconds(10))
SearchText = null;
TargetPin = null;
nodeSourceList.Edit(list =>
{
list.Clear();
list.AddRange(nodeService.AvailableNodes);
});
IsVisible = true;
Disposable.Create(() =>
{
_closed = DateTime.Now;
IsVisible = false;
}).DisposeWith(d);
});
this.WhenAnyValue(vm => vm.SelectedNode)
.WhereNotNull()
.Where(o => o is NodeData)
.Throttle(TimeSpan.FromMilliseconds(200), RxApp.MainThreadScheduler)
.Subscribe(data =>
{
CreateNode((NodeData) data);
IsVisible = false;
SelectedNode = null;
});
}
public ReadOnlyObservableCollection<DynamicData.List.IGrouping<NodeData, string>> Categories { get; }
public bool IsVisible
{
get => _isVisible;
set => RaiseAndSetIfChanged(ref _isVisible, value);
}
public Point Position
{
get => _position;
set => RaiseAndSetIfChanged(ref _position, value);
}
public string? SearchText
{
get => _searchText;
set => RaiseAndSetIfChanged(ref _searchText, value);
}
public IPin? TargetPin
{
get => _targetPin;
set => RaiseAndSetIfChanged(ref _targetPin, value);
}
public object? SelectedNode
{
get => _selectedNode;
set => RaiseAndSetIfChanged(ref _selectedNode, value);
}
public void CreateNode(NodeData data)
{
INode node = data.CreateNode(_nodeScript, null);
node.X = Math.Round(Position.X / 10d, 0, MidpointRounding.AwayFromZero) * 10d;
node.Y = Math.Round(Position.Y / 10d, 0, MidpointRounding.AwayFromZero) * 10d;
if (TargetPin != null)
{
using (_nodeEditorService.CreateCommandScope(_nodeScript, "Create node for pin"))
{
_nodeEditorService.ExecuteCommand(_nodeScript, new AddNode(_nodeScript, node));
// Find the first compatible source pin for the target pin
IPin? source = TargetPin.Direction == PinDirection.Output
? node.Pins.Concat(node.PinCollections.SelectMany(c => c)).Where(p => p.Direction == PinDirection.Input).FirstOrDefault(p => TargetPin.IsTypeCompatible(p.Type))
: node.Pins.Concat(node.PinCollections.SelectMany(c => c)).Where(p => p.Direction == PinDirection.Output).FirstOrDefault(p => TargetPin.IsTypeCompatible(p.Type));
if (source != null)
_nodeEditorService.ExecuteCommand(_nodeScript, new ConnectPins(source, TargetPin));
}
}
else
_nodeEditorService.ExecuteCommand(_nodeScript, new AddNode(_nodeScript, node));
}
private Func<NodeData, bool> CreatePredicate(string? text, IPin? targetPin)
{
if (string.IsNullOrWhiteSpace(text))
return data => data.IsCompatibleWithPin(targetPin);
return data => data.IsCompatibleWithPin(targetPin) && data.MatchesSearch(text);
}
}
@@ -0,0 +1,90 @@
<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:paz="clr-namespace:Avalonia.Controls.PanAndZoom;assembly=Avalonia.Controls.PanAndZoom"
xmlns:visualScripting="clr-namespace:Artemis.UI.Screens.VisualScripting"
xmlns:controls="clr-namespace:Artemis.UI.Shared.Controls;assembly=Artemis.UI.Shared"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.VisualScripting.NodeScriptView"
x:DataType="visualScripting:NodeScriptViewModel">
<UserControl.Styles>
<Style Selector="FlyoutPresenter.node-picker-flyout">
<Setter Property="MaxWidth" Value="1000"></Setter>
</Style>
</UserControl.Styles>
<UserControl.KeyBindings>
<KeyBinding Command="{CompiledBinding History.Undo}" Gesture="Ctrl+Z"></KeyBinding>
<KeyBinding Command="{CompiledBinding History.Redo}" Gesture="Ctrl+Y"></KeyBinding>
<KeyBinding Command="{Binding DeleteSelectedNodes}" Gesture="Delete"></KeyBinding>
</UserControl.KeyBindings>
<paz:ZoomBorder Name="ZoomBorder"
Stretch="None"
Focusable="True"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
Background="{DynamicResource LargeCheckerboardBrush}"
ZoomChanged="ZoomBorder_OnZoomChanged"
MaxZoomX="1"
MaxZoomY="1"
EnableConstrains="True"
PointerReleased="ZoomBorder_OnPointerReleased">
<Grid Name="ContainerGrid" Background="Transparent" ClipToBounds="False">
<Grid.ContextFlyout>
<Flyout FlyoutPresenterClasses="node-picker-flyout">
<ContentControl Content="{CompiledBinding NodePickerViewModel}" />
</Flyout>
</Grid.ContextFlyout>
<Grid.Transitions>
<Transitions>
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.2" Easing="CubicEaseOut" />
</Transitions>
</Grid.Transitions>
<!-- Drag cable, if any -->
<ContentControl Content="{CompiledBinding DragViewModel}" ClipToBounds="False"/>
<!-- Cables -->
<ItemsControl Items="{CompiledBinding CableViewModels}" ClipToBounds="False">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.Styles>
<Style Selector="ItemsControl > ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding Node.X, TargetNullValue=0}" />
<Setter Property="Canvas.Top" Value="{Binding Node.Y, TargetNullValue=0}" />
</Style>
</ItemsControl.Styles>
</ItemsControl>
<!-- Nodes -->
<ItemsControl Name="NodesContainer" Items="{CompiledBinding NodeViewModels}" ClipToBounds="False">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.Styles>
<Style Selector="ItemsControl > ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding Node.X, TargetNullValue=0}" />
<Setter Property="Canvas.Top" Value="{Binding Node.Y, TargetNullValue=0}" />
</Style>
</ItemsControl.Styles>
</ItemsControl>
<controls:SelectionRectangle Name="SelectionRectangle"
InputElement="{Binding #ZoomBorder}"
SelectionUpdated="SelectionRectangle_OnSelectionUpdated"
SelectionFinished="SelectionRectangle_OnSelectionFinished"
BorderBrush="{DynamicResource SystemAccentColor}"
BorderRadius="8">
<controls:SelectionRectangle.Background>
<SolidColorBrush Color="{DynamicResource SystemAccentColorLight1}" Opacity="0.2"></SolidColorBrush>
</controls:SelectionRectangle.Background>
</controls:SelectionRectangle>
</Grid>
</paz:ZoomBorder>
</UserControl>
@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Artemis.UI.Shared.Controls;
using Artemis.UI.Shared.Events;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Generators;
using Avalonia.Controls.PanAndZoom;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.ReactiveUI;
using ReactiveUI;
namespace Artemis.UI.Screens.VisualScripting;
public class NodeScriptView : ReactiveUserControl<NodeScriptViewModel>
{
private readonly Grid _grid;
private readonly ItemsControl _nodesContainer;
private readonly SelectionRectangle _selectionRectangle;
private readonly ZoomBorder _zoomBorder;
public NodeScriptView()
{
InitializeComponent();
_grid = this.Find<Grid>("ContainerGrid");
_zoomBorder = this.Find<ZoomBorder>("ZoomBorder");
_nodesContainer = this.Find<ItemsControl>("NodesContainer");
_selectionRectangle = this.Find<SelectionRectangle>("SelectionRectangle");
_zoomBorder.PropertyChanged += ZoomBorderOnPropertyChanged;
UpdateZoomBorderBackground();
_grid.AddHandler(PointerReleasedEvent, CanvasOnPointerReleased, RoutingStrategies.Direct | RoutingStrategies.Tunnel | RoutingStrategies.Bubble, true);
this.WhenActivated(_ => ViewModel?.PickerPositionSubject.Subscribe(p =>
{
ViewModel.NodePickerViewModel.Position = p;
_grid?.ContextFlyout?.ShowAt(_grid, true);
}));
}
private void CanvasOnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
// If the flyout handled the click, update the position of the node picker
if (e.Handled && ViewModel != null)
ViewModel.NodePickerViewModel.Position = e.GetPosition(_grid);
}
private void ZoomBorderOnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property.Name == nameof(_zoomBorder.Background))
UpdateZoomBorderBackground();
}
private void UpdateZoomBorderBackground()
{
if (_zoomBorder.Background is VisualBrush visualBrush)
visualBrush.DestinationRect = new RelativeRect(_zoomBorder.OffsetX * -1, _zoomBorder.OffsetY * -1, 20, 20, RelativeUnit.Absolute);
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void ZoomBorder_OnZoomChanged(object sender, ZoomChangedEventArgs e)
{
UpdateZoomBorderBackground();
}
private void SelectionRectangle_OnSelectionUpdated(object? sender, SelectionRectangleEventArgs e)
{
List<ItemContainerInfo> itemContainerInfos = _nodesContainer.ItemContainerGenerator.Containers.Where(c => c.ContainerControl.Bounds.Intersects(e.Rectangle)).ToList();
List<NodeViewModel> nodes = itemContainerInfos.Where(c => c.Item is NodeViewModel).Select(c => (NodeViewModel) c.Item).ToList();
ViewModel?.UpdateNodeSelection(nodes, e.KeyModifiers.HasFlag(KeyModifiers.Shift), e.KeyModifiers.HasFlag(KeyModifiers.Control));
}
private void SelectionRectangle_OnSelectionFinished(object? sender, SelectionRectangleEventArgs e)
{
ViewModel?.FinishNodeSelection();
}
private void ZoomBorder_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (!_selectionRectangle.IsSelecting)
ViewModel?.ClearNodeSelection();
}
}
@@ -0,0 +1,231 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using Artemis.Core;
using Artemis.Core.Events;
using Artemis.Core.Services;
using Artemis.UI.Ninject.Factories;
using Artemis.UI.Screens.VisualScripting.Pins;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.Interfaces;
using Artemis.UI.Shared.Services.NodeEditor;
using Artemis.UI.Shared.Services.NodeEditor.Commands;
using Avalonia;
using Avalonia.Controls.Mixins;
using DynamicData;
using DynamicData.Binding;
using ReactiveUI;
namespace Artemis.UI.Screens.VisualScripting;
public class NodeScriptViewModel : ActivatableViewModelBase
{
private readonly INodeEditorService _nodeEditorService;
private readonly INotificationService _notificationService;
private readonly INodeVmFactory _nodeVmFactory;
private readonly INodeService _nodeService;
private readonly SourceList<NodeViewModel> _nodeViewModels;
private readonly Subject<Point> _requestedPickerPositionSubject;
private DragCableViewModel? _dragViewModel;
private List<NodeViewModel>? _initialNodeSelection;
public NodeScriptViewModel(NodeScript nodeScript, INodeVmFactory nodeVmFactory, INodeService nodeService, INodeEditorService nodeEditorService, INotificationService notificationService)
{
_nodeVmFactory = nodeVmFactory;
_nodeService = nodeService;
_nodeEditorService = nodeEditorService;
_notificationService = notificationService;
_nodeViewModels = new SourceList<NodeViewModel>();
_requestedPickerPositionSubject = new Subject<Point>();
NodeScript = nodeScript;
NodePickerViewModel = _nodeVmFactory.NodePickerViewModel(nodeScript);
History = nodeEditorService.GetHistory(NodeScript);
PickerPositionSubject = _requestedPickerPositionSubject.AsObservable();
this.WhenActivated(d =>
{
Observable.FromEventPattern<SingleValueEventArgs<INode>>(x => NodeScript.NodeAdded += x, x => NodeScript.NodeAdded -= x)
.Subscribe(e => HandleNodeAdded(e.EventArgs))
.DisposeWith(d);
Observable.FromEventPattern<SingleValueEventArgs<INode>>(x => NodeScript.NodeRemoved += x, x => NodeScript.NodeRemoved -= x)
.Subscribe(e => HandleNodeRemoved(e.EventArgs))
.DisposeWith(d);
nodeEditorService.GetHistory(NodeScript).Undo
.Subscribe(c => _notificationService.CreateNotification().WithMessage(c != null ? $"Undid {c.DisplayName}" : "Nothing to undo").Show())
.DisposeWith(d);
nodeEditorService.GetHistory(NodeScript).Redo
.Subscribe(c => _notificationService.CreateNotification().WithMessage(c != null ? $"Redid {c.DisplayName}" : "Nothing to redo").Show())
.DisposeWith(d);
});
// Create VMs for all nodes
_nodeViewModels.Connect().Bind(out ReadOnlyObservableCollection<NodeViewModel> nodeViewModels).Subscribe();
_nodeViewModels.Edit(l =>
{
foreach (INode nodeScriptNode in NodeScript.Nodes)
l.Add(_nodeVmFactory.NodeViewModel(this, nodeScriptNode));
});
NodeViewModels = nodeViewModels;
NodeViewModels.ToObservableChangeSet().TransformMany(vm => vm.PinViewModels)
.Bind(out ReadOnlyObservableCollection<PinViewModel> pinViewModels)
.Subscribe();
PinViewModels = pinViewModels;
// Observe all outgoing pin connections and create cables for them
PinViewModels.ToObservableChangeSet()
.Filter(p => p.Pin.Direction == PinDirection.Output)
.TransformMany(p => p.Connections)
.Filter(p => p.ConnectedTo.Any())
.Transform(pin => _nodeVmFactory.CableViewModel(this, pin.ConnectedTo.First(), pin))
.Bind(out ReadOnlyObservableCollection<CableViewModel> cableViewModels)
.Subscribe();
CableViewModels = cableViewModels;
}
public NodeScript NodeScript { get; }
public ReadOnlyObservableCollection<NodeViewModel> NodeViewModels { get; }
public ReadOnlyObservableCollection<PinViewModel> PinViewModels { get; }
public ReadOnlyObservableCollection<CableViewModel> CableViewModels { get; }
public NodePickerViewModel NodePickerViewModel { get; }
public NodeEditorHistory History { get; }
public IObservable<Point> PickerPositionSubject { get; }
public DragCableViewModel? DragViewModel
{
get => _dragViewModel;
set => RaiseAndSetIfChanged(ref _dragViewModel, value);
}
public void DeleteSelectedNodes()
{
List<NodeViewModel> toRemove = NodeViewModels.Where(vm => vm.IsSelected && !vm.Node.IsDefaultNode && !vm.Node.IsExitNode).ToList();
if (!toRemove.Any())
return;
using (_nodeEditorService.CreateCommandScope(NodeScript, "Delete nodes"))
{
foreach (NodeViewModel node in toRemove)
_nodeEditorService.ExecuteCommand(NodeScript, new DeleteNode(NodeScript, node.Node));
}
}
public void UpdateNodeSelection(List<NodeViewModel> nodes, bool expand, bool invert)
{
_initialNodeSelection ??= NodeViewModels.Where(vm => vm.IsSelected).ToList();
if (expand)
{
foreach (NodeViewModel nodeViewModel in nodes)
nodeViewModel.IsSelected = true;
}
else if (invert)
{
foreach (NodeViewModel nodeViewModel in nodes)
nodeViewModel.IsSelected = !_initialNodeSelection.Contains(nodeViewModel);
}
else
{
foreach (NodeViewModel nodeViewModel in nodes)
nodeViewModel.IsSelected = true;
foreach (NodeViewModel nodeViewModel in NodeViewModels.Except(nodes))
nodeViewModel.IsSelected = false;
}
}
public void FinishNodeSelection()
{
_initialNodeSelection = null;
}
public void ClearNodeSelection()
{
foreach (NodeViewModel nodeViewModel in NodeViewModels)
nodeViewModel.IsSelected = false;
}
public void StartNodeDrag(Point position)
{
foreach (NodeViewModel nodeViewModel in NodeViewModels)
nodeViewModel.StartDrag(position);
}
public void UpdateNodeDrag(Point position)
{
foreach (NodeViewModel nodeViewModel in NodeViewModels)
nodeViewModel.UpdateDrag(position);
}
public void FinishNodeDrag()
{
List<MoveNode> commands = NodeViewModels.Select(n => n.FinishDrag()).Where(c => c != null).Cast<MoveNode>().ToList();
if (!commands.Any())
return;
if (commands.Count == 1)
_nodeEditorService.ExecuteCommand(NodeScript, commands.First());
using NodeEditorCommandScope scope = _nodeEditorService.CreateCommandScope(NodeScript, $"Move {commands.Count} nodes");
foreach (MoveNode moveNode in commands)
_nodeEditorService.ExecuteCommand(NodeScript, moveNode);
}
public bool UpdatePinDrag(PinViewModel sourcePinViewModel, PinViewModel? targetPinVmModel, Point position)
{
if (DragViewModel?.PinViewModel != sourcePinViewModel)
DragViewModel = new DragCableViewModel(sourcePinViewModel);
DragViewModel.DragPoint = position;
return targetPinVmModel == null || targetPinVmModel.IsCompatibleWith(sourcePinViewModel);
}
public void FinishPinDrag(PinViewModel sourcePinViewModel, PinViewModel? targetPinVmModel, Point position)
{
if (DragViewModel == null)
return;
DragViewModel = null;
// If dropped on top of a compatible pin, connect to it
if (targetPinVmModel != null && targetPinVmModel.IsCompatibleWith(sourcePinViewModel))
_nodeEditorService.ExecuteCommand(NodeScript, new ConnectPins(sourcePinViewModel.Pin, targetPinVmModel.Pin));
// If not dropped on a pin allow the user to create a new node
else if (targetPinVmModel == null)
{
// If there is only one, spawn that straight away
List<NodeData> singleCompatibleNode = _nodeService.AvailableNodes.Where(n => n.IsCompatibleWithPin(sourcePinViewModel.Pin)).ToList();
if (singleCompatibleNode.Count == 1)
{
// Borrow the node picker to spawn the node in, even if it's never shown
NodePickerViewModel.TargetPin = sourcePinViewModel.Pin;
NodePickerViewModel.CreateNode(singleCompatibleNode.First());
}
// Otherwise show the user the picker by requesting it at the drop position
else
{
_requestedPickerPositionSubject.OnNext(position);
NodePickerViewModel.TargetPin = sourcePinViewModel.Pin;
}
}
}
private void HandleNodeAdded(SingleValueEventArgs<INode> eventArgs)
{
_nodeViewModels.Add(_nodeVmFactory.NodeViewModel(this, eventArgs.Value));
}
private void HandleNodeRemoved(SingleValueEventArgs<INode> eventArgs)
{
NodeViewModel? toRemove = NodeViewModels.FirstOrDefault(vm => ReferenceEquals(vm.Node, eventArgs.Value));
if (toRemove != null)
_nodeViewModels.Remove(toRemove);
}
}
@@ -0,0 +1,75 @@
<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:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:visualScripting="clr-namespace:Artemis.UI.Screens.VisualScripting"
mc:Ignorable="d" d:DesignWidth="250" d:DesignHeight="150"
x:Class="Artemis.UI.Screens.VisualScripting.NodeView"
x:DataType="visualScripting:NodeViewModel">
<UserControl.Styles>
<Style Selector="Border.node-container">
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Background">
<SolidColorBrush Color="{DynamicResource SolidBackgroundFillColorBase}" Opacity="0.75"></SolidColorBrush>
</Setter>
<Setter Property="BorderBrush" Value="{DynamicResource CardStrokeColorDefaultBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Transitions">
<Setter.Value>
<Transitions>
<BrushTransition Property="BorderBrush" Duration="0:0:0.2" Easing="CubicEaseOut" />
</Transitions>
</Setter.Value>
</Setter>
</Style>
<Style Selector="Border.node-container-selected">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
</Style>
</UserControl.Styles>
<Border Classes="node-container" Classes.node-container-selected="{CompiledBinding IsSelected}" PointerMoved="NodeContainer_OnPointerMoved">
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0"
CornerRadius="6 6 0 0"
Cursor="Hand"
PointerReleased="InputElement_OnPointerReleased"
PointerMoved="InputElement_OnPointerMoved"
ClipToBounds="True"
Background="{DynamicResource ContentDialogBackground}">
<Border Background="{DynamicResource TaskDialogHeaderBackground}">
<Grid Classes="node-header"
VerticalAlignment="Top"
ColumnDefinitions="*,Auto">
<TextBlock VerticalAlignment="Center"
Margin="10 0 0 0"
Text="{CompiledBinding Node.Name}"
ToolTip.Tip="{CompiledBinding Node.Description}">
</TextBlock>
<Button VerticalAlignment="Center"
Classes="icon-button icon-button-small"
Grid.Column="1"
Margin="5"
Command="{CompiledBinding DeleteNode}">
<avalonia:MaterialIcon Kind="Close"></avalonia:MaterialIcon>
</Button>
</Grid>
</Border>
</Border>
<Grid Grid.Row="1" ColumnDefinitions="Auto,*,Auto" Margin="4">
<StackPanel Grid.Column="0" IsVisible="{CompiledBinding HasInputPins}">
<ItemsControl Items="{CompiledBinding InputPinViewModels}" Margin="4 0" />
<ItemsControl Items="{CompiledBinding InputPinCollectionViewModels}" />
</StackPanel>
<ContentControl Grid.Column="1" Name="CustomViewModelContainer" Content="{CompiledBinding CustomNodeViewModel}" IsVisible="{CompiledBinding CustomNodeViewModel}" />
<StackPanel Grid.Column="2" IsVisible="{CompiledBinding HasOutputPins}">
<ItemsControl Items="{CompiledBinding OutputPinViewModels}" Margin="4 0" />
<ItemsControl Items="{CompiledBinding OutputPinCollectionViewModels}" />
</StackPanel>
</Grid>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
using Avalonia.VisualTree;
namespace Artemis.UI.Screens.VisualScripting;
public class NodeView : ReactiveUserControl<NodeViewModel>
{
private bool _dragging;
public NodeView()
{
InitializeComponent();
}
#region Overrides of Layoutable
/// <inheritdoc />
protected override Size MeasureOverride(Size availableSize)
{
// Take the base implementation's size
(double width, double height) = base.MeasureOverride(availableSize);
// Ceil the resulting size
width = Math.Ceiling(width / 10.0) * 10.0;
height = Math.Ceiling(height / 10.0) * 10.0;
return new Size(width, height);
}
#endregion
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void InputElement_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (ViewModel == null || e.InitialPressMouseButton != MouseButton.Left)
return;
if (_dragging)
{
_dragging = false;
ViewModel.NodeScriptViewModel.FinishNodeDrag();
e.Pointer.Capture(null);
}
else
{
ViewModel.NodeScriptViewModel.UpdateNodeSelection(new List<NodeViewModel> {ViewModel}, e.KeyModifiers.HasFlag(KeyModifiers.Shift), e.KeyModifiers.HasFlag(KeyModifiers.Control));
ViewModel.NodeScriptViewModel.FinishNodeSelection();
}
e.Handled = true;
}
private void InputElement_OnPointerMoved(object? sender, PointerEventArgs e)
{
PointerPoint point = e.GetCurrentPoint(this.FindAncestorOfType<Canvas>());
if (ViewModel == null || !point.Properties.IsLeftButtonPressed)
return;
if (!_dragging)
{
_dragging = true;
if (!ViewModel.IsSelected)
{
ViewModel.NodeScriptViewModel.UpdateNodeSelection(new List<NodeViewModel> {ViewModel}, false, false);
ViewModel.NodeScriptViewModel.FinishNodeSelection();
}
ViewModel.NodeScriptViewModel.StartNodeDrag(point.Position);
e.Pointer.Capture((IInputElement?) sender);
}
ViewModel.NodeScriptViewModel.UpdateNodeDrag(point.Position);
e.Handled = true;
}
private void NodeContainer_OnPointerMoved(object? sender, PointerEventArgs e)
{
e.Handled = true;
}
}
@@ -0,0 +1,181 @@
using System;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Reactive;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.UI.Ninject.Factories;
using Artemis.UI.Screens.VisualScripting.Pins;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.NodeEditor;
using Artemis.UI.Shared.Services.NodeEditor.Commands;
using Avalonia;
using Avalonia.Controls.Mixins;
using DynamicData;
using DynamicData.Binding;
using ReactiveUI;
namespace Artemis.UI.Screens.VisualScripting;
public class NodeViewModel : ActivatableViewModelBase
{
private readonly INodeEditorService _nodeEditorService;
private ICustomNodeViewModel? _customNodeViewModel;
private ReactiveCommand<Unit, Unit>? _deleteNode;
private double _dragOffsetX;
private double _dragOffsetY;
private bool _isSelected;
private double _startX;
private double _startY;
private ObservableAsPropertyHelper<bool>? _isStaticNode;
private ObservableAsPropertyHelper<bool>? _hasInputPins;
private ObservableAsPropertyHelper<bool>? _hasOutputPins;
public NodeViewModel(NodeScriptViewModel nodeScriptViewModel, INode node, INodeVmFactory nodeVmFactory, INodeEditorService nodeEditorService)
{
NodeScriptViewModel = nodeScriptViewModel;
_nodeEditorService = nodeEditorService;
Node = node;
DeleteNode = ReactiveCommand.Create(ExecuteDeleteNode, this.WhenAnyValue(vm => vm.IsStaticNode).Select(v => !v));
SourceList<IPin> nodePins = new();
SourceList<IPinCollection> nodePinCollections = new();
// Create observable collections split up by direction
nodePins.Connect()
.Filter(n => n.Direction == PinDirection.Input)
.Transform(p => (PinViewModel) nodeVmFactory.InputPinViewModel(p))
.Bind(out ReadOnlyObservableCollection<PinViewModel> inputPins)
.Subscribe();
nodePins.Connect()
.Filter(n => n.Direction == PinDirection.Output)
.Transform(p => (PinViewModel) nodeVmFactory.OutputPinViewModel(p))
.Bind(out ReadOnlyObservableCollection<PinViewModel> outputPins)
.Subscribe();
InputPinViewModels = inputPins;
OutputPinViewModels = outputPins;
// Same again but for pin collections
nodePinCollections.Connect()
.Filter(n => n.Direction == PinDirection.Input)
.Transform(c => (PinCollectionViewModel) nodeVmFactory.InputPinCollectionViewModel(c, nodeScriptViewModel))
.Bind(out ReadOnlyObservableCollection<PinCollectionViewModel> inputPinCollections)
.Subscribe();
nodePinCollections.Connect()
.Filter(n => n.Direction == PinDirection.Output)
.Transform(c => (PinCollectionViewModel) nodeVmFactory.OutputPinCollectionViewModel(c, nodeScriptViewModel))
.Bind(out ReadOnlyObservableCollection<PinCollectionViewModel> outputPinCollections)
.Subscribe();
InputPinCollectionViewModels = inputPinCollections;
OutputPinCollectionViewModels = outputPinCollections;
// Create a single observable collection containing all pin view models
InputPinViewModels.ToObservableChangeSet()
.Merge(OutputPinViewModels.ToObservableChangeSet())
.Merge(InputPinCollectionViewModels.ToObservableChangeSet().TransformMany(c => c.PinViewModels))
.Merge(OutputPinCollectionViewModels.ToObservableChangeSet().TransformMany(c => c.PinViewModels))
.Bind(out ReadOnlyObservableCollection<PinViewModel> pins)
.Subscribe();
PinViewModels = pins;
this.WhenActivated(d =>
{
_isStaticNode = Node.WhenAnyValue(n => n.IsDefaultNode, n => n.IsExitNode)
.Select(tuple => tuple.Item1 || tuple.Item2)
.ToProperty(this, model => model.IsStaticNode)
.DisposeWith(d);
_hasInputPins = InputPinViewModels.ToObservableChangeSet()
.Merge(InputPinCollectionViewModels.ToObservableChangeSet().TransformMany(c => c.PinViewModels))
.Any()
.ToProperty(this, vm => vm.HasInputPins)
.DisposeWith(d);
_hasOutputPins = OutputPinViewModels.ToObservableChangeSet()
.Merge(OutputPinCollectionViewModels.ToObservableChangeSet().TransformMany(c => c.PinViewModels))
.Any()
.ToProperty(this, vm => vm.HasOutputPins)
.DisposeWith(d);
// Subscribe to pin changes
Node.WhenAnyValue(n => n.Pins).Subscribe(p => nodePins.Edit(source =>
{
source.Clear();
source.AddRange(p);
})).DisposeWith(d);
// Subscribe to pin collection changes
Node.WhenAnyValue(n => n.PinCollections).Subscribe(c => nodePinCollections.Edit(source =>
{
source.Clear();
source.AddRange(c);
})).DisposeWith(d);
if (Node is Node coreNode)
CustomNodeViewModel = coreNode.GetCustomViewModel(nodeScriptViewModel.NodeScript);
});
}
public bool IsStaticNode => _isStaticNode?.Value ?? true;
public bool HasInputPins => _hasInputPins?.Value ?? false;
public bool HasOutputPins => _hasOutputPins?.Value ?? false;
public NodeScriptViewModel NodeScriptViewModel { get; }
public INode Node { get; }
public ReadOnlyObservableCollection<PinViewModel> InputPinViewModels { get; }
public ReadOnlyObservableCollection<PinCollectionViewModel> InputPinCollectionViewModels { get; }
public ReadOnlyObservableCollection<PinViewModel> OutputPinViewModels { get; }
public ReadOnlyObservableCollection<PinCollectionViewModel> OutputPinCollectionViewModels { get; }
public ReadOnlyObservableCollection<PinViewModel> PinViewModels { get; }
public ICustomNodeViewModel? CustomNodeViewModel
{
get => _customNodeViewModel;
set => RaiseAndSetIfChanged(ref _customNodeViewModel, value);
}
public ReactiveCommand<Unit, Unit>? DeleteNode
{
get => _deleteNode;
set => RaiseAndSetIfChanged(ref _deleteNode, value);
}
public bool IsSelected
{
get => _isSelected;
set => RaiseAndSetIfChanged(ref _isSelected, value);
}
public void StartDrag(Point mouseStartPosition)
{
if (!IsSelected)
return;
_dragOffsetX = Node.X - mouseStartPosition.X;
_dragOffsetY = Node.Y - mouseStartPosition.Y;
_startX = Node.X;
_startY = Node.Y;
}
public void UpdateDrag(Point mousePosition)
{
if (!IsSelected)
return;
Node.X = Math.Round((mousePosition.X + _dragOffsetX) / 10d, 0, MidpointRounding.AwayFromZero) * 10d;
Node.Y = Math.Round((mousePosition.Y + _dragOffsetY) / 10d, 0, MidpointRounding.AwayFromZero) * 10d;
}
public MoveNode? FinishDrag()
{
if (IsSelected && (Math.Abs(_startX - Node.X) > 0.01 || Math.Abs(_startY - Node.Y) > 0.01))
return new MoveNode(Node, Node.X, Node.Y, _startX, _startY);
return null;
}
private void ExecuteDeleteNode()
{
_nodeEditorService.ExecuteCommand(NodeScriptViewModel.NodeScript, new DeleteNode(NodeScriptViewModel.NodeScript, Node));
}
}
@@ -0,0 +1,32 @@
<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:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:pins="clr-namespace:Artemis.UI.Screens.VisualScripting.Pins"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.VisualScripting.Pins.InputPinCollectionView"
x:DataType="pins:PinCollectionViewModel">
<StackPanel>
<Button Classes="icon-button icon-button-small pin-collection-button"
ToolTip.Tip="Add new pin"
Command="{CompiledBinding AddPin}">
<avalonia:MaterialIcon Kind="Add"></avalonia:MaterialIcon>
</Button>
<ItemsControl Items="{CompiledBinding PinViewModels}" Margin="4 0">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="pins:PinViewModel">
<StackPanel Orientation="Horizontal">
<ContentControl Content="{CompiledBinding}"></ContentControl>
<Button Classes="icon-button icon-button-small"
ToolTip.Tip="Remove pin"
Command="{CompiledBinding RemovePin}"
CommandParameter="{CompiledBinding Pin}">
<avalonia:MaterialIcon Kind="Close"></avalonia:MaterialIcon>
</Button>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</UserControl>
@@ -0,0 +1,20 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.VisualScripting.Pins
{
public partial class InputPinCollectionView : ReactiveUserControl<PinCollectionViewModel>
{
public InputPinCollectionView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
}
@@ -0,0 +1,25 @@
using Artemis.Core;
using Artemis.UI.Ninject.Factories;
using Artemis.UI.Shared.Services.NodeEditor;
namespace Artemis.UI.Screens.VisualScripting.Pins;
public class InputPinCollectionViewModel : PinCollectionViewModel
{
private readonly INodeVmFactory _nodeVmFactory;
public IPinCollection InputPinCollection { get; }
public InputPinCollectionViewModel(IPinCollection inputPinCollection, NodeScriptViewModel nodeScriptViewModel, INodeVmFactory nodeVmFactory, INodeEditorService nodeEditorService)
: base(inputPinCollection, nodeScriptViewModel, nodeEditorService)
{
_nodeVmFactory = nodeVmFactory;
InputPinCollection = inputPinCollection;
}
protected override PinViewModel CreatePinViewModel(IPin pin)
{
PinViewModel vm = _nodeVmFactory.InputPinViewModel(pin);
vm.RemovePin = RemovePin;
return vm;
}
}
@@ -0,0 +1,31 @@
<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:pins="clr-namespace:Artemis.UI.Screens.VisualScripting.Pins"
mc:Ignorable="d"
d:DesignWidth="200"
x:Class="Artemis.UI.Screens.VisualScripting.Pins.InputPinView"
x:DataType="pins:PinViewModel">
<UserControl.Styles>
<StyleInclude Source="/Screens/VisualScripting/VisualScripting.axaml" />
<Style Selector="StackPanel#PinContainer Border#VisualPinPoint">
<Setter Property="Background">
<Setter.Value>
<SolidColorBrush Color="{CompiledBinding DarkenedPinColor}"></SolidColorBrush>
</Setter.Value>
</Setter>
<Setter Property="BorderBrush">
<Setter.Value>
<SolidColorBrush Color="{CompiledBinding PinColor}"></SolidColorBrush>
</Setter.Value>
</Setter>
</Style>
</UserControl.Styles>
<StackPanel Name="PinContainer" Orientation="Horizontal" Spacing="6">
<Border Name="PinPoint">
<Border Name="VisualPinPoint" />
</Border>
<TextBlock Name="PinName" VerticalAlignment="Center" Text="{CompiledBinding Pin.Name}" />
</StackPanel>
</UserControl>
@@ -0,0 +1,18 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Artemis.UI.Screens.VisualScripting.Pins;
public class InputPinView : PinView
{
public InputPinView()
{
InitializeComponent();
InitializePin(this.Get<Border>("PinPoint"));
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
@@ -0,0 +1,11 @@
using Artemis.Core;
using Artemis.Core.Services;
namespace Artemis.UI.Screens.VisualScripting.Pins;
public class InputPinViewModel : PinViewModel
{
public InputPinViewModel(IPin inputPin, INodeService nodeService) : base(inputPin, nodeService)
{
}
}
@@ -0,0 +1,33 @@
<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:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:pins="clr-namespace:Artemis.UI.Screens.VisualScripting.Pins"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.VisualScripting.Pins.OutputPinCollectionView"
x:DataType="pins:PinCollectionViewModel">
<StackPanel>
<Button Classes="icon-button icon-button-small pin-collection-button"
ToolTip.Tip="Add new pin"
HorizontalAlignment="Right"
Command="{CompiledBinding AddPin}">
<avalonia:MaterialIcon Kind="Add"></avalonia:MaterialIcon>
</Button>
<ItemsControl Items="{CompiledBinding PinViewModels}" Margin="4 0" HorizontalAlignment="Right">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="pins:PinViewModel">
<StackPanel Orientation="Horizontal">
<Button Classes="icon-button icon-button-small"
ToolTip.Tip="Remove pin"
Command="{CompiledBinding RemovePin}"
CommandParameter="{CompiledBinding Pin}">
<avalonia:MaterialIcon Kind="Close"></avalonia:MaterialIcon>
</Button>
<ContentControl Content="{CompiledBinding}"></ContentControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</UserControl>
@@ -0,0 +1,20 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.VisualScripting.Pins
{
public partial class OutputPinCollectionView : ReactiveUserControl<PinCollectionViewModel>
{
public OutputPinCollectionView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
}
@@ -0,0 +1,25 @@
using Artemis.Core;
using Artemis.UI.Ninject.Factories;
using Artemis.UI.Shared.Services.NodeEditor;
namespace Artemis.UI.Screens.VisualScripting.Pins;
public class OutputPinCollectionViewModel : PinCollectionViewModel
{
private readonly INodeVmFactory _nodeVmFactory;
public IPinCollection OutputPinCollection { get; }
public OutputPinCollectionViewModel(IPinCollection outputPinCollection, NodeScriptViewModel nodeScriptViewModel, INodeVmFactory nodeVmFactory, INodeEditorService nodeEditorService)
: base(outputPinCollection, nodeScriptViewModel, nodeEditorService)
{
_nodeVmFactory = nodeVmFactory;
OutputPinCollection = outputPinCollection;
}
protected override PinViewModel CreatePinViewModel(IPin pin)
{
PinViewModel vm = _nodeVmFactory.OutputPinViewModel(pin);
vm.RemovePin = RemovePin;
return vm;
}
}
@@ -0,0 +1,31 @@
<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:pins="clr-namespace:Artemis.UI.Screens.VisualScripting.Pins"
mc:Ignorable="d"
d:DesignWidth="200"
x:Class="Artemis.UI.Screens.VisualScripting.Pins.OutputPinView"
x:DataType="pins:PinViewModel">
<UserControl.Styles>
<StyleInclude Source="/Screens/VisualScripting/VisualScripting.axaml" />
<Style Selector="StackPanel#PinContainer Border#VisualPinPoint">
<Setter Property="Background">
<Setter.Value>
<SolidColorBrush Color="{CompiledBinding DarkenedPinColor}" />
</Setter.Value>
</Setter>
<Setter Property="BorderBrush">
<Setter.Value>
<SolidColorBrush Color="{CompiledBinding PinColor}" />
</Setter.Value>
</Setter>
</Style>
</UserControl.Styles>
<StackPanel Name="PinContainer" Orientation="Horizontal" Spacing="6">
<TextBlock Name="PinName" VerticalAlignment="Center" Text="{CompiledBinding Pin.Name}" />
<Border Name="PinPoint">
<Border Name="VisualPinPoint" />
</Border>
</StackPanel>
</UserControl>
@@ -0,0 +1,18 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Artemis.UI.Screens.VisualScripting.Pins;
public class OutputPinView : PinView
{
public OutputPinView()
{
InitializeComponent();
InitializePin(this.Get<Border>("PinPoint"));
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
@@ -0,0 +1,11 @@
using Artemis.Core;
using Artemis.Core.Services;
namespace Artemis.UI.Screens.VisualScripting.Pins;
public class OutputPinViewModel : PinViewModel
{
public OutputPinViewModel(IPin outputPin, INodeService nodeService) : base(outputPin, nodeService)
{
}
}
@@ -0,0 +1,49 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.Core.Events;
using Artemis.UI.Ninject.Factories;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.NodeEditor;
using Artemis.UI.Shared.Services.NodeEditor.Commands;
using Avalonia.Controls.Mixins;
using DynamicData;
using ReactiveUI;
namespace Artemis.UI.Screens.VisualScripting.Pins;
public abstract class PinCollectionViewModel : ActivatableViewModelBase
{
protected PinCollectionViewModel(IPinCollection pinCollection, NodeScriptViewModel nodeScriptViewModel, INodeEditorService nodeEditorService)
{
PinCollection = pinCollection;
PinViewModels = new ObservableCollection<PinViewModel>();
this.WhenActivated(d =>
{
PinViewModels.Clear();
PinViewModels.AddRange(PinCollection.Select(CreatePinViewModel));
Observable.FromEventPattern<SingleValueEventArgs<IPin>>(x => PinCollection.PinAdded += x, x => PinCollection.PinAdded -= x)
.Subscribe(e => PinViewModels.Add(CreatePinViewModel(e.EventArgs.Value)))
.DisposeWith(d);
Observable.FromEventPattern<SingleValueEventArgs<IPin>>(x => PinCollection.PinRemoved += x, x => PinCollection.PinRemoved -= x)
.Subscribe(e => PinViewModels.RemoveMany(PinViewModels.Where(p => p.Pin == e.EventArgs.Value).ToList()))
.DisposeWith(d);
});
AddPin = ReactiveCommand.Create(() => nodeEditorService.ExecuteCommand(nodeScriptViewModel.NodeScript, new AddPin(pinCollection)));
RemovePin = ReactiveCommand.Create((IPin pin) => nodeEditorService.ExecuteCommand(nodeScriptViewModel.NodeScript, new RemovePin(pinCollection, pin)));
}
public IPinCollection PinCollection { get; }
public ReactiveCommand<Unit, Unit> AddPin { get; }
public ReactiveCommand<IPin, Unit> RemovePin { get; }
public ObservableCollection<PinViewModel> PinViewModels { get; }
protected abstract PinViewModel CreatePinViewModel(IPin pin);
}
@@ -0,0 +1,90 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.ReactiveUI;
using Avalonia.VisualTree;
namespace Artemis.UI.Screens.VisualScripting.Pins;
public class PinView : ReactiveUserControl<PinViewModel>
{
private bool _dragging;
private Canvas? _container;
private Border? _pinPoint;
protected void InitializePin(Border pinPoint)
{
_pinPoint = pinPoint;
_pinPoint.PointerMoved += PinPointOnPointerMoved;
_pinPoint.PointerReleased += PinPointOnPointerReleased;
}
private void PinPointOnPointerMoved(object? sender, PointerEventArgs e)
{
if (ViewModel == null || _container == null || _pinPoint == null)
return;
NodeScriptViewModel? nodeScriptViewModel = this.FindAncestorOfType<NodeScriptView>()?.ViewModel;
PointerPoint point = e.GetCurrentPoint(_container);
if (nodeScriptViewModel == null || !point.Properties.IsLeftButtonPressed)
return;
if (!_dragging)
e.Pointer.Capture(_pinPoint);
PinViewModel? targetPin = (_container.InputHitTest(point.Position) as Border)?.DataContext as PinViewModel;
if (targetPin == ViewModel)
targetPin = null;
_pinPoint.Cursor = new Cursor(nodeScriptViewModel.UpdatePinDrag(ViewModel, targetPin, point.Position) ? StandardCursorType.Hand : StandardCursorType.No);
_dragging = true;
e.Handled = true;
}
private void PinPointOnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (!_dragging || ViewModel == null || _container == null || _pinPoint == null)
return;
_dragging = false;
e.Pointer.Capture(null);
PointerPoint point = e.GetCurrentPoint(_container);
PinViewModel? targetPin = (_container.InputHitTest(point.Position) as Border)?.DataContext as PinViewModel;
if (targetPin == ViewModel)
targetPin = null;
this.FindAncestorOfType<NodeScriptView>()?.ViewModel?.FinishPinDrag(ViewModel, targetPin, point.Position);
_pinPoint.Cursor = new Cursor(StandardCursorType.Hand);
e.Handled = true;
}
#region Overrides of Visual
/// <inheritdoc />
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
_container = this.FindAncestorOfType<Canvas>();
}
/// <inheritdoc />
public override void Render(DrawingContext context)
{
base.Render(context);
UpdatePosition();
}
private void UpdatePosition()
{
if (_container == null || _pinPoint == null || ViewModel == null)
return;
Matrix? transform = _pinPoint.TransformToVisual(_container);
if (transform != null)
ViewModel.Position = new Point(_pinPoint.Bounds.Width / 2, _pinPoint.Bounds.Height / 2).Transform(transform.Value);
}
#endregion
}
@@ -0,0 +1,88 @@
using System;
using System.Reactive;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.Core.Events;
using Artemis.Core.Services;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Extensions;
using Avalonia;
using Avalonia.Controls.Mixins;
using Avalonia.Media;
using DynamicData;
using ReactiveUI;
namespace Artemis.UI.Screens.VisualScripting.Pins;
public abstract class PinViewModel : ActivatableViewModelBase
{
private readonly INodeService _nodeService;
private ReactiveCommand<IPin, Unit>? _removePin;
private Point _position;
private Color _pinColor;
private Color _darkenedPinColor;
protected PinViewModel(IPin pin, INodeService nodeService)
{
_nodeService = nodeService;
Pin = pin;
SourceList<IPin> connectedPins = new();
this.WhenActivated(d =>
{
Observable.FromEventPattern<SingleValueEventArgs<IPin>>(x => Pin.PinConnected += x, x => Pin.PinConnected -= x)
.Subscribe(e => connectedPins.Add(e.EventArgs.Value))
.DisposeWith(d);
Observable.FromEventPattern<SingleValueEventArgs<IPin>>(x => Pin.PinDisconnected += x, x => Pin.PinDisconnected -= x)
.Subscribe(e => connectedPins.Remove(e.EventArgs.Value))
.DisposeWith(d);
Pin.WhenAnyValue(p => p.Type).Subscribe(_ => UpdatePinColor()).DisposeWith(d);
});
Connections = connectedPins.Connect().AsObservableList();
connectedPins.AddRange(Pin.ConnectedTo);
}
private void UpdatePinColor()
{
TypeColorRegistration registration = _nodeService.GetTypeColorRegistration(Pin.Type);
PinColor = registration.Color.ToColor();
DarkenedPinColor = registration.DarkenedColor.ToColor();
}
public IObservableList<IPin> Connections { get; }
public IPin Pin { get; }
public Color PinColor
{
get => _pinColor;
set => RaiseAndSetIfChanged(ref _pinColor, value);
}
public Color DarkenedPinColor
{
get => _darkenedPinColor;
set => RaiseAndSetIfChanged(ref _darkenedPinColor, value);
}
public Point Position
{
get => _position;
set => RaiseAndSetIfChanged(ref _position, value);
}
public ReactiveCommand<IPin, Unit>? RemovePin
{
get => _removePin;
set => RaiseAndSetIfChanged(ref _removePin, value);
}
public bool IsCompatibleWith(PinViewModel pinViewModel)
{
if (pinViewModel.Pin.Direction == Pin.Direction || pinViewModel.Pin.Node == Pin.Node)
return false;
return Pin.IsTypeCompatible(pinViewModel.Pin.Type);
}
}
@@ -0,0 +1,36 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.PreviewWith>
<Border Padding="20">
<!-- Add Controls for Previewer Here -->
</Border>
</Design.PreviewWith>
<Style Selector=":is(Button).pin-collection-button">
<!-- <Setter Property="Margin" Value="0 2"/> -->
</Style>
<Style Selector="StackPanel#PinContainer">
<Setter Property="Height" Value="26" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="StackPanel#PinContainer Border#PinPoint">
<Setter Property="Width" Value="13" />
<Setter Property="Height" Value="13" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="StackPanel#PinContainer Border#VisualPinPoint">
<Setter Property="Width" Value="11" />
<Setter Property="Height" Value="11" />
<Setter Property="Margin" Value="2" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Background" Value="DarkRed" />
<Setter Property="BorderBrush" Value="Red" />
<Setter Property="BorderThickness" Value="2" />
</Style>
<Style Selector="StackPanel#PinContainer TextBlock#PinName">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="FontSize" Value="11" />
</Style>
</Styles>