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,217 @@
<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:menuBar="clr-namespace:Artemis.UI.Screens.ProfileEditor.MenuBar"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.MenuBar.MenuBarView"
x:DataType="menuBar:MenuBarViewModel">
<Menu VerticalAlignment="Top">
<MenuItem Header="_File">
<MenuItem Header="New">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Plus" />
</MenuItem.Icon>
<MenuItem Header="Folder" Command="{Binding AddFolder}">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Folder" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Layer" Command="{Binding AddLayer}">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Layers" />
</MenuItem.Icon>
</MenuItem>
</MenuItem>
<Separator />
<MenuItem Header="View Properties" Command="{Binding ViewProperties}">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Settings" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="_View Scripts" Command="{Binding ViewScripts}">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="BookEdit" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Adapt Profile" Command="{Binding AdaptProfile}">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Magic" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Suspend Profile" IsSelected="{CompiledBinding IsSuspended}">
<MenuItem.Icon>
<CheckBox BorderThickness="0"
IsHitTestVisible="False"
IsChecked="{CompiledBinding IsSuspended}"/>
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem Header="Export Profile" Command="{Binding ExportProfile}">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Export" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Duplicate Profile" Command="{Binding DuplicateProfile}">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="ContentDuplicate" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem Header="Delete Profile" Command="{Binding DeleteProfile}">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Trash" />
</MenuItem.Icon>
</MenuItem>
</MenuItem>
<MenuItem Header="_Edit" SubmenuOpened="MenuItem_OnSubmenuOpened">
<MenuItem Header="_Undo"
Command="{Binding History.Undo}"
HotKey="Ctrl+Z"
InputGesture="Ctrl+Z">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Undo" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="_Redo"
Command="{Binding History.Redo}"
HotKey="Ctrl+Y"
InputGesture="Ctrl+Y">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Redo" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem Header="_Duplicate"
Command="{Binding Duplicate}"
HotKey="Ctrl+D"
IsEnabled="{Binding HasSelectedElement}">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="ContentDuplicate" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="_Copy"
Command="{Binding Copy}"
HotKey="Ctrl+C"
IsEnabled="{Binding HasSelectedElement}">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="ContentCopy" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="_Paste"
Command="{Binding Paste}"
HotKey="Ctrl+V">
<MenuItem.Icon>
<avalonia:MaterialIconExt Kind="ContentPaste" />
</MenuItem.Icon>
</MenuItem>
</MenuItem>
<MenuItem Header="_Run">
<MenuItem Header="_Switch run mode"
Command="{Binding ToggleSuspend}"
HotKey="F5">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="SwapHorizontal" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Run Profile on Focus Loss"
ToolTip.Tip="If enabled, run mode is set to normal on focus loss"
HotKey="Shift+F5">
<MenuItem.Icon>
<CheckBox BorderThickness="0"
IsHitTestVisible="False"
IsChecked="{Binding StopOnFocusLoss.Value}" />
</MenuItem.Icon>
</MenuItem>
</MenuItem>
<MenuItem Header="_Options">
<MenuItem Header="Focus Selected Layer"
ToolTip.Tip="If enabled, displays only the layer you currently have selected"
IsEnabled="False">
<MenuItem.Icon>
<CheckBox BorderThickness="0"
IsHitTestVisible="False"
IsChecked="{Binding FocusSelectedLayer.Value}" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Display Data Model Values">
<MenuItem.Icon>
<CheckBox BorderThickness="0"
IsHitTestVisible="False"
IsChecked="{Binding ShowDataModelValues.Value}" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Display Full Condition Paths">
<MenuItem.Icon>
<CheckBox BorderThickness="0"
IsHitTestVisible="False"
IsChecked="{Binding ShowFullPaths.Value}" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Always Display Cable Values" ToolTip.Tip="If enabled, cable values are always shown instead of only on hover">
<MenuItem.Icon>
<CheckBox BorderThickness="0"
IsHitTestVisible="False"
IsChecked="{Binding AlwaysShowValues.Value}" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Apply All Data Bindings During Edit" ToolTip.Tip="If enabled, updates all data bindings instead of only the one you are editing">
<MenuItem.Icon>
<CheckBox BorderThickness="0"
IsHitTestVisible="False"
IsChecked="{Binding AlwaysApplyDataBindings.Value}" />
</MenuItem.Icon>
</MenuItem>
</MenuItem>
<MenuItem Header="_Help">
<MenuItem Header="Artemis Wiki" Command="{Binding OpenUrl}" CommandParameter="https://wiki.artemis-rgb.com/">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="BookEdit" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem Header="Editor" Command="{Binding OpenUrl}" CommandParameter="https://wiki.artemis-rgb.com/en/guides/user/profiles">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Edit" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Layers" Command="{Binding OpenUrl}" CommandParameter="https://wiki.artemis-rgb.com/guides/user/profiles/layers">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Layers" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Display Conditions" Command="{Binding OpenUrl}" CommandParameter="https://wiki.artemis-rgb.com/guides/user/profiles/conditions">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="NotEqual" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Timeline" Command="{Binding OpenUrl}" CommandParameter="https://wiki.artemis-rgb.com/guides/user/profiles/timeline">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Stopwatch" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Data Bindings" Command="{Binding OpenUrl}" CommandParameter="https://wiki.artemis-rgb.com/guides/user/profiles/data-bindings">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="VectorLink" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Scripting" Command="{Binding OpenUrl}" CommandParameter="https://wiki.artemis-rgb.com/guides/user/profiles/scripting">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="CodeJson" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem Header="Report a Bug" Command="{Binding OpenUrl}" CommandParameter="https://github.com/Artemis-RGB/Artemis/issues">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Github" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Get Help on Discord" Command="{Binding OpenUrl}" CommandParameter="https://discord.gg/S3MVaC9">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Discord" />
</MenuItem.Icon>
</MenuItem>
</MenuItem>
</Menu>
</UserControl>
@@ -0,0 +1,24 @@
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.MenuBar
{
public partial class MenuBarView : ReactiveUserControl<MenuBarViewModel>
{
public MenuBarView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void MenuItem_OnSubmenuOpened(object? sender, RoutedEventArgs e)
{
}
}
}
@@ -0,0 +1,54 @@
using System;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.ProfileEditor;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.MenuBar;
public class MenuBarViewModel : ActivatableViewModelBase
{
private readonly IProfileService _profileService;
private ProfileEditorHistory? _history;
private ObservableAsPropertyHelper<ProfileConfiguration?>? _profileConfiguration;
private ObservableAsPropertyHelper<bool>? _isSuspended;
public MenuBarViewModel(IProfileEditorService profileEditorService, IProfileService profileService)
{
_profileService = profileService;
this.WhenActivated(d =>
{
profileEditorService.History.Subscribe(history => History = history).DisposeWith(d);
_profileConfiguration = profileEditorService.ProfileConfiguration.ToProperty(this, vm => vm.ProfileConfiguration);
_isSuspended = profileEditorService.ProfileConfiguration
.Select(p => p?.WhenAnyValue(c => c.IsSuspended) ?? Observable.Never<bool>())
.Switch()
.ToProperty(this, vm => vm.IsSuspended)
.DisposeWith(d);
});
}
public ProfileEditorHistory? History
{
get => _history;
set => RaiseAndSetIfChanged(ref _history, value);
}
public ProfileConfiguration? ProfileConfiguration => _profileConfiguration?.Value;
public bool IsSuspended
{
get => _isSuspended?.Value ?? false;
set
{
if (ProfileConfiguration == null)
return;
ProfileConfiguration.IsSuspended = value;
_profileService.SaveProfileCategory(ProfileConfiguration.Category);
}
}
}
@@ -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:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:playback="clr-namespace:Artemis.UI.Screens.ProfileEditor.Playback"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="48"
x:Class="Artemis.UI.Screens.ProfileEditor.Playback.PlaybackView"
x:DataType="playback:PlaybackViewModel">
<DockPanel Margin="8 0">
<StackPanel Orientation="Horizontal" Spacing="5">
<Button Classes="icon-button icon-button-large"
ToolTip.Tip="Play from start (Shift+Space)" Command="{Binding PlayFromStart}"
Focusable="False">
<avalonia:MaterialIcon Kind="StepForward" />
</Button>
<Button Classes="icon-button icon-button-large"
ToolTip.Tip="Toggle play/pause (Space)" Command="{Binding TogglePlay}" Focusable="False">
<StackPanel>
<avalonia:MaterialIcon Kind="Play" IsVisible="{CompiledBinding !Playing}" />
<avalonia:MaterialIcon Kind="Pause" IsVisible="{CompiledBinding Playing}" />
</StackPanel>
</Button>
<Button Classes="icon-button icon-button-large" ToolTip.Tip="Go to start" Command="{Binding GoToStart}" Focusable="False">
<avalonia:MaterialIcon Kind="SkipBackward" />
</Button>
<Button Classes="icon-button icon-button-large" ToolTip.Tip="Go to end" Command="{Binding GoToEnd}" Focusable="False">
<avalonia:MaterialIcon Kind="SkipForward" />
</Button>
<Button Classes="icon-button icon-button-large" ToolTip.Tip="Previous frame" Command="{Binding GoToPreviousFrame}" Focusable="False">
<avalonia:MaterialIcon Kind="SkipPrevious" />
</Button>
<Button Classes="icon-button icon-button-large" ToolTip.Tip="Next frame" Command="{Binding GoToNextFrame}" Focusable="False">
<avalonia:MaterialIcon Kind="SkipNext" />
</Button>
<ToggleButton Classes="icon-button icon-button-large"
IsChecked="{CompiledBinding Repeating}"
Focusable="False"
Command="{Binding CycleRepeating}">
<ToolTip.Tip>
<StackPanel>
<StackPanel IsVisible="{CompiledBinding Repeating}">
<TextBlock Text="Repeat entire timeline"
IsVisible="{CompiledBinding RepeatTimeline}" />
<TextBlock Text="Repeat segment"
IsVisible="{CompiledBinding RepeatSegment}" />
</StackPanel>
<TextBlock IsVisible="{CompiledBinding !Repeating}">
Don't repeat the timeline
</TextBlock>
<TextBlock TextWrapping="Wrap">
This setting only applies to the editor and does not affect the repeat mode during normal profile playback
</TextBlock>
</StackPanel>
</ToolTip.Tip>
<Grid>
<avalonia:MaterialIcon Kind="Repeat" IsVisible="{CompiledBinding RepeatTimeline}" />
<avalonia:MaterialIcon Kind="RepeatOne" IsVisible="{CompiledBinding RepeatSegment}" />
</Grid>
</ToggleButton>
</StackPanel>
<TextBlock Classes="h4" Text="{CompiledBinding FormattedCurrentTime, FallbackValue=00.000}" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0"/>
</DockPanel>
</UserControl>
@@ -0,0 +1,18 @@
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Playback
{
public partial class PlaybackView : ReactiveUserControl<PlaybackViewModel>
{
public PlaybackView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
}
@@ -0,0 +1,205 @@
using System;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.ProfileEditor;
using Avalonia.Threading;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Playback;
public class PlaybackViewModel : ActivatableViewModelBase
{
private readonly IProfileEditorService _profileEditorService;
private readonly ISettingsService _settingsService;
private RenderProfileElement? _profileElement;
private ObservableAsPropertyHelper<TimeSpan>? _currentTime;
private ObservableAsPropertyHelper<string?>? _formattedCurrentTime;
private ObservableAsPropertyHelper<bool>? _playing;
private bool _repeating;
private bool _repeatTimeline;
private bool _repeatSegment;
private DateTime _lastUpdate;
public PlaybackViewModel(IProfileEditorService profileEditorService, ISettingsService settingsService)
{
_profileEditorService = profileEditorService;
_settingsService = settingsService;
this.WhenActivated(d =>
{
_profileEditorService.ProfileElement.Subscribe(e => _profileElement = e).DisposeWith(d);
_currentTime = _profileEditorService.Time.ToProperty(this, vm => vm.CurrentTime).DisposeWith(d);
_formattedCurrentTime = _profileEditorService.Time.Select(t => $"{Math.Floor(t.TotalSeconds):00}.{t.Milliseconds:000}").ToProperty(this, vm => vm.FormattedCurrentTime).DisposeWith(d);
_playing = _profileEditorService.Playing.ToProperty(this, vm => vm.Playing).DisposeWith(d);
_lastUpdate = DateTime.MinValue;
DispatcherTimer updateTimer = new(TimeSpan.FromMilliseconds(60.0 / 1000), DispatcherPriority.Render, Update);
updateTimer.Start();
Disposable.Create(() => updateTimer.Stop());
});
}
public TimeSpan CurrentTime => _currentTime?.Value ?? TimeSpan.Zero;
public string? FormattedCurrentTime => _formattedCurrentTime?.Value;
public bool Playing => _playing?.Value ?? false;
public bool Repeating
{
get => _repeating;
set => RaiseAndSetIfChanged(ref _repeating, value);
}
public bool RepeatTimeline
{
get => _repeatTimeline;
set => RaiseAndSetIfChanged(ref _repeatTimeline, value);
}
public bool RepeatSegment
{
get => _repeatSegment;
set => RaiseAndSetIfChanged(ref _repeatSegment, value);
}
public void PlayFromStart()
{
GoToStart();
if (!Playing)
_profileEditorService.Play();
}
public void TogglePlay()
{
if (!Playing)
_profileEditorService.Play();
else
_profileEditorService.Pause();
}
public void GoToStart()
{
_profileEditorService.ChangeTime(TimeSpan.Zero);
}
public void GoToEnd()
{
if (_profileElement == null)
return;
_profileEditorService.ChangeTime(_profileElement.Timeline.EndSegmentEndPosition);
}
public void GoToPreviousFrame()
{
if (_profileElement == null)
return;
double frameTime = 1000.0 / _settingsService.GetSetting("Core.TargetFrameRate", 30).Value;
double newTime = Math.Max(0, Math.Round((CurrentTime.TotalMilliseconds - frameTime) / frameTime) * frameTime);
_profileEditorService.ChangeTime(TimeSpan.FromMilliseconds(newTime));
}
public void GoToNextFrame()
{
if (_profileElement == null)
return;
double frameTime = 1000.0 / _settingsService.GetSetting("Core.TargetFrameRate", 30).Value;
double newTime = Math.Round((CurrentTime.TotalMilliseconds + frameTime) / frameTime) * frameTime;
newTime = Math.Min(newTime, _profileElement.Timeline.EndSegmentEndPosition.TotalMilliseconds);
_profileEditorService.ChangeTime(TimeSpan.FromMilliseconds(newTime));
}
public void CycleRepeating()
{
if (!Repeating)
{
RepeatTimeline = true;
RepeatSegment = false;
Repeating = true;
}
else if (RepeatTimeline)
{
RepeatTimeline = false;
RepeatSegment = true;
}
else if (RepeatSegment)
{
RepeatTimeline = true;
RepeatSegment = false;
Repeating = false;
}
}
private TimeSpan GetCurrentSegmentStart()
{
if (_profileElement == null)
return TimeSpan.Zero;
if (CurrentTime < _profileElement.Timeline.StartSegmentEndPosition)
return TimeSpan.Zero;
if (CurrentTime < _profileElement.Timeline.MainSegmentEndPosition)
return _profileElement.Timeline.MainSegmentStartPosition;
if (CurrentTime < _profileElement.Timeline.EndSegmentEndPosition)
return _profileElement.Timeline.EndSegmentStartPosition;
return TimeSpan.Zero;
}
private TimeSpan GetCurrentSegmentEnd()
{
if (_profileElement == null)
return TimeSpan.Zero;
if (CurrentTime < _profileElement.Timeline.StartSegmentEndPosition)
return _profileElement.Timeline.StartSegmentEndPosition;
if (CurrentTime < _profileElement.Timeline.MainSegmentEndPosition)
return _profileElement.Timeline.MainSegmentEndPosition;
if (CurrentTime < _profileElement.Timeline.EndSegmentEndPosition)
return _profileElement.Timeline.EndSegmentEndPosition;
return TimeSpan.Zero;
}
private void Update(object? sender, EventArgs e)
{
try
{
if (!Playing)
return;
if (_lastUpdate == DateTime.MinValue)
_lastUpdate = DateTime.Now;
TimeSpan newTime = CurrentTime.Add(DateTime.Now - _lastUpdate);
if (_profileElement != null)
{
if (Repeating && RepeatTimeline)
{
if (newTime > _profileElement.Timeline.Length)
newTime = TimeSpan.Zero;
}
else if (Repeating && RepeatSegment)
{
if (newTime > GetCurrentSegmentEnd())
newTime = GetCurrentSegmentStart();
}
else if (newTime > _profileElement.Timeline.Length)
{
newTime = _profileElement.Timeline.Length;
_profileEditorService.Pause();
}
}
_profileEditorService.ChangeTime(newTime);
}
finally
{
_lastUpdate = DateTime.Now;
}
}
}
@@ -0,0 +1,186 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Generators;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using Avalonia.Xaml.Interactions.DragAndDrop;
namespace Artemis.UI.Screens.ProfileEditor.ProfileTree.Behaviors;
public class ProfileTreeViewDropHandler : DropHandlerBase
{
public override bool Validate(object? sender, DragEventArgs e, object? sourceContext, object? targetContext, object? state)
{
if (e.Source is IControl && sender is TreeView treeView)
return Validate<TreeItemViewModel>(treeView, e, sourceContext, targetContext, false);
return false;
}
public override bool Execute(object? sender, DragEventArgs e, object? sourceContext, object? targetContext, object? state)
{
bool result = false;
if (e.Source is IControl && sender is TreeView treeView)
result = Validate<TreeItemViewModel>(treeView, e, sourceContext, targetContext, true);
if (sender is ItemsControl itemsControl)
{
foreach (TreeViewItem treeViewItem in GetFlattenedTreeView(itemsControl))
SetDraggingPseudoClasses(treeViewItem, TreeDropType.None);
}
return result;
}
public override void Cancel(object? sender, RoutedEventArgs e)
{
if (sender is ItemsControl itemsControl)
{
foreach (TreeViewItem treeViewItem in GetFlattenedTreeView(itemsControl))
SetDraggingPseudoClasses(treeViewItem, TreeDropType.None);
}
base.Cancel(sender, e);
}
private bool Validate<T>(TreeView treeView, DragEventArgs e, object? sourceContext, object? targetContext, bool bExecute) where T : TreeItemViewModel
{
Point position = e.GetPosition(treeView);
IVisual? targetVisual = treeView.GetVisualAt(position).FindAncestorOfType<TreeViewItem>();
if (sourceContext is not T sourceNode || targetContext is not ProfileTreeViewModel vm || targetVisual is not IControl {DataContext: T targetNode})
return false;
TreeItemViewModel? sourceParent = sourceNode.Parent;
TreeItemViewModel? targetParent = targetNode.Parent;
ObservableCollection<TreeItemViewModel> sourceNodes = sourceParent is { } ? sourceParent.Children : vm.Children;
ObservableCollection<TreeItemViewModel> targetNodes = targetParent is { } ? targetParent.Children : vm.Children;
int sourceIndex = sourceNodes.IndexOf(sourceNode);
int targetIndex = targetNodes.IndexOf(targetNode);
// Update the target index according to the position
TreeDropType dropType = TreeDropType.Before;
if (!targetNode.SupportsChildren)
{
if (position.Y > targetVisual.Bounds.Top + targetVisual.Bounds.Height / 2)
dropType = TreeDropType.After;
}
else
{
IVisual? header = targetVisual.GetVisualDescendants().FirstOrDefault(d => d is Border b && b.Name == "PART_LayoutRoot");
if (header != null)
{
double segments = header.Bounds.Height / 3.0;
if (position.Y > targetVisual.Bounds.Top + segments * 2)
dropType = TreeDropType.After;
else if (position.Y > targetVisual.Bounds.Top + segments)
dropType = TreeDropType.Into;
}
}
if (dropType == TreeDropType.After)
targetIndex += 1;
else if (dropType == TreeDropType.Into)
targetIndex = 0;
TreeItemViewModel? currentParent = targetNode.Parent;
while (currentParent != null)
{
if (currentParent == sourceNode)
return false;
currentParent = currentParent.Parent;
}
if (sourceIndex < 0 || targetIndex < 0)
return false;
if (e.DragEffects != DragDropEffects.Move)
return false;
foreach (TreeViewItem treeViewItem in GetFlattenedTreeView(treeView))
SetDraggingPseudoClasses(treeViewItem, TreeDropType.None);
if (bExecute)
{
if (dropType == TreeDropType.Into)
{
targetNode.InsertElement(sourceNode, 0);
}
else
{
if (sourceParent == targetParent && sourceIndex < targetIndex)
targetIndex--;
if (targetNode.Parent != null)
targetNode.Parent.InsertElement(sourceNode, targetIndex);
}
}
else
{
SetDraggingPseudoClasses((IControl) targetVisual, dropType);
}
return true;
}
private List<TreeViewItem> GetFlattenedTreeView(ItemsControl currentNode)
{
List<TreeViewItem> result = new();
foreach (ItemContainerInfo containerInfo in currentNode.ItemContainerGenerator.Containers)
{
if (containerInfo.ContainerControl is TreeViewItem treeViewItem && containerInfo.Item is TreeItemViewModel)
{
result.Add(treeViewItem);
if (treeViewItem.ItemContainerGenerator.Containers.Any())
result.AddRange(GetFlattenedTreeView(treeViewItem));
}
}
return result;
}
private void SetDraggingPseudoClasses(IControl control, TreeDropType type)
{
if (type == TreeDropType.None)
{
((IPseudoClasses) control.Classes).Remove(":dragging");
((IPseudoClasses) control.Classes).Remove(":dragging-before");
((IPseudoClasses) control.Classes).Remove(":dragging-after");
((IPseudoClasses) control.Classes).Remove(":dragging-into");
}
else
{
((IPseudoClasses) control.Classes).Add(":dragging");
if (type == TreeDropType.Before)
{
((IPseudoClasses) control.Classes).Add(":dragging-before");
((IPseudoClasses) control.Classes).Remove(":dragging-after");
((IPseudoClasses) control.Classes).Remove(":dragging-into");
}
else if (type == TreeDropType.After)
{
((IPseudoClasses) control.Classes).Remove(":dragging-before");
((IPseudoClasses) control.Classes).Add(":dragging-after");
((IPseudoClasses) control.Classes).Remove(":dragging-into");
}
else if (type == TreeDropType.Into)
{
((IPseudoClasses) control.Classes).Remove(":dragging-before");
((IPseudoClasses) control.Classes).Remove(":dragging-after");
((IPseudoClasses) control.Classes).Add(":dragging-into");
}
}
}
private enum TreeDropType
{
Before,
After,
Into,
None
}
}
@@ -0,0 +1,48 @@
<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:profileTree="clr-namespace:Artemis.UI.Screens.ProfileEditor.ProfileTree"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.ProfileTree.FolderTreeItemView"
x:DataType="profileTree:FolderTreeItemViewModel">
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
<Button Grid.Column="0"
ToolTip.Tip="{CompiledBinding ProfileElement.BrokenState}"
IsVisible="{CompiledBinding ProfileElement.BrokenState, Converter={x:Static ObjectConverters.IsNotNull}}"
Classes="icon-button icon-button-small"
Foreground="White"
Background="#E74C4C"
BorderBrush="#E74C4C"
Margin="0 0 5 0"
Command="{Binding ShowBrokenStateExceptions}">
<avalonia:MaterialIcon Kind="AlertCircle" />
</Button>
<avalonia:MaterialIcon Grid.Column="1"
Kind="Folder"
Margin="0 0 5 0"
IsVisible="{Binding !IsExpanded, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type TreeViewItem}}}" />
<avalonia:MaterialIcon Grid.Column="1"
Kind="FolderOpen"
Margin="0 0 5 0"
IsVisible="{Binding IsExpanded, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type TreeViewItem}}}" />
<TextBox Grid.Column="2"
Margin="-5 0 0 0"
Classes="condensed"
IsVisible="{CompiledBinding Renaming}"
Text="{CompiledBinding RenameValue}"
x:Name="Input"
KeyUp="InputElement_OnKeyUp"
LostFocus="InputElement_OnLostFocus"/>
<TextBlock Grid.Column="2" IsVisible="{CompiledBinding !Renaming}" Text="{Binding Folder.Name}" VerticalAlignment="Center" />
<ToggleButton Grid.Column="3"
Classes="icon-button icon-button-small"
ToolTip.Tip="Toggle suspended state"
IsChecked="{CompiledBinding Folder.Suspended}"
VerticalAlignment="Center"
Margin="4 0">
<avalonia:MaterialIcon Kind="Pause" />
</ToggleButton>
</Grid>
</UserControl>
@@ -0,0 +1,40 @@
using System;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.ProfileTree;
public class FolderTreeItemView : ReactiveUserControl<FolderTreeItemViewModel>
{
public FolderTreeItemView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
this.WhenActivated(_ => ViewModel?.Rename.Subscribe(_ =>
{
this.Get<TextBox>("Input").Focus();
this.Get<TextBox>("Input").SelectAll();
}));
}
private void InputElement_OnKeyUp(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
ViewModel?.SubmitRename();
else if (e.Key == Key.Escape)
ViewModel?.CancelRename();
}
private void InputElement_OnLostFocus(object? sender, RoutedEventArgs e)
{
ViewModel?.CancelRename();
}
}
@@ -0,0 +1,33 @@
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.Ninject.Factories;
using Artemis.UI.Shared.Services.Interfaces;
using Artemis.UI.Shared.Services.ProfileEditor;
namespace Artemis.UI.Screens.ProfileEditor.ProfileTree
{
public class FolderTreeItemViewModel : TreeItemViewModel
{
public FolderTreeItemViewModel(TreeItemViewModel? parent,
Folder folder,
IWindowService windowService,
IProfileEditorService profileEditorService,
ILayerBrushService layerBrushService,
IProfileEditorVmFactory profileEditorVmFactory,
IRgbService rgbService)
: base(parent, folder, windowService, profileEditorService, rgbService, layerBrushService, profileEditorVmFactory)
{
Folder = folder;
}
public Folder Folder { get; }
#region Overrides of TreeItemViewModel
/// <inheritdoc />
public override bool SupportsChildren => true;
#endregion
}
}
@@ -0,0 +1,39 @@
<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:profileTree="clr-namespace:Artemis.UI.Screens.ProfileEditor.ProfileTree"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.ProfileTree.LayerTreeItemView"
x:DataType="profileTree:LayerTreeItemViewModel">
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
<Button Grid.Column="0"
ToolTip.Tip="{CompiledBinding ProfileElement.BrokenState}"
IsVisible="{CompiledBinding ProfileElement.BrokenState, Converter={x:Static ObjectConverters.IsNotNull}}"
Classes="icon-button icon-button-small"
Foreground="#E74C4C"
Margin="0 0 5 0"
Command="{Binding ShowBrokenStateExceptions}">
<avalonia:MaterialIcon Kind="AlertCircle" />
</Button>
<avalonia:MaterialIcon Grid.Column="1" Kind="{CompiledBinding Layer.LayerBrush.Descriptor.Icon, FallbackValue=Layers}" Margin="0 0 5 0" />
<TextBox Grid.Column="2"
Margin="-5 0 0 0"
Classes="condensed"
x:Name="Input"
IsVisible="{CompiledBinding Renaming}"
Text="{CompiledBinding RenameValue}"
KeyUp="InputElement_OnKeyUp"
LostFocus="InputElement_OnLostFocus"></TextBox>
<TextBlock Grid.Column="2" IsVisible="{CompiledBinding !Renaming}" Text="{CompiledBinding Layer.Name}" VerticalAlignment="Center" />
<ToggleButton Grid.Column="3"
Classes="icon-button icon-button-small"
ToolTip.Tip="Toggle suspended state"
IsChecked="{CompiledBinding Layer.Suspended}"
VerticalAlignment="Center"
Margin="4 0">
<avalonia:MaterialIcon Kind="Pause" />
</ToggleButton>
</Grid>
</UserControl>
@@ -0,0 +1,40 @@
using System;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.ProfileTree;
public class LayerTreeItemView : ReactiveUserControl<LayerTreeItemViewModel>
{
public LayerTreeItemView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
this.WhenActivated(_ => ViewModel?.Rename.Subscribe(_ =>
{
this.Get<TextBox>("Input").Focus();
this.Get<TextBox>("Input").SelectAll();
}));
}
private void InputElement_OnKeyUp(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
ViewModel?.SubmitRename();
else if (e.Key == Key.Escape)
ViewModel?.CancelRename();
}
private void InputElement_OnLostFocus(object? sender, RoutedEventArgs e)
{
ViewModel?.CancelRename();
}
}
@@ -0,0 +1,32 @@
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.Ninject.Factories;
using Artemis.UI.Shared.Services.Interfaces;
using Artemis.UI.Shared.Services.ProfileEditor;
namespace Artemis.UI.Screens.ProfileEditor.ProfileTree
{
public class LayerTreeItemViewModel : TreeItemViewModel
{
public LayerTreeItemViewModel(TreeItemViewModel? parent,
Layer layer,
IWindowService windowService,
IProfileEditorService profileEditorService,
IRgbService rgbService,
ILayerBrushService layerBrushService,
IProfileEditorVmFactory profileEditorVmFactory)
: base(parent, layer, windowService, profileEditorService, rgbService, layerBrushService, profileEditorVmFactory)
{
Layer = layer;
}
public Layer Layer { get; }
#region Overrides of TreeItemViewModel
/// <inheritdoc />
public override bool SupportsChildren => false;
#endregion
}
}
@@ -0,0 +1,157 @@
<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:profileTree="clr-namespace:Artemis.UI.Screens.ProfileEditor.ProfileTree"
xmlns:profileBehaviors="clr-namespace:Artemis.UI.Screens.ProfileEditor.ProfileTree.Behaviors"
xmlns:behaviors="clr-namespace:Artemis.UI.Behaviors"
xmlns:converters="clr-namespace:Artemis.UI.Converters"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.ProfileTree.ProfileTreeView"
x:DataType="profileTree:ProfileTreeViewModel">
<UserControl.Resources>
<converters:ColorOpacityConverter x:Key="ColorOpacityConverter" />
</UserControl.Resources>
<UserControl.Styles>
<StyleInclude Source="avares://Avalonia.Xaml.Interactions/Draggable/Styles.axaml" />
<Style Selector="TreeView#ProfileTreeView">
<Style.Resources>
<profileBehaviors:ProfileTreeViewDropHandler x:Key="ProfileTreeViewDropHandler" />
</Style.Resources>
<Setter Property="(Interaction.Behaviors)">
<BehaviorCollectionTemplate>
<BehaviorCollection>
<ContextDropBehavior Handler="{StaticResource ProfileTreeViewDropHandler}" />
</BehaviorCollection>
</BehaviorCollectionTemplate>
</Setter>
</Style>
<Style Selector="TreeView#ProfileTreeView TreeViewItem">
<Setter Property="IsExpanded" Value="True" />
<Setter Property="(Interaction.Behaviors)">
<BehaviorCollectionTemplate>
<BehaviorCollection>
<behaviors:SimpleContextDragBehavior />
</BehaviorCollection>
</BehaviorCollectionTemplate>
</Setter>
</Style>
<Style Selector="TreeView#ProfileTreeView TreeViewItem:dragging-before">
<Setter Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,28">
<GradientStop Color="{StaticResource SystemAccentColorLight3}" Offset="0.0" />
<GradientStop Color="{StaticResource SystemAccentColorLight3}" Offset="0.05" />
<GradientStop Color="{Binding Source={StaticResource SystemAccentColorLight3}, Converter={StaticResource ColorOpacityConverter}, ConverterParameter=0.25}" Offset="0.05" />
<GradientStop Color="{Binding Source={StaticResource SystemAccentColorLight3}, Converter={StaticResource ColorOpacityConverter}, ConverterParameter=0}" Offset="0.25" />
</LinearGradientBrush>
</Setter.Value>
</Setter>
</Style>
<Style Selector="TreeView#ProfileTreeView TreeViewItem:dragging-into">
<Setter Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,28">
<GradientStop Color="{StaticResource SystemAccentColorLight3}" Offset="0.0" />
<GradientStop Color="{StaticResource SystemAccentColorLight3}" Offset="0.05" />
<GradientStop Color="{Binding Source={StaticResource SystemAccentColorLight3}, Converter={StaticResource ColorOpacityConverter}, ConverterParameter=0.25}" Offset="0.05" />
<GradientStop Color="{Binding Source={StaticResource SystemAccentColorLight3}, Converter={StaticResource ColorOpacityConverter}, ConverterParameter=0}" Offset="0.25" />
<GradientStop Color="{Binding Source={StaticResource SystemAccentColorLight3}, Converter={StaticResource ColorOpacityConverter}, ConverterParameter=0}" Offset="0.75" />
<GradientStop Color="{Binding Source={StaticResource SystemAccentColorLight3}, Converter={StaticResource ColorOpacityConverter}, ConverterParameter=0.25}" Offset="0.95" />
<GradientStop Color="{StaticResource SystemAccentColorLight3}" Offset="0.95" />
<GradientStop Color="{StaticResource SystemAccentColorLight3}" Offset="1" />
</LinearGradientBrush>
</Setter.Value>
</Setter>
</Style>
<Style Selector="TreeView#ProfileTreeView TreeViewItem:dragging-after">
<Setter Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,28">
<GradientStop Color="{Binding Source={StaticResource SystemAccentColorLight3}, Converter={StaticResource ColorOpacityConverter}, ConverterParameter=0}" Offset="0.75" />
<GradientStop Color="{Binding Source={StaticResource SystemAccentColorLight3}, Converter={StaticResource ColorOpacityConverter}, ConverterParameter=0.25}" Offset="0.95" />
<GradientStop Color="{StaticResource SystemAccentColorLight3}" Offset="0.95" />
<GradientStop Color="{StaticResource SystemAccentColorLight3}" Offset="1" />
</LinearGradientBrush>
</Setter.Value>
</Setter>
</Style>
</UserControl.Styles>
<Grid RowDefinitions="*,Auto">
<TreeView Name="ProfileTreeView" Classes="no-right-margin draggable" Items="{CompiledBinding Children}" SelectedItem="{CompiledBinding SelectedChild}"
SelectionChanged="ProfileTreeView_OnSelectionChanged">
<TreeView.Styles>
<Style Selector="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
</Style>
</TreeView.Styles>
<TreeView.KeyBindings>
<KeyBinding Gesture="Escape" Command="{CompiledBinding ClearSelection}" />
<KeyBinding Gesture="F2" Command="{CompiledBinding SelectedChild.Rename}" />
<KeyBinding Gesture="Delete" Command="{CompiledBinding SelectedChild.Delete}" />
<KeyBinding Gesture="Ctrl+D" Command="{CompiledBinding SelectedChild.Duplicate}" />
<KeyBinding Gesture="Ctrl+C" Command="{CompiledBinding SelectedChild.Copy}" />
<KeyBinding Gesture="Ctrl+V" Command="{CompiledBinding SelectedChild.Paste}" />
</TreeView.KeyBindings>
<TreeView.ItemTemplate>
<TreeDataTemplate ItemsSource="{Binding Children}">
<ContentControl Content="{Binding}" x:DataType="profileTree:TreeItemViewModel">
<ContentControl.ContextFlyout>
<MenuFlyout>
<MenuItem Header="Add new folder" Command="{CompiledBinding AddFolder}">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="CreateNewFolder" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Add new layer" Command="{CompiledBinding AddLayer}">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="LayersPlus" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem Header="Duplicate" Command="{CompiledBinding Duplicate}" InputGesture="Ctrl+D">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="ContentDuplicate" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Copy" Command="{CompiledBinding Copy}" InputGesture="Ctrl+C">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="ContentCopy" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Paste" Command="{CompiledBinding Paste}" InputGesture="Ctrl+V">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="ContentPaste" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem Header="Rename" Command="{CompiledBinding Rename}" InputGesture="F2">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="RenameBox" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Delete" Command="{CompiledBinding Delete}" InputGesture="Delete">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="TrashCan" />
</MenuItem.Icon>
</MenuItem>
</MenuFlyout>
</ContentControl.ContextFlyout>
</ContentControl>
</TreeDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Classes="icon-button" ToolTip.Tip="Add new folder to root" Command="{CompiledBinding AddFolder}">
<avalonia:MaterialIcon Kind="FolderAdd" />
</Button>
<Button Classes="icon-button" ToolTip.Tip="Add new layer to root" Command="{CompiledBinding AddLayer}" Margin="2 0 0 0">
<avalonia:MaterialIcon Kind="LayersPlus" />
</Button>
</StackPanel>
</Grid>
</UserControl>
@@ -0,0 +1,128 @@
using System;
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.LogicalTree;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.ReactiveUI;
using Avalonia.VisualTree;
namespace Artemis.UI.Screens.ProfileEditor.ProfileTree;
public class ProfileTreeView : ReactiveUserControl<ProfileTreeViewModel>
{
private TreeView _treeView;
private Image? _dragAdorner;
private Point _dragStartPosition;
private Point _elementDragOffset;
public ProfileTreeView()
{
InitializeComponent();
AddHandler(DragDrop.DragEnterEvent, HandleDragEnterEvent, RoutingStrategies.Direct | RoutingStrategies.Tunnel | RoutingStrategies.Bubble, true);
AddHandler(DragDrop.DragOverEvent, HandleDragOver, RoutingStrategies.Direct | RoutingStrategies.Tunnel | RoutingStrategies.Bubble, true);
AddHandler(PointerEnterEvent, HandlePointerEnter, RoutingStrategies.Direct | RoutingStrategies.Tunnel | RoutingStrategies.Bubble, true);
}
private void HandlePointerEnter(object? sender, PointerEventArgs e)
{
DisposeDragAdorner();
}
private void HandleDragEnterEvent(object? sender, DragEventArgs e)
{
CreateDragAdorner(e);
}
private void CreateDragAdorner(DragEventArgs e)
{
if (_dragAdorner != null)
return;
if (e.Source is not Control c)
return;
// Get the tree view item that raised the event
TreeViewItem? container = c.FindLogicalAncestorOfType<TreeViewItem>();
if (container == null)
return;
// Take a snapshot of said tree view item and add it as an adorner
ITransform? originalTransform = container.RenderTransform;
try
{
_dragStartPosition = e.GetPosition(this.FindAncestorOfType<Window>());
_elementDragOffset = e.GetPosition(container);
RenderTargetBitmap renderTarget = new(new PixelSize((int) container.Bounds.Width, (int) container.Bounds.Height));
container.RenderTransform = new TranslateTransform(container.Bounds.X * -1, container.Bounds.Y * -1);
renderTarget.Render(container);
_dragAdorner = new Image
{
Source = renderTarget,
VerticalAlignment = VerticalAlignment.Top,
HorizontalAlignment = HorizontalAlignment.Left,
Stretch = Stretch.None,
IsHitTestVisible = false
};
AdornerLayer.GetAdornerLayer(this)!.Children.Add(_dragAdorner);
}
finally
{
container.RenderTransform = originalTransform;
}
}
private void HandleDragOver(object? sender, DragEventArgs e)
{
UpdateDragAdorner(e);
}
private void HandleLeaveEvent(object? sender, RoutedEventArgs e)
{
// If there is currently an adorner, dispose of it
DisposeDragAdorner();
}
private void HandleDrop(object? sender, DragEventArgs e)
{
// If there is currently an adorner, dispose of it
DisposeDragAdorner();
}
private void DisposeDragAdorner()
{
if (_dragAdorner == null)
return;
AdornerLayer.GetAdornerLayer(this)!.Children.Remove(_dragAdorner);
(_dragAdorner.Source as RenderTargetBitmap)?.Dispose();
_dragAdorner = null;
}
private void UpdateDragAdorner(DragEventArgs e)
{
if (_dragAdorner == null)
return;
Point position = e.GetPosition(this.FindAncestorOfType<Window>());
_dragAdorner.RenderTransform = new TranslateTransform(_dragStartPosition.X - _elementDragOffset.X, position.Y - _elementDragOffset.Y);
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
_treeView = this.Get<TreeView>("ProfileTreeView");
}
private void ProfileTreeView_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_treeView.Focus();
}
}
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive;
using System.Reactive.Disposables;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.Ninject.Factories;
using Artemis.UI.Shared.Services.Interfaces;
using Artemis.UI.Shared.Services.ProfileEditor;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.ProfileTree
{
public class ProfileTreeViewModel : TreeItemViewModel
{
private TreeItemViewModel? _selectedChild;
public ProfileTreeViewModel(IWindowService windowService,
IProfileEditorService profileEditorService,
ILayerBrushService layerBrushService,
IProfileEditorVmFactory profileEditorVmFactory,
IRgbService rgbService)
: base(null, null, windowService, profileEditorService, rgbService, layerBrushService, profileEditorVmFactory)
{
this.WhenActivated(d =>
{
profileEditorService.ProfileConfiguration.WhereNotNull().Subscribe(configuration =>
{
if (configuration.Profile == null)
{
windowService.ShowConfirmContentDialog("Failed to load profile", "It appears that this profile is corrupt and cannot be loaded. Please check your logs.", "Confirm", null);
return;
}
ProfileElement = configuration.Profile.GetRootFolder();
SubscribeToProfileElement(d);
CreateTreeItems();
}).DisposeWith(d);
profileEditorService.ProfileElement.Subscribe(SelectCurrentProfileElement).DisposeWith(d);
});
this.WhenAnyValue(vm => vm.SelectedChild).Subscribe(model =>
{
if (model?.ProfileElement is RenderProfileElement renderProfileElement)
profileEditorService.ChangeCurrentProfileElement(renderProfileElement);
});
ClearSelection = ReactiveCommand.Create(() => profileEditorService.ChangeCurrentProfileElement(null));
}
public ReactiveCommand<Unit, Unit> ClearSelection { get; }
public TreeItemViewModel? SelectedChild
{
get => _selectedChild;
set => RaiseAndSetIfChanged(ref _selectedChild, value);
}
private void SelectCurrentProfileElement(RenderProfileElement? element)
{
if (SelectedChild?.ProfileElement == element)
return;
// Find the tree item belonging to the selected element
List<TreeItemViewModel> treeItems = GetAllTreeItems(Children);
TreeItemViewModel? selected = treeItems.FirstOrDefault(e => e.ProfileElement == element);
// Walk up the tree, expanding parents
TreeItemViewModel? currentParent = selected?.Parent;
while (currentParent != null)
{
currentParent.IsExpanded = true;
currentParent = currentParent.Parent;
}
SelectedChild = selected;
}
private List<TreeItemViewModel> GetAllTreeItems(ObservableCollection<TreeItemViewModel> treeItems)
{
List<TreeItemViewModel> result = new();
foreach (TreeItemViewModel treeItemViewModel in treeItems)
{
result.Add(treeItemViewModel);
if (treeItemViewModel.Children.Any())
result.AddRange(GetAllTreeItems(treeItemViewModel.Children));
}
return result;
}
public override bool SupportsChildren => true;
}
}
@@ -0,0 +1,239 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.Ninject.Factories;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.Interfaces;
using Artemis.UI.Shared.Services.ProfileEditor;
using Artemis.UI.Shared.Services.ProfileEditor.Commands;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.ProfileTree;
public abstract class TreeItemViewModel : ActivatableViewModelBase
{
private readonly IProfileEditorService _profileEditorService;
private readonly IProfileEditorVmFactory _profileEditorVmFactory;
private readonly IWindowService _windowService;
private RenderProfileElement? _currentProfileElement;
private bool _isExpanded;
private ProfileElement? _profileElement;
private string? _renameValue;
private bool _renaming;
protected TreeItemViewModel(TreeItemViewModel? parent, ProfileElement? profileElement,
IWindowService windowService,
IProfileEditorService profileEditorService,
IRgbService rgbService,
ILayerBrushService layerBrushService,
IProfileEditorVmFactory profileEditorVmFactory)
{
_windowService = windowService;
_profileEditorService = profileEditorService;
_profileEditorVmFactory = profileEditorVmFactory;
Parent = parent;
ProfileElement = profileElement;
AddLayer = ReactiveCommand.Create(() =>
{
if (ProfileElement is Layer targetLayer)
{
Layer layer = new(targetLayer.Parent, targetLayer.GetNewLayerName());
layerBrushService.ApplyDefaultBrush(layer);
layer.AddLeds(rgbService.EnabledDevices.SelectMany(d => d.Leds));
profileEditorService.ExecuteCommand(new AddProfileElement(layer, targetLayer.Parent, targetLayer.Order));
}
else if (ProfileElement != null)
{
Layer layer = new(ProfileElement, ProfileElement.GetNewLayerName());
layerBrushService.ApplyDefaultBrush(layer);
layer.AddLeds(rgbService.EnabledDevices.SelectMany(d => d.Leds));
profileEditorService.ExecuteCommand(new AddProfileElement(layer, ProfileElement, 0));
}
});
AddFolder = ReactiveCommand.Create(() =>
{
if (ProfileElement is Layer targetLayer)
profileEditorService.ExecuteCommand(new AddProfileElement(new Folder(targetLayer.Parent, targetLayer.Parent.GetNewFolderName()), targetLayer.Parent, targetLayer.Order));
else if (ProfileElement != null)
profileEditorService.ExecuteCommand(new AddProfileElement(new Folder(ProfileElement, ProfileElement.GetNewFolderName()), ProfileElement, 0));
});
Rename = ReactiveCommand.Create(() =>
{
Renaming = true;
RenameValue = ProfileElement?.Name;
});
Duplicate = ReactiveCommand.Create(() => throw new NotImplementedException());
Copy = ReactiveCommand.Create(() => throw new NotImplementedException());
Paste = ReactiveCommand.Create(() => throw new NotImplementedException());
Delete = ReactiveCommand.Create(() =>
{
if (ProfileElement is RenderProfileElement renderProfileElement)
profileEditorService.ExecuteCommand(new RemoveProfileElement(renderProfileElement));
});
this.WhenActivated(d =>
{
_profileEditorService.ProfileElement.Subscribe(element => _currentProfileElement = element).DisposeWith(d);
SubscribeToProfileElement(d);
CreateTreeItems();
});
}
public ProfileElement? ProfileElement
{
get => _profileElement;
set => RaiseAndSetIfChanged(ref _profileElement, value);
}
public bool IsExpanded
{
get => _isExpanded;
set => RaiseAndSetIfChanged(ref _isExpanded, value);
}
public bool Renaming
{
get => _renaming;
set => RaiseAndSetIfChanged(ref _renaming, value);
}
public TreeItemViewModel? Parent { get; set; }
public ObservableCollection<TreeItemViewModel> Children { get; } = new();
public ReactiveCommand<Unit, Unit> AddLayer { get; }
public ReactiveCommand<Unit, Unit> AddFolder { get; }
public ReactiveCommand<Unit, Unit> Rename { get; }
public ReactiveCommand<Unit, Unit> Duplicate { get; }
public ReactiveCommand<Unit, Unit> Copy { get; }
public ReactiveCommand<Unit, Unit> Paste { get; }
public ReactiveCommand<Unit, Unit> Delete { get; }
public abstract bool SupportsChildren { get; }
public string? RenameValue
{
get => _renameValue;
set => RaiseAndSetIfChanged(ref _renameValue, value);
}
public async Task ShowBrokenStateExceptions()
{
if (ProfileElement == null)
return;
List<IBreakableModel> broken = ProfileElement.GetBrokenHierarchy().Where(b => b.BrokenStateException != null).ToList();
foreach (IBreakableModel current in broken)
{
_windowService.ShowExceptionDialog($"{current.BrokenDisplayName} - {current.BrokenState}", current.BrokenStateException!);
if (broken.Last() != current)
if (!await _windowService.ShowConfirmContentDialog("Broken state", "Do you want to view the next exception?"))
return;
}
}
public void SubmitRename()
{
if (ProfileElement == null)
{
Renaming = false;
return;
}
_profileEditorService.ExecuteCommand(new RenameProfileElement(ProfileElement, RenameValue));
Renaming = false;
}
public void CancelRename()
{
Renaming = false;
}
public void InsertElement(TreeItemViewModel elementViewModel, int targetIndex)
{
if (elementViewModel.Parent == this && Children.IndexOf(elementViewModel) == targetIndex)
return;
if (ProfileElement != null && elementViewModel.ProfileElement != null)
_profileEditorService.ExecuteCommand(new MoveProfileElement(ProfileElement, elementViewModel.ProfileElement, targetIndex));
}
protected void SubscribeToProfileElement(CompositeDisposable d)
{
if (ProfileElement == null)
return;
Observable.FromEventPattern<ProfileElementEventArgs>(x => ProfileElement.ChildAdded += x, x => ProfileElement.ChildAdded -= x)
.Subscribe(c => AddTreeItemIfMissing(c.EventArgs.ProfileElement)).DisposeWith(d);
Observable.FromEventPattern<ProfileElementEventArgs>(x => ProfileElement.ChildRemoved += x, x => ProfileElement.ChildRemoved -= x)
.Subscribe(c => RemoveTreeItemsIfFound(c.Sender, c.EventArgs.ProfileElement)).DisposeWith(d);
}
protected void RemoveTreeItemsIfFound(object? sender, ProfileElement profileElement)
{
List<TreeItemViewModel> toRemove = Children.Where(t => t.ProfileElement == profileElement).ToList();
foreach (TreeItemViewModel treeItemViewModel in toRemove)
Children.Remove(treeItemViewModel);
if (_currentProfileElement != profileElement)
return;
// Find a good candidate for a new selection, preferring the next sibling, falling back to the previous sibling and finally the parent
ProfileElement? parent = sender as ProfileElement;
ProfileElement? newSelection = null;
if (parent != null)
{
newSelection = parent.Children.FirstOrDefault(c => c.Order == profileElement.Order) ?? parent.Children.FirstOrDefault(c => c.Order == profileElement.Order - 1);
if (newSelection == null && parent is Folder {IsRootFolder: false})
newSelection = parent;
}
_profileEditorService.ChangeCurrentProfileElement(newSelection as RenderProfileElement);
}
protected void AddTreeItemIfMissing(ProfileElement profileElement)
{
if (Children.Any(t => t.ProfileElement == profileElement))
return;
if (profileElement is Folder folder)
Children.Insert(folder.Parent.Children.IndexOf(folder), _profileEditorVmFactory.FolderTreeItemViewModel(this, folder));
else if (profileElement is Layer layer)
Children.Insert(layer.Parent.Children.IndexOf(layer), _profileEditorVmFactory.LayerTreeItemViewModel(this, layer));
// Select the newly added element
if (profileElement is RenderProfileElement renderProfileElement)
_profileEditorService.ChangeCurrentProfileElement(renderProfileElement);
}
protected void CreateTreeItems()
{
if (Children.Any())
Children.Clear();
if (ProfileElement == null)
return;
foreach (ProfileElement profileElement in ProfileElement.Children)
{
if (profileElement is Folder folder)
Children.Add(_profileEditorVmFactory.FolderTreeItemViewModel(this, folder));
else if (profileElement is Layer layer)
Children.Add(_profileEditorVmFactory.LayerTreeItemViewModel(this, layer));
}
}
}
@@ -0,0 +1,147 @@
<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:controls="clr-namespace:Artemis.UI.Controls"
xmlns:local="clr-namespace:Artemis.UI.Screens.ProfileEditor.Properties"
xmlns:converters="clr-namespace:Artemis.UI.Converters"
mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="350"
x:Class="Artemis.UI.Screens.ProfileEditor.Properties.PropertiesView"
x:DataType="local:PropertiesViewModel">
<UserControl.Styles>
<StyleInclude Source="/Screens/ProfileEditor/Panels/Properties/Timeline/Segments/Segment.axaml" />
</UserControl.Styles>
<UserControl.Resources>
<converters:DoubleToGridLengthConverter x:Key="DoubleToGridLengthConverter"></converters:DoubleToGridLengthConverter>
</UserControl.Resources>
<Grid Name="ContainerGrid">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="{CompiledBinding PropertiesTreeWidth.Value, Mode=TwoWay, Converter={StaticResource DoubleToGridLengthConverter}}" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid RowDefinitions="48,*">
<ContentControl Grid.Row="0" Content="{CompiledBinding PlaybackViewModel}" />
<ScrollViewer Grid.Row="1"
Grid.Column="0"
Name="TreeScrollViewer"
Offset="{CompiledBinding #TimelineScrollViewer.Offset, Mode=OneWay}"
Background="{DynamicResource CardStrokeColorDefaultSolidBrush}">
<ItemsControl Items="{CompiledBinding PropertyGroupViewModels}" Padding="0 0 8 0">
<ItemsControl.ItemTemplate>
<TreeDataTemplate DataType="{x:Type local:PropertyGroupViewModel}" ItemsSource="{CompiledBinding Children}">
<ContentControl Content="{CompiledBinding TreeGroupViewModel}" />
</TreeDataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
<GridSplitter Grid.Column="1"
Cursor="SizeWestEast"
Foreground="Transparent"
Background="Transparent"
Margin="0 0 -5 0" />
<!-- Horizontal scrolling -->
<ScrollViewer Grid.Column="2" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<Grid RowDefinitions="48,*">
<!-- Timeline header body -->
<controls:TimelineHeader Grid.Row="0"
Name="TimelineHeader"
Margin="0 18 0 0"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
HorizontalAlignment="Left"
PixelsPerSecond="{CompiledBinding PixelsPerSecond}"
HorizontalOffset="{CompiledBinding #TimelineScrollViewer.Offset.X, Mode=OneWay}"
VisibleWidth="{CompiledBinding #TimelineScrollViewer.Bounds.Width}"
OffsetFirstValue="True"
PointerReleased="TimelineHeader_OnPointerReleased"
Width="{CompiledBinding #TimelineScrollViewer.Viewport.Width}"
Cursor="Hand" />
<Canvas Grid.Row="0" ZIndex="2">
<!-- Segment dividers -->
<Line Name="TimelineLine"
Canvas.Left="{CompiledBinding TimelineViewModel.CaretPosition}"
Cursor="SizeWestEast"
PointerPressed="TimelineCaret_OnPointerPressed"
PointerReleased="TimelineCaret_OnPointerReleased"
PointerMoved="TimelineCaret_OnPointerMoved"
StartPoint="0,0"
EndPoint="{CompiledBinding #ContainerGrid.Bounds.BottomLeft}"
StrokeThickness="2"
Stroke="{DynamicResource SystemAccentColorLight1}">
</Line>
<Line Name="StartSegmentLine"
Canvas.Left="{CompiledBinding TimelineViewModel.StartSegmentViewModel.EndX}"
IsVisible="{CompiledBinding !TimelineViewModel.MainSegmentViewModel.ShowAddStart}"
StartPoint="0,0"
EndPoint="{CompiledBinding #ContainerGrid.Bounds.BottomLeft}"
StrokeThickness="2"
Stroke="{DynamicResource SystemAccentColorLight1}"
StrokeDashArray="6,2"
Opacity="0.5">
</Line>
<Line Name="MainSegmentLine"
Canvas.Left="{CompiledBinding TimelineViewModel.MainSegmentViewModel.EndX}"
IsVisible="{CompiledBinding !TimelineViewModel.MainSegmentViewModel.ShowAddMain}"
StartPoint="0,0"
EndPoint="{CompiledBinding #ContainerGrid.Bounds.BottomLeft}"
StrokeThickness="2"
Stroke="{DynamicResource SystemAccentColorLight1}"
StrokeDashArray="6,2"
Opacity="0.5">
</Line>
<Line Name="EndSegmentLine"
Canvas.Left="{CompiledBinding TimelineViewModel.EndSegmentViewModel.EndX}"
IsVisible="{CompiledBinding !TimelineViewModel.MainSegmentViewModel.ShowAddEnd}"
StartPoint="0,0"
EndPoint="{CompiledBinding #ContainerGrid.Bounds.BottomLeft}"
StrokeThickness="2"
Stroke="{DynamicResource SystemAccentColorLight1}"
StrokeDashArray="6,2"
Opacity="0.5">
</Line>
<!-- Timeline segments -->
<ContentControl Canvas.Left="{CompiledBinding TimelineViewModel.EndSegmentViewModel.StartX}"
Classes="segment-content-control"
Content="{CompiledBinding TimelineViewModel.EndSegmentViewModel}" />
<ContentControl Canvas.Left="{CompiledBinding TimelineViewModel.MainSegmentViewModel.StartX}"
Classes="segment-content-control"
Content="{CompiledBinding TimelineViewModel.MainSegmentViewModel}" />
<ContentControl Canvas.Left="{CompiledBinding TimelineViewModel.StartSegmentViewModel.StartX}"
Classes="segment-content-control"
Content="{CompiledBinding TimelineViewModel.StartSegmentViewModel}" />
<!-- Timeline caret -->
<Polygon Name="TimelineCaret"
Canvas.Left="{CompiledBinding TimelineViewModel.CaretPosition}"
Cursor="SizeWestEast"
PointerPressed="TimelineCaret_OnPointerPressed"
PointerReleased="TimelineCaret_OnPointerReleased"
PointerMoved="TimelineCaret_OnPointerMoved"
Points="-8,0 -8,8 0,20, 8,8 8,0"
Fill="{DynamicResource SystemAccentColorLight1}">
</Polygon>
</Canvas>
<ScrollViewer Grid.Row="1"
Name="TimelineScrollViewer"
Offset="{CompiledBinding #TreeScrollViewer.Offset, Mode=OneWay}"
VerticalScrollBarVisibility="Hidden">
<ContentControl Content="{CompiledBinding TimelineViewModel}"
Background="{DynamicResource CardStrokeColorDefaultSolidBrush}" />
</ScrollViewer>
<!-- TODO: Databindings here -->
</Grid>
</ScrollViewer>
</Grid>
</UserControl>
@@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
using Avalonia.VisualTree;
namespace Artemis.UI.Screens.ProfileEditor.Properties;
public class PropertiesView : ReactiveUserControl<PropertiesViewModel>
{
private readonly Polygon _timelineCaret;
private readonly Line _timelineLine;
public PropertiesView()
{
InitializeComponent();
_timelineCaret = this.Get<Polygon>("TimelineCaret");
_timelineLine = this.Get<Line>("TimelineLine");
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void ApplyTransition(bool enable)
{
// if (enable)
// {
// ((DoubleTransition) _timelineCaret.Transitions![0]).Duration = TimeSpan.FromMilliseconds(50);
// ((DoubleTransition) _timelineLine.Transitions![0]).Duration = TimeSpan.FromMilliseconds(50);
// }
// else
// {
// ((DoubleTransition) _timelineCaret.Transitions![0]).Duration = TimeSpan.Zero;
// ((DoubleTransition) _timelineLine.Transitions![0]).Duration = TimeSpan.Zero;
// }
}
private void TimelineCaret_OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
e.Pointer.Capture((IInputElement?) sender);
ApplyTransition(false);
}
private void TimelineCaret_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
e.Pointer.Capture(null);
ApplyTransition(true);
}
private void TimelineCaret_OnPointerMoved(object? sender, PointerEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || ViewModel == null)
return;
IInputElement? senderElement = (IInputElement?) sender;
if (senderElement == null)
return;
// Get the parent grid, need that for our position
IVisual? parent = senderElement.VisualParent;
double x = Math.Max(0, e.GetPosition(parent).X);
TimeSpan newTime = TimeSpan.FromSeconds(x / ViewModel.PixelsPerSecond);
newTime = RoundTime(newTime);
// If holding down shift, snap to the closest segment or keyframe
if (e.KeyModifiers.HasFlag(KeyModifiers.Shift))
{
List<TimeSpan> snapTimes = ViewModel.PropertyGroupViewModels.SelectMany(g => g.GetAllKeyframeViewModels(true)).Select(k => k.Position).ToList();
newTime = ViewModel.TimelineViewModel.SnapToTimeline(newTime, true, false, snapTimes);
}
// If holding down control, round to the closest 50ms
if (e.KeyModifiers.HasFlag(KeyModifiers.Control))
newTime = TimeSpan.FromMilliseconds(Math.Round(newTime.TotalMilliseconds / 50.0) * 50.0);
ViewModel.TimelineViewModel.ChangeTime(newTime);
}
private void TimelineHeader_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (ViewModel == null || sender is not IInputElement senderElement)
return;
// Get the parent grid, need that for our position
double x = Math.Max(0, e.GetPosition(senderElement.VisualParent).X);
TimeSpan newTime = TimeSpan.FromSeconds(x / ViewModel.PixelsPerSecond);
ViewModel.TimelineViewModel.ChangeTime(RoundTime(newTime));
}
private TimeSpan RoundTime(TimeSpan time)
{
// Round the time to something that fits the current zoom level
if (ViewModel!.PixelsPerSecond < 200)
return TimeSpan.FromMilliseconds(Math.Round(time.TotalMilliseconds / 5.0) * 5.0);
if (ViewModel.PixelsPerSecond < 500)
return TimeSpan.FromMilliseconds(Math.Round(time.TotalMilliseconds / 2.0) * 2.0);
return TimeSpan.FromMilliseconds(Math.Round(time.TotalMilliseconds));
}
}
@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.Core.LayerBrushes;
using Artemis.Core.LayerEffects;
using Artemis.Core.Services;
using Artemis.UI.Ninject.Factories;
using Artemis.UI.Screens.ProfileEditor.Playback;
using Artemis.UI.Screens.ProfileEditor.Properties.Timeline;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.ProfileEditor;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties;
public class PropertiesViewModel : ActivatableViewModelBase
{
private readonly Dictionary<LayerPropertyGroup, PropertyGroupViewModel> _cachedViewModels;
private readonly ILayerPropertyVmFactory _layerPropertyVmFactory;
private readonly IProfileEditorService _profileEditorService;
private readonly ISettingsService _settingsService;
private ObservableAsPropertyHelper<int>? _pixelsPerSecond;
private ObservableAsPropertyHelper<RenderProfileElement?>? _profileElement;
/// <inheritdoc />
public PropertiesViewModel(IProfileEditorService profileEditorService, ISettingsService settingsService, ILayerPropertyVmFactory layerPropertyVmFactory, PlaybackViewModel playbackViewModel)
{
_profileEditorService = profileEditorService;
_settingsService = settingsService;
_layerPropertyVmFactory = layerPropertyVmFactory;
_cachedViewModels = new Dictionary<LayerPropertyGroup, PropertyGroupViewModel>();
PropertyGroupViewModels = new ObservableCollection<PropertyGroupViewModel>();
PlaybackViewModel = playbackViewModel;
TimelineViewModel = layerPropertyVmFactory.TimelineViewModel(PropertyGroupViewModels);
// Subscribe to events of the latest selected profile element - borrowed from https://stackoverflow.com/a/63950940
this.WhenAnyValue(vm => vm.ProfileElement)
.Select(p => p is Layer l
? Observable.FromEventPattern(x => l.LayerBrushUpdated += x, x => l.LayerBrushUpdated -= x)
: Observable.Never<EventPattern<object>>())
.Switch()
.Subscribe(_ => UpdateGroups());
this.WhenAnyValue(vm => vm.ProfileElement)
.Select(p => p != null
? Observable.FromEventPattern(x => p.LayerEffectsUpdated += x, x => p.LayerEffectsUpdated -= x)
: Observable.Never<EventPattern<object>>())
.Switch()
.Subscribe(_ => UpdateGroups());
// React to service profile element changes as long as the VM is active
this.WhenActivated(d =>
{
_profileElement = profileEditorService.ProfileElement.ToProperty(this, vm => vm.ProfileElement).DisposeWith(d);
_pixelsPerSecond = profileEditorService.PixelsPerSecond.ToProperty(this, vm => vm.PixelsPerSecond).DisposeWith(d);
Disposable.Create(() => _settingsService.SaveAllSettings()).DisposeWith(d);
});
this.WhenAnyValue(vm => vm.ProfileElement).Subscribe(_ => UpdateGroups());
}
public ObservableCollection<PropertyGroupViewModel> PropertyGroupViewModels { get; }
public PlaybackViewModel PlaybackViewModel { get; }
public TimelineViewModel TimelineViewModel { get; }
public RenderProfileElement? ProfileElement => _profileElement?.Value;
public Layer? Layer => _profileElement?.Value as Layer;
public int PixelsPerSecond => _pixelsPerSecond?.Value ?? 0;
public IObservable<bool> Playing => _profileEditorService.Playing;
public PluginSetting<double> PropertiesTreeWidth => _settingsService.GetSetting("ProfileEditor.PropertiesTreeWidth", 500.0);
private void UpdateGroups()
{
if (ProfileElement == null)
{
PropertyGroupViewModels.Clear();
return;
}
ObservableCollection<PropertyGroupViewModel> viewModels = new();
if (Layer != null)
{
// Add base VMs
viewModels.Add(GetOrCreateViewModel(Layer.General, null, null));
viewModels.Add(GetOrCreateViewModel(Layer.Transform, null, null));
// Add brush VM if the brush has properties
if (Layer.LayerBrush?.BaseProperties != null)
viewModels.Add(GetOrCreateViewModel(Layer.LayerBrush.BaseProperties, Layer.LayerBrush, null));
}
// Add effect VMs
foreach (BaseLayerEffect layerEffect in ProfileElement.LayerEffects.OrderBy(e => e.Order))
if (layerEffect.BaseProperties != null)
viewModels.Add(GetOrCreateViewModel(layerEffect.BaseProperties, null, layerEffect));
// Map the most recent collection of VMs to the current list of VMs, making as little changes to the collection as possible
for (int index = 0; index < viewModels.Count; index++)
{
PropertyGroupViewModel propertyGroupViewModel = viewModels[index];
if (index > PropertyGroupViewModels.Count - 1)
PropertyGroupViewModels.Add(propertyGroupViewModel);
else if (!ReferenceEquals(PropertyGroupViewModels[index], propertyGroupViewModel))
PropertyGroupViewModels[index] = propertyGroupViewModel;
}
while (PropertyGroupViewModels.Count > viewModels.Count)
PropertyGroupViewModels.RemoveAt(PropertyGroupViewModels.Count - 1);
}
private PropertyGroupViewModel GetOrCreateViewModel(LayerPropertyGroup layerPropertyGroup, BaseLayerBrush? layerBrush, BaseLayerEffect? layerEffect)
{
if (_cachedViewModels.TryGetValue(layerPropertyGroup, out PropertyGroupViewModel? cachedVm))
return cachedVm;
PropertyGroupViewModel createdVm;
if (layerBrush != null)
createdVm = _layerPropertyVmFactory.PropertyGroupViewModel(layerPropertyGroup, layerBrush);
else if (layerEffect != null)
createdVm = _layerPropertyVmFactory.PropertyGroupViewModel(layerPropertyGroup, layerEffect);
else
createdVm = _layerPropertyVmFactory.PropertyGroupViewModel(layerPropertyGroup);
_cachedViewModels[layerPropertyGroup] = createdVm;
return createdVm;
}
}
@@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reflection;
using Artemis.Core;
using Artemis.Core.LayerBrushes;
using Artemis.Core.LayerEffects;
using Artemis.UI.Ninject.Factories;
using Artemis.UI.Screens.ProfileEditor.Properties.Timeline;
using Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Keyframes;
using Artemis.UI.Screens.ProfileEditor.Properties.Tree;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.PropertyInput;
namespace Artemis.UI.Screens.ProfileEditor.Properties;
public class PropertyGroupViewModel : ViewModelBase, IDisposable
{
private readonly ILayerPropertyVmFactory _layerPropertyVmFactory;
private readonly IPropertyInputService _propertyInputService;
private bool _hasChildren;
private bool _isExpanded;
private bool _isVisible;
public PropertyGroupViewModel(LayerPropertyGroup layerPropertyGroup, ILayerPropertyVmFactory layerPropertyVmFactory, IPropertyInputService propertyInputService)
{
_layerPropertyVmFactory = layerPropertyVmFactory;
_propertyInputService = propertyInputService;
Children = new ObservableCollection<ViewModelBase>();
LayerPropertyGroup = layerPropertyGroup;
TreeGroupViewModel = layerPropertyVmFactory.TreeGroupViewModel(this);
TimelineGroupViewModel = layerPropertyVmFactory.TimelineGroupViewModel(this);
LayerPropertyGroup.VisibilityChanged += LayerPropertyGroupOnVisibilityChanged;
_isVisible = !LayerPropertyGroup.IsHidden;
PopulateChildren();
}
public PropertyGroupViewModel(LayerPropertyGroup layerPropertyGroup, ILayerPropertyVmFactory layerPropertyVmFactory, IPropertyInputService propertyInputService,
BaseLayerBrush layerBrush)
: this(layerPropertyGroup, layerPropertyVmFactory, propertyInputService)
{
LayerBrush = layerBrush;
}
public PropertyGroupViewModel(LayerPropertyGroup layerPropertyGroup, ILayerPropertyVmFactory layerPropertyVmFactory, IPropertyInputService propertyInputService,
BaseLayerEffect layerEffect)
: this(layerPropertyGroup, layerPropertyVmFactory, propertyInputService)
{
LayerEffect = layerEffect;
}
public ObservableCollection<ViewModelBase> Children { get; }
public LayerPropertyGroup LayerPropertyGroup { get; }
public BaseLayerBrush? LayerBrush { get; }
public BaseLayerEffect? LayerEffect { get; }
public TreeGroupViewModel TreeGroupViewModel { get; }
public TimelineGroupViewModel TimelineGroupViewModel { get; }
public bool IsVisible
{
get => _isVisible;
set => RaiseAndSetIfChanged(ref _isVisible, value);
}
public bool IsExpanded
{
get => _isExpanded;
set => RaiseAndSetIfChanged(ref _isExpanded, value);
}
public bool HasChildren
{
get => _hasChildren;
set => RaiseAndSetIfChanged(ref _hasChildren, value);
}
public List<ITimelineKeyframeViewModel> GetAllKeyframeViewModels(bool expandedOnly)
{
List<ITimelineKeyframeViewModel> result = new();
if (expandedOnly && !IsExpanded)
return result;
foreach (ViewModelBase child in Children)
{
if (child is PropertyViewModel profileElementPropertyViewModel)
result.AddRange(profileElementPropertyViewModel.TimelinePropertyViewModel.GetAllKeyframeViewModels());
else if (child is PropertyGroupViewModel profileElementPropertyGroupViewModel)
result.AddRange(profileElementPropertyGroupViewModel.GetAllKeyframeViewModels(expandedOnly));
}
return result;
}
private void PopulateChildren()
{
// Get all properties and property groups and create VMs for them
// The group has methods for getting this without reflection but then we lose the order of the properties as they are defined on the group
foreach (PropertyInfo propertyInfo in LayerPropertyGroup.GetType().GetProperties())
{
if (Attribute.IsDefined(propertyInfo, typeof(LayerPropertyIgnoreAttribute)))
continue;
if (typeof(ILayerProperty).IsAssignableFrom(propertyInfo.PropertyType))
{
ILayerProperty? value = (ILayerProperty?) propertyInfo.GetValue(LayerPropertyGroup);
// Ensure a supported input VM was found, otherwise don't add it
if (value != null && _propertyInputService.CanCreatePropertyInputViewModel(value))
Children.Add(_layerPropertyVmFactory.PropertyViewModel(value));
}
else if (typeof(LayerPropertyGroup).IsAssignableFrom(propertyInfo.PropertyType))
{
LayerPropertyGroup? value = (LayerPropertyGroup?) propertyInfo.GetValue(LayerPropertyGroup);
if (value != null)
Children.Add(_layerPropertyVmFactory.PropertyGroupViewModel(value));
}
}
HasChildren = Children.Any(i => i is PropertyViewModel {IsVisible: true} || i is PropertyGroupViewModel {IsVisible: true});
}
private void LayerPropertyGroupOnVisibilityChanged(object? sender, EventArgs e)
{
IsVisible = !LayerPropertyGroup.IsHidden;
}
/// <inheritdoc />
public void Dispose()
{
LayerPropertyGroup.VisibilityChanged -= LayerPropertyGroupOnVisibilityChanged;
foreach (ViewModelBase viewModelBase in Children)
{
if (viewModelBase is IDisposable disposable)
disposable.Dispose();
}
}
}
@@ -0,0 +1,58 @@
using System;
using Artemis.Core;
using Artemis.UI.Ninject.Factories;
using Artemis.UI.Screens.ProfileEditor.Properties.Timeline;
using Artemis.UI.Screens.ProfileEditor.Properties.Tree;
using Artemis.UI.Shared;
namespace Artemis.UI.Screens.ProfileEditor.Properties;
public class PropertyViewModel : ViewModelBase, IDisposable
{
private bool _isExpanded;
private bool _isHighlighted;
private bool _isVisible;
public PropertyViewModel(ILayerProperty layerProperty, IPropertyVmFactory propertyVmFactory)
{
LayerProperty = layerProperty;
TreePropertyViewModel = propertyVmFactory.TreePropertyViewModel(LayerProperty, this);
TimelinePropertyViewModel = propertyVmFactory.TimelinePropertyViewModel(LayerProperty, this);
LayerProperty.VisibilityChanged += LayerPropertyOnVisibilityChanged;
_isVisible = !LayerProperty.IsHidden;
}
public ILayerProperty LayerProperty { get; }
public ITreePropertyViewModel TreePropertyViewModel { get; }
public ITimelinePropertyViewModel TimelinePropertyViewModel { get; }
public bool IsVisible
{
get => _isVisible;
set => RaiseAndSetIfChanged(ref _isVisible, value);
}
public bool IsHighlighted
{
get => _isHighlighted;
set => RaiseAndSetIfChanged(ref _isHighlighted, value);
}
public bool IsExpanded
{
get => _isExpanded;
set => RaiseAndSetIfChanged(ref _isExpanded, value);
}
private void LayerPropertyOnVisibilityChanged(object? sender, LayerPropertyEventArgs e)
{
IsVisible = !LayerProperty.IsHidden;
}
/// <inheritdoc />
public void Dispose()
{
LayerProperty.VisibilityChanged -= LayerPropertyOnVisibilityChanged;
}
}
@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Keyframes;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline;
public interface ITimelinePropertyViewModel : IReactiveObject
{
List<ITimelineKeyframeViewModel> GetAllKeyframeViewModels();
void WipeKeyframes(TimeSpan? start, TimeSpan? end);
void ShiftKeyframes(TimeSpan? start, TimeSpan? end, TimeSpan amount);
}
@@ -0,0 +1,31 @@
using System;
using Artemis.Core;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Keyframes;
public interface ITimelineKeyframeViewModel
{
bool IsSelected { get; }
TimeSpan Position { get; }
ILayerPropertyKeyframe Keyframe { get; }
#region Movement
void Select(bool expand, bool toggle);
void StartMovement(ITimelineKeyframeViewModel source);
void UpdateMovement(TimeSpan position);
void FinishMovement();
TimeSpan GetTimeSpanAtPosition(double x);
#endregion
#region Context menu actions
void PopulateEasingViewModels();
void Duplicate();
void Copy();
void Paste();
void Delete();
#endregion
}
@@ -0,0 +1,19 @@
<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:keyframes="clr-namespace:Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Keyframes"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Keyframes.TimelineEasingView"
x:DataType="keyframes:TimelineEasingViewModel">
<StackPanel Orientation="Horizontal">
<Polyline Stroke="{DynamicResource TextFillColorPrimaryBrush}"
StrokeThickness="1"
Points="{CompiledBinding EasingPoints}"
Stretch="Uniform"
Width="20"
Height="20"
Margin="0 0 10 0" />
<TextBlock Text="{CompiledBinding Description}" />
</StackPanel>
</UserControl>
@@ -0,0 +1,18 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Keyframes
{
public partial class TimelineEasingView : UserControl
{
public TimelineEasingView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
}
@@ -0,0 +1,33 @@
using System.Collections.Generic;
using Artemis.Core;
using Artemis.UI.Shared;
using Avalonia;
using Humanizer;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Keyframes;
public class TimelineEasingViewModel : ViewModelBase
{
private readonly ILayerPropertyKeyframe _keyframe;
public TimelineEasingViewModel(Easings.Functions easingFunction, ILayerPropertyKeyframe keyframe)
{
_keyframe = keyframe;
EasingFunction = easingFunction;
Description = easingFunction.Humanize();
EasingPoints = new List<Point>();
for (int i = 1; i <= 10; i++)
{
int x = i;
double y = Easings.Interpolate(i / 10.0, EasingFunction) * 10;
EasingPoints.Add(new Point(x, y));
}
}
public Easings.Functions EasingFunction { get; }
public List<Point> EasingPoints { get; }
public string Description { get; }
public bool IsEasingModeSelected => _keyframe.EasingFunction == EasingFunction;
}
@@ -0,0 +1,80 @@
<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:timeline="clr-namespace:Artemis.UI.Screens.ProfileEditor.Properties.Timeline"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Keyframes.TimelineKeyframeView"
ClipToBounds="False"
Height="{DynamicResource RailsHeight}">
<Ellipse Fill="{DynamicResource SystemAccentColorLight2}"
Stroke="White"
Width="10"
Height="10"
Margin="-5,0,0,0"
ToolTip.Tip="{Binding Timestamp}"
Classes.selected="{Binding IsSelected}"
PointerPressed="InputElement_OnPointerPressed"
PointerReleased="InputElement_OnPointerReleased"
PointerMoved="InputElement_OnPointerMoved"
Cursor="Hand">
<Ellipse.Styles>
<Style Selector="Ellipse">
<Setter Property="StrokeThickness" Value="0" />
</Style>
<Style Selector="Ellipse.selected">
<Setter Property="StrokeThickness" Value="1" />
</Style>
</Ellipse.Styles>
<Ellipse.Transitions>
<Transitions>
<DoubleTransition Property="StrokeThickness" Duration="0:0:0.25" />
</Transitions>
</Ellipse.Transitions>
<Ellipse.ContextFlyout>
<MenuFlyout Opening="FlyoutBase_OnOpening">
<MenuItem Header="Easing" Items="{Binding EasingViewModels}">
<MenuItem.Styles>
<Style Selector="MenuItem > MenuItem">
<Setter Property="Icon">
<Setter.Value>
<Template>
<CheckBox IsHitTestVisible="False" IsChecked="{Binding IsEasingModeSelected}" />
</Template>
</Setter.Value>
</Setter>
<Setter Property="Command" Value="{Binding $parent[UserControl].DataContext.SelectEasingFunction}"></Setter>
<Setter Property="CommandParameter" Value="{Binding EasingFunction}"></Setter>
</Style>
</MenuItem.Styles>
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Creation" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem Header="Duplicate" Command="{Binding $parent[timeline:TimelineView].DataContext.DuplicateKeyframes}" CommandParameter="{Binding}" InputGesture="Ctrl+D">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="ContentDuplicate" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Copy" Command="{Binding $parent[timeline:TimelineView].DataContext.CopyKeyframes}" CommandParameter="{Binding}" InputGesture="Ctrl+C">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="ContentCopy" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Paste" Command="{Binding $parent[timeline:TimelineView].DataContext.PasteKeyframes}" CommandParameter="{Binding}" InputGesture="Ctrl+V">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="ContentPaste" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem Header="Delete" Command="{Binding $parent[timeline:TimelineView].DataContext.DeleteKeyframes}" CommandParameter="{Binding}" InputGesture="Delete">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Delete" />
</MenuItem.Icon>
</MenuItem>
</MenuFlyout>
</Ellipse.ContextFlyout>
</Ellipse>
</UserControl>
@@ -0,0 +1,85 @@
using System;
using Avalonia.Input;
using Avalonia.LogicalTree;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Keyframes;
public class TimelineKeyframeView : ReactiveUserControl<ITimelineKeyframeViewModel>
{
private TimelinePropertyView? _timelinePropertyView;
private TimelineView? _timelineView;
private bool _moved;
public TimelineKeyframeView()
{
InitializeComponent();
}
#region Overrides of TemplatedControl
/// <inheritdoc />
protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e)
{
_timelineView = this.FindLogicalAncestorOfType<TimelineView>();
_timelinePropertyView = this.FindLogicalAncestorOfType<TimelinePropertyView>();
base.OnAttachedToLogicalTree(e);
}
#endregion
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void InputElement_OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (ViewModel == null || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
return;
e.Pointer.Capture((IInputElement?) sender);
e.Handled = true;
_moved = false;
}
private void InputElement_OnPointerMoved(object? sender, PointerEventArgs e)
{
if (ViewModel == null || _timelineView?.ViewModel == null || e.Pointer.Captured != sender || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
return;
if (!_moved)
_timelineView.ViewModel.StartKeyframeMovement(ViewModel);
_moved = true;
TimeSpan time = ViewModel.GetTimeSpanAtPosition(e.GetPosition(_timelinePropertyView).X);
_timelineView.ViewModel.UpdateKeyframeMovement(ViewModel, time, e.KeyModifiers.HasFlag(KeyModifiers.Shift), e.KeyModifiers.HasFlag(KeyModifiers.Control));
e.Handled = true;
}
private void InputElement_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (ViewModel == null || _timelineView?.ViewModel == null || e.Pointer.Captured != sender || e.InitialPressMouseButton != MouseButton.Left)
return;
e.Pointer.Capture(null);
e.Handled = true;
// Select the keyframe if the user didn't move
if (!_moved)
ViewModel.Select(e.KeyModifiers.HasFlag(KeyModifiers.Shift), e.KeyModifiers.HasFlag(KeyModifiers.Control));
else
{
TimeSpan time = ViewModel.GetTimeSpanAtPosition(e.GetPosition(_timelinePropertyView).X);
_timelineView.ViewModel.FinishKeyframeMovement(ViewModel, time, e.KeyModifiers.HasFlag(KeyModifiers.Shift), e.KeyModifiers.HasFlag(KeyModifiers.Control));
}
}
private void FlyoutBase_OnOpening(object? sender, EventArgs e)
{
ViewModel?.PopulateEasingViewModels();
}
}
@@ -0,0 +1,168 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.ProfileEditor;
using Artemis.UI.Shared.Services.ProfileEditor.Commands;
using Avalonia.Controls.Mixins;
using DynamicData;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Keyframes;
public class TimelineKeyframeViewModel<T> : ActivatableViewModelBase, ITimelineKeyframeViewModel
{
private readonly IProfileEditorService _profileEditorService;
private double _x;
private string _timestamp;
private ObservableAsPropertyHelper<bool>? _isSelected;
public TimelineKeyframeViewModel(LayerPropertyKeyframe<T> layerPropertyKeyframe, IProfileEditorService profileEditorService)
{
_profileEditorService = profileEditorService;
_timestamp = "0.000";
LayerPropertyKeyframe = layerPropertyKeyframe;
EasingViewModels = new ObservableCollection<TimelineEasingViewModel>();
this.WhenActivated(d =>
{
_isSelected = profileEditorService.ConnectToKeyframes().ToCollection().Select(keyframes => keyframes.Contains(LayerPropertyKeyframe)).ToProperty(this, vm => vm.IsSelected).DisposeWith(d);
profileEditorService.ConnectToKeyframes();
profileEditorService.PixelsPerSecond.Subscribe(p =>
{
_pixelsPerSecond = p;
profileEditorService.PixelsPerSecond.Subscribe(_ => Update()).DisposeWith(d);
}).DisposeWith(d);
this.WhenAnyValue(vm => vm.LayerPropertyKeyframe.Position).Subscribe(_ => Update()).DisposeWith(d);
});
}
public LayerPropertyKeyframe<T> LayerPropertyKeyframe { get; }
public ObservableCollection<TimelineEasingViewModel> EasingViewModels { get; }
public double X
{
get => _x;
set => RaiseAndSetIfChanged(ref _x, value);
}
public string Timestamp
{
get => _timestamp;
set => RaiseAndSetIfChanged(ref _timestamp, value);
}
public bool IsSelected => _isSelected?.Value ?? false;
public TimeSpan Position => LayerPropertyKeyframe.Position;
public ILayerPropertyKeyframe Keyframe => LayerPropertyKeyframe;
public void Update()
{
X = _pixelsPerSecond * LayerPropertyKeyframe.Position.TotalSeconds;
Timestamp = $"{Math.Floor(LayerPropertyKeyframe.Position.TotalSeconds):00}.{LayerPropertyKeyframe.Position.Milliseconds:000}";
}
/// <inheritdoc />
public void Select(bool expand, bool toggle)
{
_profileEditorService.SelectKeyframe(Keyframe, expand, toggle);
}
public TimeSpan GetTimeSpanAtPosition(double x)
{
return TimeSpan.FromSeconds(x / _pixelsPerSecond);
}
#region Context menu actions
public void Duplicate()
{
throw new NotImplementedException();
}
public void Copy()
{
throw new NotImplementedException();
}
public void Paste()
{
throw new NotImplementedException();
}
public void Delete()
{
_profileEditorService.ExecuteCommand(new DeleteKeyframe(LayerPropertyKeyframe));
}
#endregion
#region Movement
private TimeSpan? _offset;
private TimeSpan? _startPosition;
private double _pixelsPerSecond;
public void StartMovement(ITimelineKeyframeViewModel source)
{
_startPosition = LayerPropertyKeyframe.Position;
if (source != this)
_offset = LayerPropertyKeyframe.Position - source.Position;
}
public void UpdateMovement(TimeSpan position)
{
if (_offset == null)
UpdatePosition(position);
else
UpdatePosition(position + _offset.Value);
}
public void FinishMovement()
{
MoveKeyframe command = _startPosition != null
? new MoveKeyframe(Keyframe, Keyframe.Position, _startPosition.Value)
: new MoveKeyframe(Keyframe, Keyframe.Position);
_startPosition = null;
_offset = null;
_profileEditorService.ExecuteCommand(command);
}
private void UpdatePosition(TimeSpan position)
{
if (position < TimeSpan.Zero)
LayerPropertyKeyframe.Position = TimeSpan.Zero;
else if (position > LayerPropertyKeyframe.LayerProperty.ProfileElement.Timeline.Length)
LayerPropertyKeyframe.Position = LayerPropertyKeyframe.LayerProperty.ProfileElement.Timeline.Length;
else
LayerPropertyKeyframe.Position = position;
}
#endregion
#region Easing
public void PopulateEasingViewModels()
{
if (EasingViewModels.Any())
return;
EasingViewModels.AddRange(Enum.GetValues(typeof(Easings.Functions))
.Cast<Easings.Functions>()
.Select(e => new TimelineEasingViewModel(e, Keyframe)));
}
public void SelectEasingFunction(Easings.Functions easingFunction)
{
_profileEditorService.ExecuteCommand(new ChangeKeyframeEasing(Keyframe, easingFunction));
}
#endregion
}
@@ -0,0 +1,58 @@
<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:segments="clr-namespace:Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Segments"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="18"
x:Class="Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Segments.EndSegmentView"
x:DataType="segments:EndSegmentViewModel">
<UserControl.Styles>
<StyleInclude Source="/Screens/ProfileEditor/Panels/Properties/Timeline/Segments/Segment.axaml" />
</UserControl.Styles>
<Border Classes="segment-container">
<Grid Name="SegmentGrid"
Background="{DynamicResource CardStrokeColorDefaultSolidBrush}"
Width="{CompiledBinding Width}"
ColumnDefinitions="Auto, Auto,*,Auto">
<Rectangle Name="KeyframeDragVisualLeft"
Grid.Column="0"
Classes="resize-visual" />
<Button Grid.Column="1"
Name="AddMainSegment"
Classes="AppBarButton icon-button icon-button-small"
ToolTip.Tip="Add main segment"
Command="{Binding AddMainSegment}"
IsVisible="{CompiledBinding ShowAddMain}">
<avalonia:MaterialIcon Kind="PlusCircle" />
</Button>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" HorizontalAlignment="Center" ClipToBounds="True">
<TextBlock Name="SegmentTitle"
FontSize="13"
ToolTip.Tip="This segment is played once a condition is no longer met">
End
</TextBlock>
<Button Name="SegmentClose"
Classes="AppBarButton icon-button icon-button-small"
ToolTip.Tip="Remove this segment"
Command="{Binding RemoveSegment}">
<avalonia:MaterialIcon Kind="CloseCircle" />
</Button>
</StackPanel>
<Rectangle Name="KeyframeDragVisualRight"
Grid.Column="3"
Classes="resize-visual" />
<Rectangle Name="KeyframeDragAnchor"
Grid.Column="3"
Classes="resize-anchor"
PointerPressed="KeyframeDragAnchor_OnPointerPressed"
PointerMoved="KeyframeDragAnchor_OnPointerMoved"
PointerReleased="KeyframeDragAnchor_OnPointerReleased"
ToolTip.Tip="{CompiledBinding EndTimestamp}"/>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,50 @@
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Segments
{
public partial class EndSegmentView : ReactiveUserControl<EndSegmentViewModel>
{
private readonly Rectangle _keyframeDragAnchor;
private double _dragOffset;
public EndSegmentView()
{
InitializeComponent();
_keyframeDragAnchor = this.Get<Rectangle>("KeyframeDragAnchor");
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void KeyframeDragAnchor_OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (ViewModel == null || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
return;
e.Pointer.Capture(_keyframeDragAnchor);
_dragOffset = ViewModel.Width - e.GetCurrentPoint(this).Position.X;
ViewModel.StartResize();
}
private void KeyframeDragAnchor_OnPointerMoved(object? sender, PointerEventArgs e)
{
if (ViewModel == null || !ReferenceEquals(e.Pointer.Captured, _keyframeDragAnchor))
return;
ViewModel.UpdateResize(e.GetCurrentPoint(this).Position.X + _dragOffset);
}
private void KeyframeDragAnchor_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (ViewModel == null || !ReferenceEquals(e.Pointer.Captured, _keyframeDragAnchor))
return;
e.Pointer.Capture(null);
ViewModel.FinishResize(e.GetCurrentPoint(this).Position.X + _dragOffset);
}
}
}
@@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.UI.Shared.Services.ProfileEditor;
using Artemis.UI.Shared.Services.ProfileEditor.Commands;
using Avalonia.Controls.Mixins;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Segments;
public class EndSegmentViewModel : TimelineSegmentViewModel
{
private readonly IProfileEditorService _profileEditorService;
private RenderProfileElement? _profileElement;
private int _pixelsPerSecond;
private TimeSpan _time;
private ObservableAsPropertyHelper<double>? _start;
private ObservableAsPropertyHelper<double>? _end;
private ObservableAsPropertyHelper<string?>? _endTimestamp;
private readonly ObservableAsPropertyHelper<double> _width;
private TimeSpan _initialLength;
public EndSegmentViewModel(IProfileEditorService profileEditorService) : base(profileEditorService)
{
_profileEditorService = profileEditorService;
this.WhenActivated(d =>
{
profileEditorService.ProfileElement.Subscribe(p => _profileElement = p).DisposeWith(d);
profileEditorService.Time.Subscribe(t => _time = t).DisposeWith(d);
profileEditorService.PixelsPerSecond.Subscribe(p => _pixelsPerSecond = p).DisposeWith(d);
_start = profileEditorService.ProfileElement
.Select(p => p?.WhenAnyValue(element => element.Timeline.EndSegmentStartPosition) ?? Observable.Never<TimeSpan>())
.Switch()
.CombineLatest(profileEditorService.PixelsPerSecond, (t, p) => t.TotalSeconds * p)
.ToProperty(this, vm => vm.StartX)
.DisposeWith(d);
_end = profileEditorService.ProfileElement
.Select(p => p?.WhenAnyValue(element => element.Timeline.EndSegmentEndPosition) ?? Observable.Never<TimeSpan>())
.Switch()
.CombineLatest(profileEditorService.PixelsPerSecond, (t, p) => t.TotalSeconds * p)
.ToProperty(this, vm => vm.EndX)
.DisposeWith(d);
_endTimestamp = profileEditorService.ProfileElement
.Select(p => p?.WhenAnyValue(element => element.Timeline.EndSegmentEndPosition) ?? Observable.Never<TimeSpan>())
.Switch()
.Select(p => $"{Math.Floor(p.TotalSeconds):00}.{p.Milliseconds:000}")
.ToProperty(this, vm => vm.EndTimestamp)
.DisposeWith(d);
});
_width = this.WhenAnyValue(vm => vm.StartX, vm => vm.EndX).Select(t => t.Item2 - t.Item1).ToProperty(this, vm => vm.Width);
}
public override TimeSpan Start => _profileElement?.Timeline.EndSegmentStartPosition ?? TimeSpan.Zero;
public override double StartX => _start?.Value ?? 0;
public override TimeSpan End => _profileElement?.Timeline.EndSegmentEndPosition ?? TimeSpan.Zero;
public override double EndX => _end?.Value ?? 0;
public override TimeSpan Length
{
get => _profileElement?.Timeline.EndSegmentLength ?? TimeSpan.Zero;
set
{
if (_profileElement != null)
_profileElement.Timeline.EndSegmentLength = value;
}
}
public override double Width => _width.Value;
public override string? EndTimestamp => _endTimestamp?.Value;
public override ResizeTimelineSegment.SegmentType Type => ResizeTimelineSegment.SegmentType.End;
}
@@ -0,0 +1,80 @@
<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:segments="clr-namespace:Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Segments"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="18"
x:Class="Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Segments.MainSegmentView"
x:DataType="segments:MainSegmentViewModel">
<UserControl.Styles>
<StyleInclude Source="/Screens/ProfileEditor/Panels/Properties/Timeline/Segments/Segment.axaml" />
</UserControl.Styles>
<Border Classes="segment-container">
<Grid Name="SegmentGrid"
Background="{DynamicResource CardStrokeColorDefaultSolidBrush}"
Width="{CompiledBinding Width}"
ColumnDefinitions="Auto,Auto,*,Auto,Auto">
<Rectangle Name="KeyframeDragVisualLeft"
Grid.Column="0"
IsVisible="{CompiledBinding !ShowAddStart}"
Classes="resize-visual" />
<Button Name="AddStartSegment"
Grid.Column="1"
Classes="AppBarButton icon-button icon-button-small"
VerticalAlignment="Center"
ToolTip.Tip="Add a start segment"
Command="{Binding AddStartSegment}"
IsVisible="{CompiledBinding ShowAddStart}">
<avalonia:MaterialIcon Kind="PlusCircle" />
</Button>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Name="SegmentTitle"
VerticalAlignment="Center"
HorizontalAlignment="Center"
FontSize="13"
ToolTip.Tip="This segment is played while a condition is met, either once or on a repeating loop">
Main
</TextBlock>
<Button Name="SegmentClose"
Classes="AppBarButton icon-button icon-button-small"
ToolTip.Tip="Remove this segment"
Command="{Binding DisableSegment}">
<avalonia:MaterialIcon Kind="CloseCircle" />
</Button>
<ToggleButton Name="SegmentRepeat"
Classes="icon-button icon-button-small"
ToolTip.Tip="Repeat this segment"
IsChecked="{CompiledBinding RepeatSegment}"
Padding="0">
<avalonia:MaterialIcon Kind="Repeat" />
</ToggleButton>
</StackPanel>
<Button Name="AddEndSegment"
Grid.Column="3"
Classes="AppBarButton icon-button icon-button-small"
VerticalAlignment="Center"
ToolTip.Tip="Add an end segment"
Command="{Binding AddEndSegment}"
IsVisible="{CompiledBinding ShowAddEnd}">
<avalonia:MaterialIcon Kind="PlusCircle" />
</Button>
<Rectangle Name="KeyframeDragVisual"
Grid.Column="4"
Classes="resize-visual" />
<Rectangle Name="KeyframeDragAnchor"
Grid.Column="4"
Classes="resize-anchor"
PointerPressed="KeyframeDragAnchor_OnPointerPressed"
PointerMoved="KeyframeDragAnchor_OnPointerMoved"
PointerReleased="KeyframeDragAnchor_OnPointerReleased"
ToolTip.Tip="{CompiledBinding EndTimestamp}"/>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,62 @@
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Segments
{
public partial class MainSegmentView : ReactiveUserControl<MainSegmentViewModel>
{
private readonly Rectangle _keyframeDragAnchor;
private double _dragOffset;
public MainSegmentView()
{
InitializeComponent();
_keyframeDragAnchor = this.Get<Rectangle>("KeyframeDragAnchor");
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void KeyframeDragAnchor_OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (ViewModel == null || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
return;
e.Pointer.Capture(_keyframeDragAnchor);
_dragOffset = ViewModel.Width - e.GetCurrentPoint(this).Position.X;
ViewModel.StartResize();
}
private void KeyframeDragAnchor_OnPointerMoved(object? sender, PointerEventArgs e)
{
if (ViewModel == null || !ReferenceEquals(e.Pointer.Captured, _keyframeDragAnchor))
return;
ViewModel.UpdateResize(e.GetCurrentPoint(this).Position.X + _dragOffset);
}
private void KeyframeDragAnchor_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (ViewModel == null || !ReferenceEquals(e.Pointer.Captured, _keyframeDragAnchor))
return;
e.Pointer.Capture(null);
ViewModel.FinishResize(e.GetCurrentPoint(this).Position.X + _dragOffset);
}
private void KeyframeDragStartAnchor_OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
}
private void KeyframeDragStartAnchor_OnPointerMoved(object? sender, PointerEventArgs e)
{
}
private void KeyframeDragStartAnchor_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
}
}
}
@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.UI.Shared.Services.ProfileEditor;
using Artemis.UI.Shared.Services.ProfileEditor.Commands;
using Avalonia.Controls.Mixins;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Segments;
public class MainSegmentViewModel : TimelineSegmentViewModel
{
private RenderProfileElement? _profileElement;
private ObservableAsPropertyHelper<double>? _start;
private ObservableAsPropertyHelper<double>? _end;
private ObservableAsPropertyHelper<string?>? _endTimestamp;
private ObservableAsPropertyHelper<bool>? _repeatSegment;
private readonly ObservableAsPropertyHelper<double> _width;
public MainSegmentViewModel(IProfileEditorService profileEditorService) : base(profileEditorService)
{
this.WhenActivated(d =>
{
profileEditorService.ProfileElement.Subscribe(p => _profileElement = p).DisposeWith(d);
_start = profileEditorService.ProfileElement
.Select(p => p?.WhenAnyValue(element => element.Timeline.MainSegmentStartPosition) ?? Observable.Never<TimeSpan>())
.Switch()
.CombineLatest(profileEditorService.PixelsPerSecond, (t, p) => t.TotalSeconds * p)
.ToProperty(this, vm => vm.StartX)
.DisposeWith(d);
_end = profileEditorService.ProfileElement
.Select(p => p?.WhenAnyValue(element => element.Timeline.MainSegmentEndPosition) ?? Observable.Never<TimeSpan>())
.Switch()
.CombineLatest(profileEditorService.PixelsPerSecond, (t, p) => t.TotalSeconds * p)
.ToProperty(this, vm => vm.EndX)
.DisposeWith(d);
_endTimestamp = profileEditorService.ProfileElement
.Select(p => p?.WhenAnyValue(element => element.Timeline.MainSegmentEndPosition) ?? Observable.Never<TimeSpan>())
.Switch()
.Select(p => $"{Math.Floor(p.TotalSeconds):00}.{p.Milliseconds:000}")
.ToProperty(this, vm => vm.EndTimestamp)
.DisposeWith(d);
_repeatSegment = profileEditorService.ProfileElement
.Select(p => p?.WhenAnyValue(element => element.Timeline.PlayMode) ?? Observable.Never<TimelinePlayMode>())
.Switch()
.Select(p => p == TimelinePlayMode.Repeat)
.ToProperty(this, vm => vm.RepeatSegment)
.DisposeWith(d);
});
_width = this.WhenAnyValue(vm => vm.StartX, vm => vm.EndX).Select(t => t.Item2 - t.Item1).ToProperty(this, vm => vm.Width);
}
public override TimeSpan Start => _profileElement?.Timeline.MainSegmentStartPosition ?? TimeSpan.Zero;
public override double StartX => _start?.Value ?? 0;
public override TimeSpan End => _profileElement?.Timeline.MainSegmentEndPosition ?? TimeSpan.Zero;
public override double EndX => _end?.Value ?? 0;
public override TimeSpan Length
{
get => _profileElement?.Timeline.MainSegmentLength ?? TimeSpan.Zero;
set
{
if (_profileElement != null)
_profileElement.Timeline.MainSegmentLength = value;
}
}
public override double Width => _width.Value;
public override string? EndTimestamp => _endTimestamp?.Value;
public override ResizeTimelineSegment.SegmentType Type => ResizeTimelineSegment.SegmentType.Main;
public bool RepeatSegment
{
get => _repeatSegment?.Value ?? false;
set => throw new NotImplementedException();
}
}
@@ -0,0 +1,28 @@
<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="ContentControl.segment-content-control">
</Style>
<Style Selector="Border.segment-container">
<Setter Property="Height" Value="18"></Setter>
</Style>
<Style Selector="Rectangle.resize-visual">
<Setter Property="Fill" Value="{DynamicResource SystemAccentColorLight2}"/>
<Setter Property="Width" Value="4"/>
<Setter Property="HorizontalAlignment" Value="Right"/>
</Style>
<Style Selector="Rectangle.resize-anchor">
<Setter Property="Fill" Value="Transparent"/>
<Setter Property="Width" Value="10"/>
<Setter Property="HorizontalAlignment" Value="Right"/>
<Setter Property="Cursor" Value="SizeWestEast"/>
</Style>
</Styles>
@@ -0,0 +1,56 @@
<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:system="clr-namespace:System;assembly=System.Runtime"
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:segments="clr-namespace:Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Segments"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="18"
x:Class="Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Segments.StartSegmentView"
x:DataType="segments:StartSegmentViewModel">
<UserControl.Styles>
<StyleInclude Source="/Screens/ProfileEditor/Panels/Properties/Timeline/Segments/Segment.axaml" />
</UserControl.Styles>
<Border Classes="segment-container">
<Grid Name="SegmentGrid"
Background="{DynamicResource CardStrokeColorDefaultSolidBrush}"
Width="{CompiledBinding Width}"
ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Name="SegmentTitle"
FontSize="13"
ToolTip.Tip="This segment is played when a layer starts displaying because it's conditions are met">
Start
</TextBlock>
<Button Name="SegmentClose"
Classes="AppBarButton icon-button icon-button-small"
ToolTip.Tip="Remove this segment"
Command="{Binding RemoveSegment}">
<avalonia:MaterialIcon Kind="CloseCircle" />
</Button>
</StackPanel>
<Button Grid.Column="1"
Name="AddMainSegment"
Classes="AppBarButton icon-button icon-button-small"
ToolTip.Tip="Add main segment"
Command="{Binding AddMainSegment}"
IsVisible="{CompiledBinding ShowAddMain}">
<avalonia:MaterialIcon Kind="PlusCircle" />
</Button>
<Rectangle Name="KeyframeDragVisual"
Grid.Column="2"
Classes="resize-visual" />
<Rectangle Name="KeyframeDragAnchor"
Grid.Column="2"
Classes="resize-anchor"
PointerPressed="KeyframeDragAnchor_OnPointerPressed"
PointerMoved="KeyframeDragAnchor_OnPointerMoved"
PointerReleased="KeyframeDragAnchor_OnPointerReleased"
ToolTip.Tip="{CompiledBinding EndTimestamp}"/>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,50 @@
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Segments
{
public partial class StartSegmentView : ReactiveUserControl<StartSegmentViewModel>
{
private readonly Rectangle _keyframeDragAnchor;
private double _dragOffset;
public StartSegmentView()
{
InitializeComponent();
_keyframeDragAnchor = this.Get<Rectangle>("KeyframeDragAnchor");
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void KeyframeDragAnchor_OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (ViewModel == null || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
return;
e.Pointer.Capture(_keyframeDragAnchor);
_dragOffset = ViewModel.Width - e.GetCurrentPoint(this).Position.X;
ViewModel.StartResize();
}
private void KeyframeDragAnchor_OnPointerMoved(object? sender, PointerEventArgs e)
{
if (ViewModel == null || !ReferenceEquals(e.Pointer.Captured, _keyframeDragAnchor))
return;
ViewModel.UpdateResize(e.GetCurrentPoint(this).Position.X + _dragOffset);
}
private void KeyframeDragAnchor_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (ViewModel == null || !ReferenceEquals(e.Pointer.Captured, _keyframeDragAnchor))
return;
e.Pointer.Capture(null);
ViewModel.FinishResize(e.GetCurrentPoint(this).Position.X + _dragOffset);
}
}
}
@@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.UI.Shared.Services.ProfileEditor;
using Artemis.UI.Shared.Services.ProfileEditor.Commands;
using Avalonia.Controls.Mixins;
using Castle.DynamicProxy.Generators;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Segments;
public class StartSegmentViewModel : TimelineSegmentViewModel
{
private readonly IProfileEditorService _profileEditorService;
private RenderProfileElement? _profileElement;
private int _pixelsPerSecond;
private TimeSpan _time;
private ObservableAsPropertyHelper<double>? _end;
private ObservableAsPropertyHelper<string?>? _endTimestamp;
private readonly ObservableAsPropertyHelper<double> _width;
private TimeSpan _initialLength;
public StartSegmentViewModel(IProfileEditorService profileEditorService) : base(profileEditorService)
{
_profileEditorService = profileEditorService;
this.WhenActivated(d =>
{
profileEditorService.ProfileElement.Subscribe(p => _profileElement = p).DisposeWith(d);
profileEditorService.Time.Subscribe(t => _time = t).DisposeWith(d);
profileEditorService.PixelsPerSecond.Subscribe(p => _pixelsPerSecond = p).DisposeWith(d);
_end = profileEditorService.ProfileElement
.Select(p => p?.WhenAnyValue(element => element.Timeline.StartSegmentEndPosition) ?? Observable.Never<TimeSpan>())
.Switch()
.CombineLatest(profileEditorService.PixelsPerSecond, (t, p) => t.TotalSeconds * p)
.ToProperty(this, vm => vm.EndX)
.DisposeWith(d);
_endTimestamp = profileEditorService.ProfileElement
.Select(p => p?.WhenAnyValue(element => element.Timeline.StartSegmentEndPosition) ?? Observable.Never<TimeSpan>())
.Switch()
.Select(p => $"{Math.Floor(p.TotalSeconds):00}.{p.Milliseconds:000}")
.ToProperty(this, vm => vm.EndTimestamp)
.DisposeWith(d);
});
_width = this.WhenAnyValue(vm => vm.StartX, vm => vm.EndX).Select(t => t.Item2 - t.Item1).ToProperty(this, vm => vm.Width);
}
public override TimeSpan Start => TimeSpan.Zero;
public override double StartX => 0;
public override TimeSpan End => _profileElement?.Timeline.StartSegmentEndPosition ?? TimeSpan.Zero;
public override double EndX => _end?.Value ?? 0;
public override TimeSpan Length
{
get => _profileElement?.Timeline.StartSegmentLength ?? TimeSpan.Zero;
set
{
if (_profileElement != null)
_profileElement.Timeline.StartSegmentLength = value;
}
}
public override double Width => _width.Value;
public override string? EndTimestamp => _endTimestamp?.Value;
public override ResizeTimelineSegment.SegmentType Type => ResizeTimelineSegment.SegmentType.Start;
}
@@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.ProfileEditor;
using Artemis.UI.Shared.Services.ProfileEditor.Commands;
using Avalonia.Controls.Mixins;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Segments;
public abstract class TimelineSegmentViewModel : ActivatableViewModelBase
{
private static readonly TimeSpan NewSegmentLength = TimeSpan.FromSeconds(2);
private readonly IProfileEditorService _profileEditorService;
private RenderProfileElement? _profileElement;
private int _pixelsPerSecond;
private Dictionary<ILayerPropertyKeyframe, TimeSpan> _originalKeyframePositions = new();
private ObservableAsPropertyHelper<bool>? _showAddStart;
private ObservableAsPropertyHelper<bool>? _showAddMain;
private ObservableAsPropertyHelper<bool>? _showAddEnd;
private TimeSpan _initialLength;
protected TimelineSegmentViewModel(IProfileEditorService profileEditorService)
{
_profileEditorService = profileEditorService;
this.WhenActivated(d =>
{
profileEditorService.ProfileElement.Subscribe(p => _profileElement = p).DisposeWith(d);
profileEditorService.PixelsPerSecond.Subscribe(p => _pixelsPerSecond = p).DisposeWith(d);
_showAddStart = profileEditorService.ProfileElement
.Select(p => p?.WhenAnyValue(element => element.Timeline.StartSegmentLength) ?? Observable.Never<TimeSpan>())
.Switch()
.Select(t => t == TimeSpan.Zero)
.ToProperty(this, vm => vm.ShowAddStart)
.DisposeWith(d);
_showAddMain = profileEditorService.ProfileElement
.Select(p => p?.WhenAnyValue(element => element.Timeline.MainSegmentLength) ?? Observable.Never<TimeSpan>())
.Switch()
.Select(t => t == TimeSpan.Zero)
.ToProperty(this, vm => vm.ShowAddMain)
.DisposeWith(d);
_showAddEnd = profileEditorService.ProfileElement
.Select(p => p?.WhenAnyValue(element => element.Timeline.EndSegmentLength) ?? Observable.Never<TimeSpan>())
.Switch()
.Select(t => t == TimeSpan.Zero)
.ToProperty(this, vm => vm.ShowAddEnd)
.DisposeWith(d);
});
}
public bool ShowAddStart => _showAddStart?.Value ?? false;
public bool ShowAddMain => _showAddMain?.Value ?? false;
public bool ShowAddEnd => _showAddEnd?.Value ?? false;
public abstract TimeSpan Start { get; }
public abstract double StartX { get; }
public abstract TimeSpan End { get; }
public abstract double EndX { get; }
public abstract TimeSpan Length { get; set; }
public abstract double Width { get; }
public abstract string? EndTimestamp { get; }
public abstract ResizeTimelineSegment.SegmentType Type { get; }
public void AddStartSegment()
{
if (_profileElement == null)
return;
using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope("Add start segment");
ShiftKeyframes(_profileElement.GetAllLayerProperties().SelectMany(p => p.UntypedKeyframes), NewSegmentLength);
ApplyPendingKeyframeMovement();
_profileEditorService.ExecuteCommand(new ResizeTimelineSegment(ResizeTimelineSegment.SegmentType.Start, _profileElement, NewSegmentLength));
}
public void AddMainSegment()
{
if (_profileElement == null)
return;
using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope("Add main segment");
ShiftKeyframes(_profileElement.GetAllLayerProperties().SelectMany(p => p.UntypedKeyframes).Where(s => s.Position > _profileElement.Timeline.StartSegmentEndPosition), NewSegmentLength);
ApplyPendingKeyframeMovement();
_profileEditorService.ExecuteCommand(new ResizeTimelineSegment(ResizeTimelineSegment.SegmentType.Main, _profileElement, NewSegmentLength));
}
public void AddEndSegment()
{
if (_profileElement == null)
return;
using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope("Add end segment");
_profileEditorService.ExecuteCommand(new ResizeTimelineSegment(ResizeTimelineSegment.SegmentType.End, _profileElement, NewSegmentLength));
}
public void StartResize()
{
if (_profileElement == null)
return;
_initialLength = Length;
}
public void UpdateResize(double x)
{
if (_profileElement == null)
return;
TimeSpan difference = GetTimeFromX(x) - Length;
List<ILayerPropertyKeyframe> keyframes = _profileElement.GetAllLayerProperties().SelectMany(p => p.UntypedKeyframes).ToList();
ShiftKeyframes(keyframes.Where(k => k.Position > End.Add(difference)), difference);
Length = GetTimeFromX(x);
}
public void FinishResize(double x)
{
if (_profileElement == null)
return;
using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope("Resize segment");
ApplyPendingKeyframeMovement();
_profileEditorService.ExecuteCommand(new ResizeTimelineSegment(Type, _profileElement, GetTimeFromX(x), _initialLength));
}
public void RemoveSegment()
{
if (_profileElement == null)
return;
using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope("Remove segment");
IEnumerable<ILayerPropertyKeyframe> keyframes = _profileElement.GetAllLayerProperties().SelectMany(p => p.UntypedKeyframes);
// Delete keyframes in the segment
foreach (ILayerPropertyKeyframe layerPropertyKeyframe in keyframes)
if (layerPropertyKeyframe.Position > Start && layerPropertyKeyframe.Position <= End)
_profileEditorService.ExecuteCommand(new DeleteKeyframe(layerPropertyKeyframe));
// Move keyframes after the segment forwards
ShiftKeyframes(keyframes.Where(s => s.Position > End), new TimeSpan(Length.Ticks * -1));
ApplyPendingKeyframeMovement();
_profileEditorService.ExecuteCommand(new ResizeTimelineSegment(Type, _profileElement, TimeSpan.Zero));
}
protected TimeSpan GetTimeFromX(double x)
{
TimeSpan length = TimeSpan.FromSeconds(x / _pixelsPerSecond);
if (length < TimeSpan.Zero)
length = TimeSpan.Zero;
return length;
}
protected void ShiftKeyframes(IEnumerable<ILayerPropertyKeyframe> keyframes, TimeSpan amount)
{
foreach (ILayerPropertyKeyframe layerPropertyKeyframe in keyframes)
{
if (!_originalKeyframePositions.ContainsKey(layerPropertyKeyframe))
_originalKeyframePositions[layerPropertyKeyframe] = layerPropertyKeyframe.Position;
layerPropertyKeyframe.Position = layerPropertyKeyframe.Position.Add(amount);
}
}
protected void ApplyPendingKeyframeMovement()
{
foreach ((ILayerPropertyKeyframe keyframe, TimeSpan originalPosition) in _originalKeyframePositions)
_profileEditorService.ExecuteCommand(new MoveKeyframe(keyframe, keyframe.Position, originalPosition));
_originalKeyframePositions.Clear();
}
}
@@ -0,0 +1,55 @@
<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:properties="clr-namespace:Artemis.UI.Screens.ProfileEditor.Properties"
xmlns:timeline="clr-namespace:Artemis.UI.Screens.ProfileEditor.Properties.Timeline"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.Properties.Timeline.TimelineGroupView"
x:DataType="timeline:TimelineGroupViewModel">
<Grid RowDefinitions="28,1,Auto">
<ItemsControl Grid.Row="0"
Height="{DynamicResource RailsHeight}"
IsVisible="{CompiledBinding !PropertyGroupViewModel.IsExpanded}"
Items="{CompiledBinding KeyframePositions}"
HorizontalAlignment="Stretch">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.Styles>
<Style Selector="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding}" />
</Style>
</ItemsControl.Styles>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Ellipse Fill="{DynamicResource ControlFillColorDisabledBrush}"
Stroke="White"
StrokeThickness="0"
Width="10"
Height="10"
Margin="-5,6,0,0" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Rectangle Grid.Row="1" HorizontalAlignment="Stretch" Fill="{DynamicResource ButtonBorderBrush}" Height="1" />
<ItemsControl Grid.Row="2"
Items="{CompiledBinding Children}"
IsVisible="{CompiledBinding PropertyGroupViewModel.IsExpanded}"
HorizontalAlignment="Stretch">
<ItemsControl.DataTemplates>
<DataTemplate DataType="properties:PropertyGroupViewModel">
<ContentControl Content="{CompiledBinding TimelineGroupViewModel}" IsVisible="{CompiledBinding IsVisible}" />
</DataTemplate>
<DataTemplate DataType="properties:PropertyViewModel">
<ContentControl Content="{CompiledBinding TimelinePropertyViewModel}" IsVisible="{CompiledBinding IsVisible}" />
</DataTemplate>
</ItemsControl.DataTemplates>
</ItemsControl>
</Grid>
</UserControl>
@@ -0,0 +1,17 @@
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline;
public class TimelineGroupView : ReactiveUserControl<TimelineGroupViewModel>
{
public TimelineGroupView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
@@ -0,0 +1,48 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.ProfileEditor;
using Avalonia.Controls.Mixins;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline;
public class TimelineGroupViewModel : ActivatableViewModelBase
{
private ObservableCollection<double>? _keyframePositions;
private int _pixelsPerSecond;
public TimelineGroupViewModel(PropertyGroupViewModel propertyGroupViewModel, IProfileEditorService profileEditorService)
{
PropertyGroupViewModel = propertyGroupViewModel;
this.WhenActivated(d =>
{
profileEditorService.PixelsPerSecond.Subscribe(p =>
{
_pixelsPerSecond = p;
UpdateKeyframePositions();
});
this.WhenAnyValue(vm => vm.PropertyGroupViewModel.IsExpanded).Subscribe(_ => UpdateKeyframePositions()).DisposeWith(d);
PropertyGroupViewModel.WhenAnyValue(vm => vm.IsExpanded).Subscribe(_ => this.RaisePropertyChanged(nameof(Children))).DisposeWith(d);
});
}
public PropertyGroupViewModel PropertyGroupViewModel { get; }
public ObservableCollection<ViewModelBase>? Children => PropertyGroupViewModel.IsExpanded ? PropertyGroupViewModel.Children : null;
public ObservableCollection<double>? KeyframePositions
{
get => _keyframePositions;
set => RaiseAndSetIfChanged(ref _keyframePositions, value);
}
private void UpdateKeyframePositions()
{
KeyframePositions = new ObservableCollection<double>(PropertyGroupViewModel
.GetAllKeyframeViewModels(false)
.Select(p => p.Position.TotalSeconds * _pixelsPerSecond));
}
}
@@ -0,0 +1,21 @@
<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="Artemis.UI.Screens.ProfileEditor.Properties.Timeline.TimelinePropertyView">
<Border Height="{DynamicResource RailsBorderHeight}" BorderThickness="0,0,0,1" BorderBrush="{DynamicResource ButtonBorderBrush}">
<ItemsControl Items="{Binding KeyframeViewModels}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.Styles>
<Style Selector="ItemsControl > ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X, TargetNullValue=0}" />
</Style>
</ItemsControl.Styles>
</ItemsControl>
</Border>
</UserControl>
@@ -0,0 +1,17 @@
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline;
public class TimelinePropertyView : ReactiveUserControl<ITimelinePropertyViewModel>
{
public TimelinePropertyView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Keyframes;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.ProfileEditor;
using Avalonia.Controls.Mixins;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline;
public class TimelinePropertyViewModel<T> : ActivatableViewModelBase, ITimelinePropertyViewModel
{
private readonly IProfileEditorService _profileEditorService;
public TimelinePropertyViewModel(LayerProperty<T> layerProperty, PropertyViewModel propertyViewModel, IProfileEditorService profileEditorService)
{
_profileEditorService = profileEditorService;
LayerProperty = layerProperty;
PropertyViewModel = propertyViewModel;
KeyframeViewModels = new ObservableCollection<TimelineKeyframeViewModel<T>>();
this.WhenActivated(d =>
{
Observable.FromEventPattern<LayerPropertyEventArgs>(x => LayerProperty.KeyframesToggled += x, x => LayerProperty.KeyframesToggled -= x)
.Subscribe(_ => UpdateKeyframes())
.DisposeWith(d);
Observable.FromEventPattern<LayerPropertyEventArgs>(x => LayerProperty.KeyframeAdded += x, x => LayerProperty.KeyframeAdded -= x)
.Subscribe(_ => UpdateKeyframes())
.DisposeWith(d);
Observable.FromEventPattern<LayerPropertyEventArgs>(x => LayerProperty.KeyframeRemoved += x, x => LayerProperty.KeyframeRemoved -= x)
.Subscribe(_ => UpdateKeyframes())
.DisposeWith(d);
UpdateKeyframes();
});
}
public LayerProperty<T> LayerProperty { get; }
public PropertyViewModel PropertyViewModel { get; }
public ObservableCollection<TimelineKeyframeViewModel<T>> KeyframeViewModels { get; }
private void UpdateKeyframes()
{
// Only show keyframes if they are enabled
if (LayerProperty.KeyframesEnabled)
{
List<LayerPropertyKeyframe<T>> keyframes = LayerProperty.Keyframes.ToList();
List<TimelineKeyframeViewModel<T>> toRemove = KeyframeViewModels.Where(t => !keyframes.Contains(t.LayerPropertyKeyframe)).ToList();
foreach (TimelineKeyframeViewModel<T> timelineKeyframeViewModel in toRemove)
KeyframeViewModels.Remove(timelineKeyframeViewModel);
List<TimelineKeyframeViewModel<T>> toAdd = keyframes.Where(k => KeyframeViewModels.All(t => t.LayerPropertyKeyframe != k))
.Select(k => new TimelineKeyframeViewModel<T>(k, _profileEditorService)).ToList();
foreach (TimelineKeyframeViewModel<T> timelineKeyframeViewModel in toAdd)
KeyframeViewModels.Add(timelineKeyframeViewModel);
}
else
{
KeyframeViewModels.Clear();
}
foreach (TimelineKeyframeViewModel<T> timelineKeyframeViewModel in KeyframeViewModels)
timelineKeyframeViewModel.Update();
}
#region Implementation of ITimelinePropertyViewModel
public List<ITimelineKeyframeViewModel> GetAllKeyframeViewModels()
{
return KeyframeViewModels.Cast<ITimelineKeyframeViewModel>().ToList();
}
public void WipeKeyframes(TimeSpan? start, TimeSpan? end)
{
start ??= TimeSpan.Zero;
end ??= TimeSpan.MaxValue;
List<LayerPropertyKeyframe<T>> toShift = LayerProperty.Keyframes.Where(k => k.Position >= start && k.Position < end).ToList();
foreach (LayerPropertyKeyframe<T> keyframe in toShift)
LayerProperty.RemoveKeyframe(keyframe);
UpdateKeyframes();
}
public void ShiftKeyframes(TimeSpan? start, TimeSpan? end, TimeSpan amount)
{
start ??= TimeSpan.Zero;
end ??= TimeSpan.MaxValue;
List<LayerPropertyKeyframe<T>> toShift = LayerProperty.Keyframes.Where(k => k.Position > start && k.Position < end).ToList();
foreach (LayerPropertyKeyframe<T> keyframe in toShift)
keyframe.Position += amount;
UpdateKeyframes();
}
#endregion
}
@@ -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:local="clr-namespace:Artemis.UI.Screens.ProfileEditor.Properties"
xmlns:controls="clr-namespace:Artemis.UI.Shared.Controls;assembly=Artemis.UI.Shared"
xmlns:timeline="clr-namespace:Artemis.UI.Screens.ProfileEditor.Properties.Timeline"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.Properties.Timeline.TimelineView"
x:DataType="timeline:TimelineViewModel">
<UserControl.Resources>
<x:Double x:Key="RailsHeight">28</x:Double>
<x:Double x:Key="RailsBorderHeight">29</x:Double>
</UserControl.Resources>
<Grid Background="Transparent" PointerReleased="InputElement_OnPointerReleased" Focusable="True" MinWidth="{Binding MinWidth}">
<Grid.KeyBindings>
<KeyBinding Command="{Binding DeleteKeyframes}" Gesture="Delete"/>
</Grid.KeyBindings>
<ItemsControl Items="{CompiledBinding PropertyGroupViewModels}">
<ItemsControl.ItemTemplate>
<TreeDataTemplate DataType="{x:Type local:PropertyGroupViewModel}" ItemsSource="{CompiledBinding Children}">
<ContentControl Content="{CompiledBinding TimelineGroupViewModel}" />
</TreeDataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<controls:SelectionRectangle Name="SelectionRectangle" InputElement="{CompiledBinding $parent}" SelectionFinished="SelectionRectangle_OnSelectionFinished"></controls:SelectionRectangle>
</Grid>
</UserControl>
@@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.Linq;
using Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Keyframes;
using Artemis.UI.Shared.Controls;
using Artemis.UI.Shared.Events;
using Artemis.UI.Shared.Extensions;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline;
public class TimelineView : ReactiveUserControl<TimelineViewModel>
{
private readonly SelectionRectangle _selectionRectangle;
public TimelineView()
{
InitializeComponent();
_selectionRectangle = this.Get<SelectionRectangle>("SelectionRectangle");
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void SelectionRectangle_OnSelectionFinished(object? sender, SelectionRectangleEventArgs e)
{
if (ViewModel == null)
return;
List<TimelineKeyframeView> keyframeViews = this.GetVisualChildrenOfType<TimelineKeyframeView>().Where(k =>
{
Rect hitTestRect = k.TransformedBounds != null ? k.TransformedBounds.Value.Bounds.TransformToAABB(k.TransformedBounds.Value.Transform) : Rect.Empty;
return e.AbsoluteRectangle.Intersects(hitTestRect);
}).ToList();
ViewModel.SelectKeyframes(keyframeViews.Where(kv => kv.ViewModel != null).Select(kv => kv.ViewModel!), e.KeyModifiers.HasFlag(KeyModifiers.Shift));
}
private void InputElement_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (_selectionRectangle.IsSelecting)
return;
ViewModel?.SelectKeyframes(new List<ITimelineKeyframeViewModel>(), false);
}
}
@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive.Linq;
using Artemis.UI.Ninject.Factories;
using Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Keyframes;
using Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Segments;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.ProfileEditor;
using Artemis.UI.Shared.Services.ProfileEditor.Commands;
using Avalonia.Controls.Mixins;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline;
public class TimelineViewModel : ActivatableViewModelBase
{
private readonly IProfileEditorService _profileEditorService;
private ObservableAsPropertyHelper<double>? _caretPosition;
private ObservableAsPropertyHelper<int>? _pixelsPerSecond;
private List<ITimelineKeyframeViewModel>? _moveKeyframes;
private ObservableAsPropertyHelper<double> _minWidth;
public TimelineViewModel(ObservableCollection<PropertyGroupViewModel> propertyGroupViewModels,
StartSegmentViewModel startSegmentViewModel,
MainSegmentViewModel mainSegmentViewModel,
EndSegmentViewModel endSegmentViewModel,
IProfileEditorService profileEditorService)
{
PropertyGroupViewModels = propertyGroupViewModels;
StartSegmentViewModel = startSegmentViewModel;
MainSegmentViewModel = mainSegmentViewModel;
EndSegmentViewModel = endSegmentViewModel;
_profileEditorService = profileEditorService;
this.WhenActivated(d =>
{
_caretPosition = _profileEditorService.Time
.CombineLatest(_profileEditorService.PixelsPerSecond, (t, p) => t.TotalSeconds * p)
.ToProperty(this, vm => vm.CaretPosition)
.DisposeWith(d);
_pixelsPerSecond = _profileEditorService.PixelsPerSecond.ToProperty(this, vm => vm.PixelsPerSecond).DisposeWith(d);
_minWidth = profileEditorService.ProfileElement
.Select(p => p?.WhenAnyValue(element => element.Timeline.Length) ?? Observable.Never<TimeSpan>())
.Switch()
.CombineLatest(profileEditorService.PixelsPerSecond, (t, p) => t.TotalSeconds * p + 100)
.ToProperty(this, vm => vm.MinWidth)
.DisposeWith(d);
});
}
public ObservableCollection<PropertyGroupViewModel> PropertyGroupViewModels { get; }
public StartSegmentViewModel StartSegmentViewModel { get; }
public MainSegmentViewModel MainSegmentViewModel { get; }
public EndSegmentViewModel EndSegmentViewModel { get; }
public double CaretPosition => _caretPosition?.Value ?? 0.0;
public int PixelsPerSecond => _pixelsPerSecond?.Value ?? 0;
public double MinWidth => _minWidth?.Value ?? 0;
public void ChangeTime(TimeSpan newTime)
{
_profileEditorService.ChangeTime(newTime);
}
public TimeSpan SnapToTimeline(TimeSpan time, bool snapToSegments, bool snapToCurrentTime, List<TimeSpan>? snapTimes = null)
{
TimeSpan tolerance = TimeSpan.FromMilliseconds(1000f / PixelsPerSecond * 5);
return _profileEditorService.SnapToTimeline(time, tolerance, snapToSegments, snapToCurrentTime, snapTimes);
}
public void SelectKeyframes(IEnumerable<ITimelineKeyframeViewModel> keyframes, bool expand)
{
_profileEditorService.SelectKeyframes(keyframes.Select(k => k.Keyframe), expand);
}
#region Keyframe movement
public void StartKeyframeMovement(ITimelineKeyframeViewModel source)
{
if (!source.IsSelected)
SelectKeyframes(new List<ITimelineKeyframeViewModel> {source}, false);
_moveKeyframes = PropertyGroupViewModels.SelectMany(g => g.GetAllKeyframeViewModels(true)).Where(k => k.IsSelected).ToList();
source.StartMovement(source);
foreach (ITimelineKeyframeViewModel keyframeViewModel in _moveKeyframes)
keyframeViewModel.StartMovement(source);
}
public void UpdateKeyframeMovement(ITimelineKeyframeViewModel source, TimeSpan time, bool snap, bool round)
{
if (_moveKeyframes == null)
return;
if (round)
time = _profileEditorService.RoundTime(time);
if (snap)
time = SnapToTimeline(time, true, true);
// Always update source first
source.UpdateMovement(time);
foreach (ITimelineKeyframeViewModel keyframeViewModel in _moveKeyframes)
keyframeViewModel.UpdateMovement(time);
}
public void FinishKeyframeMovement(ITimelineKeyframeViewModel source, TimeSpan time, bool snap, bool round)
{
if (_moveKeyframes == null)
return;
if (round)
time = _profileEditorService.RoundTime(time);
if (snap)
time = SnapToTimeline(time, true, true);
// If only one selected it's always the source, update it as a single command
if (_moveKeyframes.Count == 1)
{
source.UpdateMovement(time);
source.FinishMovement();
}
// Otherwise update in a scope
else
{
using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope($"Move {_moveKeyframes.Count} keyframes.");
// Always update source first
source.UpdateMovement(time);
foreach (ITimelineKeyframeViewModel keyframeViewModel in _moveKeyframes)
{
keyframeViewModel.UpdateMovement(time);
keyframeViewModel.FinishMovement();
}
}
}
#endregion
#region Keyframe actions
public void DuplicateKeyframes(ITimelineKeyframeViewModel? source = null)
{
if (source is { IsSelected: false })
source.Delete();
else
{
List<ITimelineKeyframeViewModel> keyframes = PropertyGroupViewModels.SelectMany(g => g.GetAllKeyframeViewModels(true)).Where(k => k.IsSelected).ToList();
using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope($"Duplicate {keyframes.Count} keyframes.");
foreach (ITimelineKeyframeViewModel timelineKeyframeViewModel in keyframes)
timelineKeyframeViewModel.Duplicate();
}
}
public void CopyKeyframes(ITimelineKeyframeViewModel? source = null)
{
if (source is { IsSelected: false })
source.Copy();
else
{
List<ITimelineKeyframeViewModel> keyframes = PropertyGroupViewModels.SelectMany(g => g.GetAllKeyframeViewModels(true)).Where(k => k.IsSelected).ToList();
using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope($"Copy {keyframes.Count} keyframes.");
foreach (ITimelineKeyframeViewModel timelineKeyframeViewModel in keyframes)
timelineKeyframeViewModel.Copy();
}
}
public void PasteKeyframes(ITimelineKeyframeViewModel? source = null)
{
if (source is { IsSelected: false })
source.Paste();
else
{
List<ITimelineKeyframeViewModel> keyframes = PropertyGroupViewModels.SelectMany(g => g.GetAllKeyframeViewModels(true)).Where(k => k.IsSelected).ToList();
using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope($"Paste {keyframes.Count} keyframes.");
foreach (ITimelineKeyframeViewModel timelineKeyframeViewModel in keyframes)
timelineKeyframeViewModel.Paste();
}
}
public void DeleteKeyframes(ITimelineKeyframeViewModel? source = null)
{
if (source is {IsSelected: false})
source.Delete();
else
{
List<ITimelineKeyframeViewModel> keyframes = PropertyGroupViewModels.SelectMany(g => g.GetAllKeyframeViewModels(true)).Where(k => k.IsSelected).ToList();
using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope($"Delete {keyframes.Count} keyframes.");
foreach (ITimelineKeyframeViewModel timelineKeyframeViewModel in keyframes)
timelineKeyframeViewModel.Delete();
}
}
#endregion
}
@@ -0,0 +1,14 @@
using Artemis.Core.LayerBrushes;
using Artemis.UI.Shared;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Tree.Dialogs;
public class LayerBrushPresetViewModel : ContentDialogViewModelBase
{
public LayerBrushPresetViewModel(BaseLayerBrush layerBrush)
{
LayerBrush = layerBrush;
}
public BaseLayerBrush LayerBrush { get; }
}
@@ -0,0 +1,11 @@
using Artemis.Core;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Tree;
public interface ITreePropertyViewModel : IReactiveObject
{
ILayerProperty BaseLayerProperty { get; }
bool HasDataBinding { get; }
double GetDepth();
}
@@ -0,0 +1,196 @@
<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:controls="clr-namespace:Artemis.UI.Shared.Controls;assembly=Artemis.UI.Shared"
xmlns:sharedConverters="clr-namespace:Artemis.UI.Shared.Converters;assembly=Artemis.UI.Shared"
xmlns:converters="clr-namespace:Artemis.UI.Converters"
xmlns:viewModel="clr-namespace:Artemis.UI.Screens.ProfileEditor.Properties.Tree"
xmlns:properties="clr-namespace:Artemis.UI.Screens.ProfileEditor.Properties"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.Properties.Tree.TreeGroupView">
<UserControl.Resources>
<converters:PropertyTreeMarginConverter x:Key="PropertyTreeMarginConverter" Length="20" />
<sharedConverters:EnumToBooleanConverter x:Key="EnumBoolConverter" />
</UserControl.Resources>
<UserControl.Styles>
<Style Selector="avalonia|MaterialIcon.chevron-collapsed">
<Setter Property="RenderTransform" Value="rotate(-90deg)" />
</Style>
</UserControl.Styles>
<StackPanel>
<Border Name="Bd"
BorderBrush="{DynamicResource ButtonBorderBrush}"
BorderThickness="0,0,0,1"
Height="29">
<Grid Margin="{Binding Converter={StaticResource PropertyTreeMarginConverter}}" ColumnDefinitions="19,*">
<avalonia:MaterialIcon Classes.chevron-collapsed="{Binding !PropertyGroupViewModel.IsExpanded}"
IsVisible="{Binding PropertyGroupViewModel.HasChildren}"
Kind="ChevronDown"
Grid.Column="0"
Margin="5 0"
PointerPressed="InputElement_OnPointerPressed"
Background="Transparent">
<avalonia:MaterialIcon.Transitions>
<Transitions>
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.2" />
</Transitions>
</avalonia:MaterialIcon.Transitions>
</avalonia:MaterialIcon>
<StackPanel Grid.Column="1">
<!-- Type: None -->
<TextBlock Text="{Binding LayerPropertyGroup.GroupDescription.Name}"
ToolTip.Tip="{Binding LayerPropertyGroup.GroupDescription.Description}"
VerticalAlignment="Center"
HorizontalAlignment="Left"
Margin="3 5 0 5"
IsVisible="{Binding GroupType, Converter={StaticResource EnumBoolConverter}, ConverterParameter={x:Static viewModel:LayerPropertyGroupType.None}}" />
<!-- Type: General -->
<StackPanel Orientation="Horizontal"
Margin="0 5"
IsVisible="{Binding GroupType, Converter={StaticResource EnumBoolConverter}, ConverterParameter={x:Static viewModel:LayerPropertyGroupType.General}}">
<avalonia:MaterialIcon Kind="HammerWrench" Margin="0 -1 5 0" />
<TextBlock ToolTip.Tip="{Binding LayerPropertyGroup.GroupDescription.Description}">General</TextBlock>
</StackPanel>
<!-- Type: Transform -->
<StackPanel Orientation="Horizontal"
Margin="0 5"
IsVisible="{Binding GroupType, Converter={StaticResource EnumBoolConverter}, ConverterParameter={x:Static viewModel:LayerPropertyGroupType.Transform}}">
<avalonia:MaterialIcon Kind="TransitConnectionVariant" Margin="0 -1 5 0" />
<TextBlock ToolTip.Tip="{Binding LayerPropertyGroup.GroupDescription.Description}">Transform</TextBlock>
</StackPanel>
<!-- Type: LayerBrushRoot -->
<Grid IsVisible="{Binding GroupType, Converter={StaticResource EnumBoolConverter}, ConverterParameter={x:Static viewModel:LayerPropertyGroupType.LayerBrushRoot}}"
ColumnDefinitions="Auto,Auto,Auto,*">
<controls:ArtemisIcon Grid.Column="0"
Icon="{Binding LayerBrush.Descriptor.Icon}"
Width="16"
Height="16"
Margin="0 5 5 0" />
<TextBlock Grid.Column="1"
ToolTip.Tip="{Binding LayerBrush.Descriptor.Description}"
Margin="0 5 0 0">
Brush -&#160;
</TextBlock>
<TextBlock Grid.Column="2"
Text="{Binding LayerBrush.Descriptor.DisplayName}"
ToolTip.Tip="{Binding LayerBrush.Descriptor.Description}"
Margin="0 5 0 0" />
<StackPanel Grid.Column="3"
Orientation="Horizontal"
HorizontalAlignment="Right"
IsVisible="{Binding LayerBrush.ConfigurationDialog, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock VerticalAlignment="Center">Extra options available!</TextBlock>
<avalonia:MaterialIcon Kind="ChevronRight" VerticalAlignment="Center">
<avalonia:MaterialIcon.RenderTransform>
<TranslateTransform X="0" />
</avalonia:MaterialIcon.RenderTransform>
</avalonia:MaterialIcon>
<Button Classes="icon-button" ToolTip.Tip="Open brush settings" Width="24" Height="24" HorizontalAlignment="Right" Command="{Binding OpenBrushSettings}">
<avalonia:MaterialIcon Kind="Settings" Height="16" Width="16" />
</Button>
</StackPanel>
</Grid>
<!-- Type: LayerEffectRoot -->
<Grid Height="24"
IsVisible="{Binding GroupType, Converter={StaticResource EnumBoolConverter}, ConverterParameter={x:Static viewModel:LayerPropertyGroupType.LayerEffectRoot}}"
ColumnDefinitions="Auto,Auto,Auto,Auto,*,Auto">
<controls:ArtemisIcon
Grid.Column="0"
Cursor="SizeNorthSouth"
Icon="{Binding LayerEffect.Descriptor.Icon}"
Width="16"
Height="16"
Margin="0 5 5 0"
Background="Transparent" />
<TextBlock Grid.Column="1" ToolTip.Tip="{Binding LayerEffect.Descriptor.Description}" Margin="0 5 0 0">
Effect
</TextBlock>
<TextBlock Grid.Column="2"
ToolTip.Tip="{Binding LayerEffect.Descriptor.Description}"
Margin="3 5">
-
</TextBlock>
<!-- Show either the descriptors display name or, if set, the effect name -->
<TextBlock Grid.Column="3"
Text="{Binding LayerEffect.Descriptor.DisplayName}"
ToolTip.Tip="{Binding LayerEffect.Descriptor.Description}"
Margin="0 5"
IsVisible="{Binding !LayerEffect.Name, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Grid.Column="4"
Text="{Binding LayerEffect.Name}"
ToolTip.Tip="{Binding LayerEffect.Descriptor.Description}"
Margin="0 5"
IsVisible="{Binding LayerEffect.Name, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<StackPanel Grid.Column="5" Orientation="Horizontal">
<ToggleButton
Classes="icon-button"
ToolTip.Tip="Toggle suspended state"
Width="18"
Height="18"
IsChecked="{Binding !LayerEffect.Suspended}"
VerticalAlignment="Center" Padding="-25"
Margin="5 0"
Command="{Binding SuspendedToggled}">
<avalonia:MaterialIcon Kind="Eye" Height="13" Width="13" />
</ToggleButton>
<Button Classes="icon-button"
ToolTip.Tip="Rename"
Width="24"
Height="24"
VerticalAlignment="Center"
Command="{Binding RenameEffect}">
<avalonia:MaterialIcon Kind="RenameBox" Height="16" Width="16" />
</Button>
<Button Classes="icon-button"
ToolTip.Tip="Open effect settings"
Width="24"
Height="24"
VerticalAlignment="Center"
Command="{Binding OpenEffectSettings}"
IsVisible="{Binding LayerEffect.ConfigurationDialog, Converter={x:Static ObjectConverters.IsNotNull}}">
<avalonia:MaterialIcon Kind="Settings" Height="16" Width="16" />
</Button>
<Button Classes="icon-button"
ToolTip.Tip="Remove"
Width="24"
Height="24"
VerticalAlignment="Center"
Command="{Binding DeleteEffect}">
<avalonia:MaterialIcon Kind="TrashCan" Height="16" Width="16" />
</Button>
</StackPanel>
</Grid>
</StackPanel>
</Grid>
</Border>
<!--
Do not bind directly to the PropertyGroupViewModel.Children collection
Instead use a reference provided by the VM that is null when collapsed, virtualization for noobs
-->
<ItemsControl Items="{Binding Children}"
IsVisible="{Binding PropertyGroupViewModel.IsExpanded}"
HorizontalAlignment="Stretch">
<ItemsControl.DataTemplates>
<DataTemplate DataType="properties:PropertyGroupViewModel">
<ContentControl Content="{Binding TreeGroupViewModel}" IsVisible="{Binding IsVisible}" />
</DataTemplate>
<DataTemplate DataType="properties:PropertyViewModel">
<ContentControl Content="{Binding TreePropertyViewModel}" IsVisible="{Binding IsVisible}" />
</DataTemplate>
</ItemsControl.DataTemplates>
</ItemsControl>
</StackPanel>
</UserControl>
@@ -0,0 +1,24 @@
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Tree;
public class TreeGroupView : ReactiveUserControl<TreeGroupViewModel>
{
public TreeGroupView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void InputElement_OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (ViewModel != null)
ViewModel.PropertyGroupViewModel.IsExpanded = !ViewModel.PropertyGroupViewModel.IsExpanded;
}
}
@@ -0,0 +1,167 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive.Disposables;
using System.Reflection;
using System.Threading.Tasks;
using Artemis.Core;
using Artemis.Core.LayerBrushes;
using Artemis.Core.LayerEffects;
using Artemis.UI.Exceptions;
using Artemis.UI.Screens.ProfileEditor.Properties.Windows;
using Artemis.UI.Shared;
using Artemis.UI.Shared.LayerBrushes;
using Artemis.UI.Shared.LayerEffects;
using Artemis.UI.Shared.Services.Interfaces;
using Artemis.UI.Shared.Services.ProfileEditor;
using Ninject;
using Ninject.Parameters;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Tree;
public class TreeGroupViewModel : ActivatableViewModelBase
{
private readonly IProfileEditorService _profileEditorService;
private readonly IWindowService _windowService;
private BrushConfigurationWindowViewModel? _brushConfigurationWindowViewModel;
private EffectConfigurationWindowViewModel? _effectConfigurationWindowViewModel;
public TreeGroupViewModel(PropertyGroupViewModel propertyGroupViewModel, IWindowService windowService, IProfileEditorService profileEditorService)
{
_windowService = windowService;
_profileEditorService = profileEditorService;
PropertyGroupViewModel = propertyGroupViewModel;
DetermineGroupType();
this.WhenActivated(d =>
{
PropertyGroupViewModel.WhenAnyValue(vm => vm.IsExpanded).Subscribe(_ => this.RaisePropertyChanged(nameof(Children))).DisposeWith(d);
Disposable.Create(CloseViewModels).DisposeWith(d);
});
// TODO: Update ProfileElementPropertyGroupViewModel visibility on change (can remove the sub on line 41 as well then)
}
public PropertyGroupViewModel PropertyGroupViewModel { get; }
public LayerPropertyGroup LayerPropertyGroup => PropertyGroupViewModel.LayerPropertyGroup;
public BaseLayerBrush? LayerBrush => PropertyGroupViewModel.LayerBrush;
public BaseLayerEffect? LayerEffect => PropertyGroupViewModel.LayerEffect;
public ObservableCollection<ViewModelBase>? Children => PropertyGroupViewModel.IsExpanded ? PropertyGroupViewModel.Children : null;
public LayerPropertyGroupType GroupType { get; private set; }
public async Task OpenBrushSettings()
{
if (LayerBrush?.ConfigurationDialog is not LayerBrushConfigurationDialog configurationViewModel)
return;
try
{
// Limit to one constructor, there's no need to have more and it complicates things anyway
ConstructorInfo[] constructors = configurationViewModel.Type.GetConstructors();
if (constructors.Length != 1)
throw new ArtemisUIException("Brush configuration dialogs must have exactly one constructor");
// Find the BaseLayerBrush parameter, it is required by the base constructor so its there for sure
ParameterInfo brushParameter = constructors.First().GetParameters().First(p => typeof(BaseLayerBrush).IsAssignableFrom(p.ParameterType));
ConstructorArgument argument = new(brushParameter.Name!, LayerBrush);
BrushConfigurationViewModel viewModel =
(BrushConfigurationViewModel) LayerBrush.Descriptor.Provider.Plugin.Kernel!.Get(configurationViewModel.Type, argument);
_brushConfigurationWindowViewModel = new BrushConfigurationWindowViewModel(viewModel, configurationViewModel);
await _windowService.ShowDialogAsync(_brushConfigurationWindowViewModel);
// Save changes after the dialog closes
await _profileEditorService.SaveProfileAsync();
}
catch (Exception e)
{
_windowService.ShowExceptionDialog("An exception occurred while trying to show the brush's settings window", e);
}
}
public async Task OpenEffectSettings()
{
if (LayerEffect?.ConfigurationDialog is not LayerEffectConfigurationDialog configurationViewModel)
return;
try
{
// Limit to one constructor, there's no need to have more and it complicates things anyway
ConstructorInfo[] constructors = configurationViewModel.Type.GetConstructors();
if (constructors.Length != 1)
throw new ArtemisUIException("Effect configuration dialogs must have exactly one constructor");
// Find the BaseLayerEffect parameter, it is required by the base constructor so its there for sure
ParameterInfo effectParameter = constructors.First().GetParameters().First(p => typeof(BaseLayerEffect).IsAssignableFrom(p.ParameterType));
ConstructorArgument argument = new(effectParameter.Name!, LayerEffect);
EffectConfigurationViewModel viewModel =
(EffectConfigurationViewModel) LayerEffect.Descriptor.Provider.Plugin.Kernel!.Get(configurationViewModel.Type, argument);
_effectConfigurationWindowViewModel = new EffectConfigurationWindowViewModel(viewModel, configurationViewModel);
await _windowService.ShowDialogAsync(_effectConfigurationWindowViewModel);
// Save changes after the dialog closes
await _profileEditorService.SaveProfileAsync();
}
catch (Exception e)
{
_windowService.ShowExceptionDialog("An exception occurred while trying to show the effect's settings window", e);
}
}
public async Task RenameEffect()
{
await _windowService.ShowConfirmContentDialog("Not yet implemented", "Try again later :p");
}
public async Task DeleteEffect()
{
await _windowService.ShowConfirmContentDialog("Not yet implemented", "Try again later :p");
}
public double GetDepth()
{
int depth = 0;
LayerPropertyGroup? current = LayerPropertyGroup.Parent;
while (current != null)
{
depth++;
current = current.Parent;
}
return depth;
}
private void CloseViewModels()
{
_effectConfigurationWindowViewModel?.Close(null);
_brushConfigurationWindowViewModel?.Close(null);
}
private void DetermineGroupType()
{
if (LayerPropertyGroup is LayerGeneralProperties)
GroupType = LayerPropertyGroupType.General;
else if (LayerPropertyGroup is LayerTransformProperties)
GroupType = LayerPropertyGroupType.Transform;
else if (LayerPropertyGroup.Parent == null && PropertyGroupViewModel.LayerBrush != null)
GroupType = LayerPropertyGroupType.LayerBrushRoot;
else if (LayerPropertyGroup.Parent == null && PropertyGroupViewModel.LayerEffect != null)
GroupType = LayerPropertyGroupType.LayerEffectRoot;
else
GroupType = LayerPropertyGroupType.None;
}
}
public enum LayerPropertyGroupType
{
General,
Transform,
LayerBrushRoot,
LayerEffectRoot,
None
}
@@ -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:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:converters="clr-namespace:Artemis.UI.Converters"
xmlns:sharedConverters="clr-namespace:Artemis.UI.Shared.Converters;assembly=Artemis.UI.Shared"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.Properties.Tree.TreePropertyView">
<UserControl.Resources>
<converters:PropertyTreeMarginConverter x:Key="PropertyTreeMarginConverter" Length="20" />
</UserControl.Resources>
<Border Name="Bd"
BorderBrush="{DynamicResource ButtonBorderBrush}"
BorderThickness="0,0,0,1"
Height="29">
<Grid Margin="{Binding Converter={StaticResource PropertyTreeMarginConverter}}" ColumnDefinitions="Auto,*,Auto,Auto,Auto">
<ToggleButton Grid.Column="0"
Classes="icon-button"
ToolTip.Tip="Toggle key-framing"
Width="24"
Height="24"
IsChecked="{Binding KeyframesEnabled}"
IsEnabled="{Binding LayerProperty.KeyframesSupported}"
VerticalAlignment="Center" Padding="-25">
<avalonia:MaterialIcon Kind="Stopwatch" />
</ToggleButton>
<TextBlock Grid.Column="1"
Margin="5,0,0,0"
Padding="0,0,5,0"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
Text="{Binding LayerProperty.PropertyDescription.Name}"
ToolTip.Tip="{Binding LayerProperty.PropertyDescription.Description}"
HorizontalAlignment="Left" />
<ContentControl Grid.Column="2"
Margin="5 0"
Content="{Binding PropertyInputViewModel}"/>
<Button Grid.Column="3"
Margin="0 0 2 0"
Command="{Binding ResetToDefault}"
Classes="icon-button"
ToolTip.Tip="Reset the property to its default value."
Width="24"
Height="24">
<avalonia:MaterialIcon Kind="BackupRestore" />
</Button>
<ToggleButton Grid.Column="4" Classes="icon-button"
Margin="2 0 0 0"
ToolTip.Tip="Change the property's data binding"
Width="24"
Height="24"
VerticalAlignment="Center"
IsEnabled="{Binding LayerProperty.DataBindingsSupported}"
IsChecked="{Binding HasDataBinding, Mode=OneWay}">
<avalonia:MaterialIcon Kind="VectorLink" />
</ToggleButton>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,30 @@
using System;
using System.Reactive.Linq;
using Artemis.Core;
using Avalonia.Controls;
using Avalonia.Controls.Mixins;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Tree;
public class TreePropertyView : ReactiveUserControl<ITreePropertyViewModel>
{
public TreePropertyView()
{
this.WhenActivated(d =>
{
if (ViewModel != null)
Observable.FromEventPattern<LayerPropertyEventArgs>(e => ViewModel.BaseLayerProperty.CurrentValueSet += e, e => ViewModel.BaseLayerProperty.CurrentValueSet -= e)
.Subscribe(_ => this.BringIntoView())
.DisposeWith(d);
});
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
@@ -0,0 +1,76 @@
using System;
using System.Reactive;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.ProfileEditor;
using Artemis.UI.Shared.Services.ProfileEditor.Commands;
using Artemis.UI.Shared.Services.PropertyInput;
using Avalonia.Controls.Mixins;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Tree;
internal class TreePropertyViewModel<T> : ActivatableViewModelBase, ITreePropertyViewModel
{
private readonly IProfileEditorService _profileEditorService;
private TimeSpan _time;
public TreePropertyViewModel(LayerProperty<T> layerProperty, PropertyViewModel propertyViewModel, IProfileEditorService profileEditorService, IPropertyInputService propertyInputService)
{
_profileEditorService = profileEditorService;
LayerProperty = layerProperty;
PropertyViewModel = propertyViewModel;
PropertyInputViewModel = propertyInputService.CreatePropertyInputViewModel(LayerProperty);
this.WhenActivated(d =>
{
_profileEditorService.Time.Subscribe(t => _time = t).DisposeWith(d);
this.WhenAnyValue(vm => vm.LayerProperty.KeyframesEnabled).Subscribe(_ => this.RaisePropertyChanged(nameof(KeyframesEnabled))).DisposeWith(d);
});
ResetToDefault = ReactiveCommand.Create(ExecuteResetToDefault, Observable.Return(LayerProperty.DefaultValue != null));
}
private void ExecuteResetToDefault()
{
_profileEditorService.ExecuteCommand(new ResetLayerProperty<T>(LayerProperty));
}
public LayerProperty<T> LayerProperty { get; }
public PropertyViewModel PropertyViewModel { get; }
public PropertyInputViewModel<T>? PropertyInputViewModel { get; }
public ReactiveCommand<Unit, Unit> ResetToDefault { get; }
public bool KeyframesEnabled
{
get => LayerProperty.KeyframesEnabled;
set => UpdateKeyframesEnabled(value);
}
private void UpdateKeyframesEnabled(bool value)
{
if (value == LayerProperty.KeyframesEnabled)
return;
_profileEditorService.ExecuteCommand(new ToggleLayerPropertyKeyframes<T>(LayerProperty, value, _time));
}
public ILayerProperty BaseLayerProperty => LayerProperty;
public bool HasDataBinding => LayerProperty.HasDataBinding;
public double GetDepth()
{
int depth = 0;
LayerPropertyGroup? current = LayerProperty.LayerPropertyGroup;
while (current != null)
{
depth++;
current = current.Parent;
}
return depth;
}
}
@@ -0,0 +1,16 @@
<controls:CoreWindow 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:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.Properties.Windows.BrushConfigurationWindowView"
Title="Artemis | Brush configuration"
Width="{Binding Configuration.DialogWidth}"
Height="{Binding Configuration.DialogHeight}">
<Panel>
<ContentControl Content="{Binding ConfigurationViewModel}" />
</Panel>
</controls:CoreWindow>
@@ -0,0 +1,27 @@
using System.ComponentModel;
using Avalonia;
using Avalonia.Markup.Xaml;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Windows;
public class BrushConfigurationWindowView : ReactiveCoreWindow<EffectConfigurationWindowViewModel>
{
public BrushConfigurationWindowView()
{
InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
Closing += OnClosing;
}
private void OnClosing(object? sender, CancelEventArgs e)
{
e.Cancel = ViewModel?.CanClose() ?? true;
}
}
@@ -0,0 +1,31 @@
using System;
using Artemis.UI.Shared;
using Artemis.UI.Shared.LayerBrushes;
using Avalonia.Threading;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Windows;
public class BrushConfigurationWindowViewModel : DialogViewModelBase<object?>
{
public BrushConfigurationWindowViewModel(BrushConfigurationViewModel configurationViewModel, LayerBrushConfigurationDialog configuration)
{
ConfigurationViewModel = configurationViewModel;
Configuration = configuration;
ConfigurationViewModel.CloseRequested += ConfigurationViewModelOnCloseRequested;
}
public BrushConfigurationViewModel ConfigurationViewModel { get; }
public LayerBrushConfigurationDialog Configuration { get; }
public bool CanClose()
{
return ConfigurationViewModel.CanClose() && Dispatcher.UIThread.InvokeAsync(async () => await ConfigurationViewModel.CanCloseAsync()).GetAwaiter().GetResult();
}
private void ConfigurationViewModelOnCloseRequested(object? sender, EventArgs e)
{
if (CanClose())
Close(null);
}
}
@@ -0,0 +1,16 @@
<controls:CoreWindow 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:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.Properties.Windows.EffectConfigurationWindowView"
Title="Artemis | Effect configuration"
Width="{Binding Configuration.DialogWidth}"
Height="{Binding Configuration.DialogHeight}">
<Panel>
<ContentControl Content="{Binding ConfigurationViewModel}" />
</Panel>
</controls:CoreWindow>
@@ -0,0 +1,27 @@
using System.ComponentModel;
using Avalonia;
using Avalonia.Markup.Xaml;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Windows;
public class EffectConfigurationWindowView : ReactiveCoreWindow<EffectConfigurationWindowViewModel>
{
public EffectConfigurationWindowView()
{
InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
Closing += OnClosing;
}
private void OnClosing(object? sender, CancelEventArgs e)
{
e.Cancel = ViewModel?.CanClose() ?? true;
}
}
@@ -0,0 +1,31 @@
using System;
using Artemis.UI.Shared;
using Artemis.UI.Shared.LayerEffects;
using Avalonia.Threading;
namespace Artemis.UI.Screens.ProfileEditor.Properties.Windows;
public class EffectConfigurationWindowViewModel : DialogViewModelBase<object?>
{
public EffectConfigurationWindowViewModel(EffectConfigurationViewModel configurationViewModel, LayerEffectConfigurationDialog configuration)
{
ConfigurationViewModel = configurationViewModel;
Configuration = configuration;
ConfigurationViewModel.CloseRequested += ConfigurationViewModelOnCloseRequested;
}
public EffectConfigurationViewModel ConfigurationViewModel { get; }
public LayerEffectConfigurationDialog Configuration { get; }
public bool CanClose()
{
return ConfigurationViewModel.CanClose() && Dispatcher.UIThread.InvokeAsync(async () => await ConfigurationViewModel.CanCloseAsync()).GetAwaiter().GetResult();
}
private void ConfigurationViewModelOnCloseRequested(object? sender, EventArgs e)
{
if (CanClose())
Close(null);
}
}
@@ -0,0 +1,52 @@
<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:core="clr-namespace:Artemis.Core;assembly=Artemis.Core"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="23"
x:Class="Artemis.UI.Screens.ProfileEditor.StatusBar.StatusBarView">
<UserControl.Styles>
<Style Selector="Border.status-message-border">
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrush}"></Setter>
<Setter Property="BorderThickness" Value="1 0 0 0"></Setter>
<Setter Property="Margin" Value="5 2 0 2"></Setter>
<Setter Property="Transitions">
<Setter.Value>
<Transitions>
<DoubleTransition Property="Opacity" Duration="0.2"></DoubleTransition>
</Transitions>
</Setter.Value>
</Setter>
</Style>
<Style Selector="Border.status-message-border.hidden">
<Setter Property="Opacity" Value="0" />
</Style>
</UserControl.Styles>
<Grid ColumnDefinitions="Auto, Auto,*,*" Height="23" Margin="5 0">
<ContentControl Grid.Column="0" Content="{Binding ProfileElement}">
<ContentControl.DataTemplates>
<DataTemplate DataType="core:Folder">
<avalonia:MaterialIcon Kind="Folder" Margin="0 0 5 0" />
</DataTemplate>
<DataTemplate DataType="core:Layer">
<avalonia:MaterialIcon Kind="{Binding LayerBrush.Descriptor.Icon}" Margin="0 0 5 0" />
</DataTemplate>
</ContentControl.DataTemplates>
</ContentControl>
<TextBlock Grid.Column="1" Text="{Binding ProfileElement.Name, FallbackValue=Select a layer to get started}" VerticalAlignment="Center" />
<Border Grid.Column="2" Classes="status-message-border" Classes.hidden="{Binding !ShowStatusMessage}">
<TextBlock Margin="5 0 0 0" Text="{Binding StatusMessage}" />
</Border>
<Slider Grid.Column="3"
Margin="0 -11 0 0"
Minimum="31"
Maximum="350"
Width="319"
Value="{Binding PixelsPerSecond}"
HorizontalAlignment="Right"/>
</Grid>
</UserControl>
@@ -0,0 +1,18 @@
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.StatusBar
{
public partial class StatusBarView : ReactiveUserControl<StatusBarViewModel>
{
public StatusBarView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
}
@@ -0,0 +1,70 @@
using System;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.ProfileEditor;
using Avalonia.Controls.Mixins;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.StatusBar;
public class StatusBarViewModel : ActivatableViewModelBase
{
private readonly IProfileEditorService _profileEditorService;
private ObservableAsPropertyHelper<ProfileEditorHistory?>? _history;
private ObservableAsPropertyHelper<int>? _pixelsPerSecond;
private ObservableAsPropertyHelper<RenderProfileElement?>? _profileElement;
private bool _showStatusMessage;
private string? _statusMessage;
public StatusBarViewModel(IProfileEditorService profileEditorService)
{
_profileEditorService = profileEditorService;
this.WhenActivated(d =>
{
_profileElement = profileEditorService.ProfileElement.ToProperty(this, vm => vm.ProfileElement).DisposeWith(d);
_history = profileEditorService.History.ToProperty(this, vm => vm.History).DisposeWith(d);
_pixelsPerSecond = profileEditorService.PixelsPerSecond.ToProperty(this, vm => vm.PixelsPerSecond);
});
this.WhenAnyValue(vm => vm.History)
.Select(h => h?.Undo ?? Observable.Never<IProfileEditorCommand?>())
.Switch()
.Subscribe(c =>
{
StatusMessage = c != null ? $"Undid '{c.DisplayName}'." : "Nothing to undo.";
ShowStatusMessage = true;
});
this.WhenAnyValue(vm => vm.History)
.Select(h => h?.Redo ?? Observable.Never<IProfileEditorCommand?>())
.Switch()
.Subscribe(c =>
{
StatusMessage = c != null ? $"Redid '{c.DisplayName}'." : "Nothing to redo.";
ShowStatusMessage = true;
});
this.WhenAnyValue(vm => vm.ShowStatusMessage).Where(v => v).Throttle(TimeSpan.FromSeconds(3)).Subscribe(_ => ShowStatusMessage = false);
}
public RenderProfileElement? ProfileElement => _profileElement?.Value;
public ProfileEditorHistory? History => _history?.Value;
public int PixelsPerSecond
{
get => _pixelsPerSecond?.Value ?? 0;
set => _profileEditorService.ChangePixelsPerSecond(value);
}
public string? StatusMessage
{
get => _statusMessage;
set => RaiseAndSetIfChanged(ref _statusMessage, value);
}
public bool ShowStatusMessage
{
get => _showStatusMessage;
set => RaiseAndSetIfChanged(ref _showStatusMessage, value);
}
}
@@ -0,0 +1,20 @@
<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: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.ProfileEditor.VisualEditor.Tools.SelectionAddToolView" ClipToBounds="False">
<Grid>
<controls:SelectionRectangle InputElement="{Binding $parent[ZoomBorder]}"
BorderBrush="{DynamicResource SystemAccentColor}"
BorderRadius="8"
BorderThickness="2"
SelectionFinished="SelectionRectangle_OnSelectionFinished"
ZoomRatio="{Binding $parent[ZoomBorder].ZoomX}">
<controls:SelectionRectangle.Background>
<SolidColorBrush Color="{DynamicResource SystemAccentColorLight1}" Opacity="0.2"></SolidColorBrush>
</controls:SelectionRectangle.Background>
</controls:SelectionRectangle>
</Grid>
</UserControl>
@@ -0,0 +1,25 @@
using Artemis.UI.Shared.Events;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
using Avalonia.Skia;
namespace Artemis.UI.Screens.ProfileEditor.VisualEditor.Tools;
public class SelectionAddToolView : ReactiveUserControl<SelectionAddToolViewModel>
{
public SelectionAddToolView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void SelectionRectangle_OnSelectionFinished(object? sender, SelectionRectangleEventArgs e)
{
ViewModel?.AddLedsInRectangle(e.Rectangle.ToSKRect(), e.KeyModifiers.HasFlag(KeyModifiers.Shift), e.KeyModifiers.HasFlag(KeyModifiers.Control));
}
}
@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.Shared.Services.ProfileEditor;
using Artemis.UI.Shared.Services.ProfileEditor.Commands;
using Avalonia.Controls.Mixins;
using Material.Icons;
using ReactiveUI;
using SkiaSharp;
namespace Artemis.UI.Screens.ProfileEditor.VisualEditor.Tools;
public class SelectionAddToolViewModel : ToolViewModel
{
private readonly IProfileEditorService _profileEditorService;
private readonly IRgbService _rgbService;
private readonly ObservableAsPropertyHelper<bool>? _isEnabled;
private Layer? _layer;
/// <inheritdoc />
public SelectionAddToolViewModel(IProfileEditorService profileEditorService, IRgbService rgbService)
{
_profileEditorService = profileEditorService;
_rgbService = rgbService;
_isEnabled = profileEditorService.ProfileElement.Select(p => p is Layer).ToProperty(this, vm => vm.IsEnabled);
this.WhenActivated(d => profileEditorService.ProfileElement.Subscribe(p => _layer = p as Layer).DisposeWith(d));
}
/// <inheritdoc />
public override bool IsEnabled => _isEnabled?.Value ?? false;
/// <inheritdoc />
public override bool IsExclusive => true;
/// <inheritdoc />
public override bool ShowInToolbar => true;
/// <inheritdoc />
public override int Order => 3;
/// <inheritdoc />
public override MaterialIconKind Icon => MaterialIconKind.SelectionDrag;
/// <inheritdoc />
public override string ToolTip => "Add LEDs to the current layer";
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
_isEnabled?.Dispose();
base.Dispose(disposing);
}
public void AddLedsInRectangle(SKRect rect, bool expand, bool inverse)
{
if (_layer == null)
return;
if (inverse)
{
List<ArtemisLed> toRemove = _layer.Leds.Where(l => l.AbsoluteRectangle.IntersectsWith(rect)).ToList();
List<ArtemisLed> toAdd = _rgbService.EnabledDevices.SelectMany(d => d.Leds).Where(l => l.AbsoluteRectangle.IntersectsWith(rect)).Except(toRemove).ToList();
List<ArtemisLed> leds = _layer.Leds.Except(toRemove).ToList();
leds.AddRange(toAdd);
_profileEditorService.ExecuteCommand(new ChangeLayerLeds(_layer, leds));
}
else
{
List<ArtemisLed> leds = _rgbService.EnabledDevices.SelectMany(d => d.Leds).Where(l => l.AbsoluteRectangle.IntersectsWith(rect)).ToList();
if (expand)
leds.AddRange(_layer.Leds);
_profileEditorService.ExecuteCommand(new ChangeLayerLeds(_layer, leds.Distinct().ToList()));
}
}
}
@@ -0,0 +1,22 @@
<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:controls="clr-namespace:Artemis.UI.Shared.Controls;assembly=Artemis.UI.Shared"
xmlns:paz="clr-namespace:Avalonia.Controls.PanAndZoom;assembly=Avalonia.Controls.PanAndZoom"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.VisualEditor.Tools.SelectionRemoveToolView"
ClipToBounds="False">
<Grid>
<controls:SelectionRectangle InputElement="{Binding $parent[paz:ZoomBorder]}"
BorderBrush="{DynamicResource SystemAccentColor}"
BorderRadius="8"
BorderThickness="2"
SelectionFinished="SelectionRectangle_OnSelectionFinished"
ZoomRatio="{Binding $parent[ZoomBorder].ZoomX}">
<controls:SelectionRectangle.Background>
<SolidColorBrush Color="{DynamicResource SystemAccentColorLight1}" Opacity="0.2"></SolidColorBrush>
</controls:SelectionRectangle.Background>
</controls:SelectionRectangle>
</Grid>
</UserControl>
@@ -0,0 +1,24 @@
using Artemis.UI.Shared.Events;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
using Avalonia.Skia;
namespace Artemis.UI.Screens.ProfileEditor.VisualEditor.Tools;
public class SelectionRemoveToolView : ReactiveUserControl<SelectionRemoveToolViewModel>
{
public SelectionRemoveToolView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void SelectionRectangle_OnSelectionFinished(object? sender, SelectionRectangleEventArgs e)
{
ViewModel?.RemoveLedsInRectangle(e.Rectangle.ToSKRect());
}
}
@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.UI.Shared.Services.ProfileEditor;
using Artemis.UI.Shared.Services.ProfileEditor.Commands;
using Avalonia.Controls.Mixins;
using Material.Icons;
using ReactiveUI;
using SkiaSharp;
namespace Artemis.UI.Screens.ProfileEditor.VisualEditor.Tools;
public class SelectionRemoveToolViewModel : ToolViewModel
{
private readonly ObservableAsPropertyHelper<bool>? _isEnabled;
private readonly IProfileEditorService _profileEditorService;
private Layer? _layer;
/// <inheritdoc />
public SelectionRemoveToolViewModel(IProfileEditorService profileEditorService)
{
_profileEditorService = profileEditorService;
_isEnabled = profileEditorService.ProfileElement.Select(p => p is Layer).ToProperty(this, vm => vm.IsEnabled);
this.WhenActivated(d => profileEditorService.ProfileElement.Subscribe(p => _layer = p as Layer).DisposeWith(d));
}
/// <inheritdoc />
public override bool IsEnabled => _isEnabled?.Value ?? false;
/// <inheritdoc />
public override bool IsExclusive => true;
/// <inheritdoc />
public override bool ShowInToolbar => true;
/// <inheritdoc />
public override int Order => 3;
/// <inheritdoc />
public override MaterialIconKind Icon => MaterialIconKind.SelectOff;
/// <inheritdoc />
public override string ToolTip => "Remove LEDs from the current layer";
public void RemoveLedsInRectangle(SKRect rect)
{
if (_layer == null)
return;
List<ArtemisLed> leds = _layer.Leds.Except(_layer.Leds.Where(l => l.AbsoluteRectangle.IntersectsWith(rect))).ToList();
_profileEditorService.ExecuteCommand(new ChangeLayerLeds(_layer, leds));
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
_isEnabled?.Dispose();
base.Dispose(disposing);
}
}
@@ -0,0 +1,199 @@
<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:tools="clr-namespace:Artemis.UI.Screens.ProfileEditor.VisualEditor.Tools"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.VisualEditor.Tools.TransformToolView"
x:DataType="tools:TransformToolViewModel"
ClipToBounds="False">
<UserControl.Styles>
<Style Selector=":is(Control).unscaled">
<Setter Property="Transitions">
<Setter.Value>
<Transitions>
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.2" Easing="CubicEaseOut" />
</Transitions>
</Setter.Value>
</Setter>
</Style>
<!-- Container -->
<Style Selector="Border#TransformationContainer">
<Setter Property="ClipToBounds" Value="False" />
</Style>
<!-- Resize -->
<Style Selector="Rectangle.transform-resize-visual">
<Setter Property="Margin" Value="-5" />
<Setter Property="Width" Value="10" />
<Setter Property="Height" Value="10" />
<Setter Property="Fill" Value="White" />
<Setter Property="Stroke" Value="{DynamicResource SystemAccentColorLight2}" />
<Setter Property="StrokeThickness" Value="1" />
<Setter Property="StrokeJoin" Value="Round" />
</Style>
<Style Selector="Ellipse.transform-resize-handle">
<Setter Property="Margin" Value="-15" />
<Setter Property="Width" Value="30" />
<Setter Property="Height" Value="30" />
<Setter Property="Fill" Value="Transparent" />
</Style>
<!-- Rotate -->
<Style Selector="Ellipse.transform-rotation-handle">
<Setter Property="Margin" Value="-30" />
<Setter Property="Width" Value="60" />
<Setter Property="Height" Value="60" />
<Setter Property="Fill" Value="Transparent" />
<Setter Property="Cursor" Value="{DynamicResource RotateCursor}" />
</Style>
<!-- Movement -->
<Style Selector="Rectangle.transform-movement-handle">
<Setter Property="Fill" Value="Transparent" />
<Setter Property="Cursor" Value="{DynamicResource DragCursor}" />
</Style>
<!-- Anchor -->
<Style Selector="Panel.transform-anchor-handle">
<Setter Property="Margin" Value="-18.5" />
<Setter Property="Width" Value="37" />
<Setter Property="Height" Value="37" />
<Setter Property="Cursor" Value="SizeAll" />
</Style>
<Style Selector="Panel.transform-anchor-handle :is(Shape)">
<Setter Property="Fill" Value="White" />
<Setter Property="Stroke" Value="{DynamicResource SystemAccentColorLight2}" />
<Setter Property="StrokeThickness" Value="4" />
</Style>
</UserControl.Styles>
<Canvas ClipToBounds="False" UseLayoutRounding="False">
<Border Name="TransformationContainer"
Width="{CompiledBinding ShapeBounds.Width}"
Height="{CompiledBinding ShapeBounds.Height}"
Canvas.Left="{CompiledBinding ShapeBounds.Left}"
Canvas.Top="{CompiledBinding ShapeBounds.Top}"
RenderTransformOrigin="{CompiledBinding RelativeAnchor}">
<Border.RenderTransform>
<RotateTransform Angle="{CompiledBinding Rotation}"></RotateTransform>
</Border.RenderTransform>
<Grid Name="HandleGrid">
<!-- Render these first so that they are always behind the actual shape -->
<Panel VerticalAlignment="Top" HorizontalAlignment="Left">
<Ellipse Name="RotateTopLeft"
Classes="transform-rotation-handle unscaled"
PointerPressed="RotationOnPointerPressed"
PointerReleased="RotationOnPointerReleased"
PointerMoved="RotationOnPointerMoved" />
</Panel>
<Panel VerticalAlignment="Top" HorizontalAlignment="Right">
<Ellipse Name="RotateTopRight"
Classes="transform-rotation-handle unscaled"
PointerPressed="RotationOnPointerPressed"
PointerReleased="RotationOnPointerReleased"
PointerMoved="RotationOnPointerMoved" />
</Panel>
<Panel VerticalAlignment="Bottom" HorizontalAlignment="Right">
<Ellipse Name="RotateBottomRight"
Classes="transform-rotation-handle unscaled"
PointerPressed="RotationOnPointerPressed"
PointerReleased="RotationOnPointerReleased"
PointerMoved="RotationOnPointerMoved" />
</Panel>
<Panel VerticalAlignment="Bottom" HorizontalAlignment="Left">
<Ellipse Name="RotateBottomLeft"
Classes="transform-rotation-handle unscaled"
PointerPressed="RotationOnPointerPressed"
PointerReleased="RotationOnPointerReleased"
PointerMoved="RotationOnPointerMoved" />
</Panel>
<Rectangle Classes="transform-movement-handle"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
PointerPressed="MoveOnPointerPressed"
PointerReleased="MoveOnPointerReleased"
PointerMoved="MoveOnPointerMoved" />
<!-- Mutation points -->
<Panel VerticalAlignment="Top" HorizontalAlignment="Center" Name="ResizeTopCenter">
<Rectangle Classes="transform-resize-visual unscaled" />
<Ellipse Classes="transform-resize-handle unscaled"
PointerPressed="ResizeOnPointerPressed"
PointerReleased="ResizeOnPointerReleased"
PointerMoved="ResizeOnPointerMoved" />
</Panel>
<Panel VerticalAlignment="Center" HorizontalAlignment="Right" Name="ResizeRightCenter">
<Rectangle Classes="transform-resize-visual unscaled" />
<Ellipse Classes="transform-resize-handle unscaled"
PointerPressed="ResizeOnPointerPressed"
PointerReleased="ResizeOnPointerReleased"
PointerMoved="ResizeOnPointerMoved" />
</Panel>
<Panel VerticalAlignment="Bottom" HorizontalAlignment="Center" Name="ResizeBottomCenter">
<Rectangle Classes="transform-resize-visual unscaled" />
<Ellipse Classes="transform-resize-handle unscaled"
PointerPressed="ResizeOnPointerPressed"
PointerReleased="ResizeOnPointerReleased"
PointerMoved="ResizeOnPointerMoved" />
</Panel>
<Panel VerticalAlignment="Center" HorizontalAlignment="Left" Name="ResizeLeftCenter">
<Rectangle Classes="transform-resize-visual unscaled" />
<Ellipse Classes="transform-resize-handle unscaled"
PointerPressed="ResizeOnPointerPressed"
PointerReleased="ResizeOnPointerReleased"
PointerMoved="ResizeOnPointerMoved" />
</Panel>
<Panel VerticalAlignment="Top" HorizontalAlignment="Left" Name="ResizeTopLeft">
<Rectangle Classes="transform-resize-visual unscaled" />
<Ellipse Classes="transform-resize-handle unscaled"
PointerPressed="ResizeOnPointerPressed"
PointerReleased="ResizeOnPointerReleased"
PointerMoved="ResizeOnPointerMoved" />
</Panel>
<Panel VerticalAlignment="Top" HorizontalAlignment="Right" Name="ResizeTopRight">
<Rectangle Classes="transform-resize-visual unscaled" />
<Ellipse Classes="transform-resize-handle unscaled"
PointerPressed="ResizeOnPointerPressed"
PointerReleased="ResizeOnPointerReleased"
PointerMoved="ResizeOnPointerMoved" />
</Panel>
<Panel VerticalAlignment="Bottom" HorizontalAlignment="Right" Name="ResizeBottomRight">
<Rectangle Classes="transform-resize-visual unscaled" />
<Ellipse Classes="transform-resize-handle unscaled"
PointerPressed="ResizeOnPointerPressed"
PointerReleased="ResizeOnPointerReleased"
PointerMoved="ResizeOnPointerMoved" />
</Panel>
<Panel VerticalAlignment="Bottom" HorizontalAlignment="Left" Name="ResizeBottomLeft">
<Rectangle Classes="transform-resize-visual unscaled" />
<Ellipse Classes="transform-resize-handle unscaled"
PointerPressed="ResizeOnPointerPressed"
PointerReleased="ResizeOnPointerReleased"
PointerMoved="ResizeOnPointerMoved" />
</Panel>
<Canvas Name="AnchorCanvas">
<Panel Background="Transparent"
Name="AnchorPoint"
Classes="transform-anchor-handle unscaled"
Canvas.Left="{CompiledBinding Anchor.X}"
Canvas.Top="{CompiledBinding Anchor.Y}"
PointerPressed="AnchorOnPointerPressed"
PointerReleased="AnchorOnPointerReleased"
PointerMoved="AnchorOnPointerMoved">
<Ellipse StrokeThickness="4" Width="14" Height="14" VerticalAlignment="Center" HorizontalAlignment="Center" />
<Line StartPoint="0,0" EndPoint="0,10" StrokeThickness="2" VerticalAlignment="Top" HorizontalAlignment="Center" />
<Line StartPoint="0,25" EndPoint="0,35" StrokeThickness="2" VerticalAlignment="Bottom" HorizontalAlignment="Center" />
<Line StartPoint="0,0" EndPoint="10,0" StrokeThickness="2" VerticalAlignment="Center" HorizontalAlignment="Left" />
<Line StartPoint="25,0" EndPoint="35,0" StrokeThickness="2" VerticalAlignment="Center" HorizontalAlignment="Right" />
</Panel>
</Canvas>
</Grid>
</Border>
</Canvas>
</UserControl>
@@ -0,0 +1,411 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Artemis.Core;
using Artemis.UI.Shared.Extensions;
using Artemis.UI.Shared.Providers;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Mixins;
using Avalonia.Controls.PanAndZoom;
using Avalonia.Controls.Shapes;
using Avalonia.Input;
using Avalonia.LogicalTree;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Media.Transformation;
using Avalonia.ReactiveUI;
using Avalonia.Skia;
using Avalonia.VisualTree;
using ReactiveUI;
using SkiaSharp;
namespace Artemis.UI.Screens.ProfileEditor.VisualEditor.Tools;
public class TransformToolView : ReactiveUserControl<TransformToolViewModel>
{
private readonly List<Control> _handles = new();
private readonly Panel _resizeBottomCenter;
private readonly Panel _resizeBottomLeft;
private readonly Panel _resizeBottomRight;
private readonly Panel _resizeLeftCenter;
private readonly Panel _resizeRightCenter;
private readonly Panel _resizeTopCenter;
private readonly Panel _resizeTopLeft;
private readonly Panel _resizeTopRight;
private SKPoint _dragOffset;
private ZoomBorder? _zoomBorder;
private readonly Grid _handleGrid;
public TransformToolView()
{
InitializeComponent();
_handleGrid = this.Get<Grid>("HandleGrid");
_handles.Add(this.Get<Ellipse>("RotateTopLeft"));
_handles.Add(this.Get<Ellipse>("RotateTopRight"));
_handles.Add(this.Get<Ellipse>("RotateBottomRight"));
_handles.Add(this.Get<Ellipse>("RotateBottomLeft"));
_resizeTopCenter = this.Get<Panel>("ResizeTopCenter");
_handles.Add(_resizeTopCenter);
_resizeRightCenter = this.Get<Panel>("ResizeRightCenter");
_handles.Add(_resizeRightCenter);
_resizeBottomCenter = this.Get<Panel>("ResizeBottomCenter");
_handles.Add(_resizeBottomCenter);
_resizeLeftCenter = this.Get<Panel>("ResizeLeftCenter");
_handles.Add(_resizeLeftCenter);
_resizeTopLeft = this.Get<Panel>("ResizeTopLeft");
_handles.Add(_resizeTopLeft);
_resizeTopRight = this.Get<Panel>("ResizeTopRight");
_handles.Add(_resizeTopRight);
_resizeBottomRight = this.Get<Panel>("ResizeBottomRight");
_handles.Add(_resizeBottomRight);
_resizeBottomLeft = this.Get<Panel>("ResizeBottomLeft");
_handles.Add(_resizeBottomLeft);
_handles.Add(this.Get<Panel>("AnchorPoint"));
this.WhenActivated(d => ViewModel.WhenAnyValue(vm => vm.Rotation).Subscribe(_ => UpdateTransforms()).DisposeWith(d));
}
private void UpdateTransforms()
{
if (_zoomBorder == null || ViewModel == null)
return;
double resizeSize = Math.Clamp(1 / _zoomBorder.ZoomX, 0.2, 2);
TransformOperations.Builder builder = TransformOperations.CreateBuilder(2);
builder.AppendScale(resizeSize, resizeSize);
TransformOperations counterScale = builder.Build();
RotateTransform counterRotate = new(ViewModel.Rotation * -1);
// Apply the counter rotation to the containers
foreach (Panel panel in _handleGrid.Children.Where(c => c is Panel and not Canvas).Cast<Panel>())
panel.RenderTransform = counterRotate;
foreach (Control control in _handleGrid.GetVisualDescendants().Where(d => d is Control c && c.Classes.Contains("unscaled")).Cast<Control>())
control.RenderTransform = counterScale;
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private SKPoint GetPositionForViewModel(PointerEventArgs e)
{
if (ViewModel?.Layer == null)
return SKPoint.Empty;
SKPoint point = CounteractLayerRotation(e.GetCurrentPoint(this).Position.ToSKPoint(), ViewModel.Layer);
return point + _dragOffset;
}
private static SKPoint CounteractLayerRotation(SKPoint point, Layer layer)
{
SKPoint pivot = layer.GetLayerAnchorPosition();
using SKPath counterRotatePath = new();
counterRotatePath.AddPoly(new[] {SKPoint.Empty, point}, false);
counterRotatePath.Transform(SKMatrix.CreateRotationDegrees(layer.Transform.Rotation.CurrentValue * -1, pivot.X, pivot.Y));
return counterRotatePath.Points[1];
}
private TransformToolViewModel.ResizeSide GetResizeDirection(Ellipse element)
{
if (ReferenceEquals(element.Parent, _resizeTopLeft))
return TransformToolViewModel.ResizeSide.Top | TransformToolViewModel.ResizeSide.Left;
if (ReferenceEquals(element.Parent, _resizeTopRight))
return TransformToolViewModel.ResizeSide.Top | TransformToolViewModel.ResizeSide.Right;
if (ReferenceEquals(element.Parent, _resizeBottomRight))
return TransformToolViewModel.ResizeSide.Bottom | TransformToolViewModel.ResizeSide.Right;
if (ReferenceEquals(element.Parent, _resizeBottomLeft))
return TransformToolViewModel.ResizeSide.Bottom | TransformToolViewModel.ResizeSide.Left;
if (ReferenceEquals(element.Parent, _resizeTopCenter))
return TransformToolViewModel.ResizeSide.Top;
if (ReferenceEquals(element.Parent, _resizeRightCenter))
return TransformToolViewModel.ResizeSide.Right;
if (ReferenceEquals(element.Parent, _resizeBottomCenter))
return TransformToolViewModel.ResizeSide.Bottom;
if (ReferenceEquals(element.Parent, _resizeLeftCenter))
return TransformToolViewModel.ResizeSide.Left;
throw new ArgumentException("Given element is not a child of a resize container");
}
#region Zoom
/// <inheritdoc />
protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e)
{
_zoomBorder = (ZoomBorder?) this.GetLogicalAncestors().FirstOrDefault(l => l is ZoomBorder);
if (_zoomBorder != null)
_zoomBorder.PropertyChanged += ZoomBorderOnPropertyChanged;
base.OnAttachedToLogicalTree(e);
}
/// <inheritdoc />
protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e)
{
if (_zoomBorder != null)
_zoomBorder.PropertyChanged -= ZoomBorderOnPropertyChanged;
base.OnDetachedFromLogicalTree(e);
}
private void ZoomBorderOnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property != ZoomBorder.ZoomXProperty || _zoomBorder == null)
return;
UpdateTransforms();
}
#endregion
#region Movement
private void MoveOnPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender == null || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || ViewModel?.Layer == null)
return;
SKPoint dragStart = e.GetCurrentPoint(this).Position.ToSKPoint();
SKRect shapeBounds = ViewModel.Layer.GetLayerPath(true, true, false).Bounds;
_dragOffset = new SKPoint(dragStart.X - shapeBounds.Left, dragStart.Y - shapeBounds.Top);
ViewModel.StartMovement();
ToolTip.SetTip((Control) sender, $"X: {ViewModel.Layer.Transform.Position.CurrentValue.X:F3}% Y: {ViewModel.Layer.Transform.Position.CurrentValue.Y:F3}%");
ToolTip.SetIsOpen((Control) sender, true);
e.Pointer.Capture((IInputElement?) sender);
e.Handled = true;
}
private void MoveOnPointerMoved(object? sender, PointerEventArgs e)
{
if (!ReferenceEquals(e.Pointer.Captured, sender) || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || ViewModel?.Layer == null)
return;
SKPoint position = e.GetCurrentPoint(this).Position.ToSKPoint() - _dragOffset;
ViewModel.UpdateMovement(position, e.KeyModifiers.HasFlag(KeyModifiers.Shift), e.KeyModifiers.HasFlag(KeyModifiers.Control));
ToolTip.SetTip((Control) sender, $"X: {ViewModel.Layer.Transform.Position.CurrentValue.X:F3}% Y: {ViewModel.Layer.Transform.Position.CurrentValue.Y:F3}%");
e.Handled = true;
}
private void MoveOnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (sender == null || !ReferenceEquals(e.Pointer.Captured, sender) || e.InitialPressMouseButton != MouseButton.Left || ViewModel?.Layer == null)
return;
ViewModel.FinishMovement();
ToolTip.SetTip((Control) sender, null);
ToolTip.SetIsOpen((Control) sender, false);
e.Pointer.Capture(null);
e.Handled = true;
}
#endregion
#region Anchor movement
private void AnchorOnPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender == null || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || ViewModel?.Layer == null)
return;
SKPoint dragStart = e.GetCurrentPoint(this).Position.ToSKPoint();
_dragOffset = dragStart - ViewModel.Layer.GetLayerAnchorPosition();
ViewModel.StartAnchorMovement(e.GetCurrentPoint(this).Position.ToSKPoint() - _dragOffset);
ToolTip.SetTip((Control) sender, $"X: {ViewModel.Layer.Transform.AnchorPoint.CurrentValue.X:F3}% Y: {ViewModel.Layer.Transform.AnchorPoint.CurrentValue.Y:F3}%");
ToolTip.SetIsOpen((Control) sender, true);
e.Pointer.Capture((IInputElement?) sender);
e.Handled = true;
}
private void AnchorOnPointerMoved(object? sender, PointerEventArgs e)
{
if (!ReferenceEquals(e.Pointer.Captured, sender) || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || ViewModel?.Layer == null)
return;
SKPoint position = e.GetCurrentPoint(this).Position.ToSKPoint() - _dragOffset;
ViewModel.UpdateAnchorMovement(position, e.KeyModifiers.HasFlag(KeyModifiers.Shift), e.KeyModifiers.HasFlag(KeyModifiers.Control));
ToolTip.SetTip((Control) sender, $"X: {ViewModel.Layer.Transform.AnchorPoint.CurrentValue.X:F3}% Y: {ViewModel.Layer.Transform.AnchorPoint.CurrentValue.Y:F3}%");
e.Handled = true;
}
private void AnchorOnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (sender == null || !ReferenceEquals(e.Pointer.Captured, sender) || e.InitialPressMouseButton != MouseButton.Left || ViewModel?.Layer == null)
return;
ViewModel.FinishAnchorMovement();
ToolTip.SetTip((Control) sender, null);
ToolTip.SetIsOpen((Control) sender, false);
e.Pointer.Capture(null);
e.Handled = true;
}
#endregion
#region Resizing
private void ResizeOnPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender == null || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || ViewModel?.Layer == null)
return;
SKPoint dragStart = CounteractLayerRotation(e.GetCurrentPoint(this).Position.ToSKPoint(), ViewModel.Layer);
_dragOffset = ViewModel.Layer.GetDragOffset(dragStart);
ToolTip.SetTip((Control) sender, $"Width: {ViewModel.Layer.Transform.Scale.CurrentValue.Width:F3}% Height: {ViewModel.Layer.Transform.Scale.CurrentValue.Height:F3}%");
ToolTip.SetIsOpen((Control) sender, true);
ViewModel.StartResize();
e.Pointer.Capture((IInputElement?) sender);
e.Handled = true;
}
private void ResizeOnPointerMoved(object? sender, PointerEventArgs e)
{
UpdateCursors();
if (sender is not Ellipse element || !ReferenceEquals(e.Pointer.Captured, element) || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || ViewModel?.Layer == null)
return;
SKPoint position = GetPositionForViewModel(e);
ViewModel.UpdateResize(
position,
GetResizeDirection(element),
e.KeyModifiers.HasFlag(KeyModifiers.Alt),
e.KeyModifiers.HasFlag(KeyModifiers.Shift),
e.KeyModifiers.HasFlag(KeyModifiers.Control)
);
ToolTip.SetTip((Control) sender, $"Width: {ViewModel.Layer.Transform.Scale.CurrentValue.Width:F3}% Height: {ViewModel.Layer.Transform.Scale.CurrentValue.Height:F3}%");
e.Handled = true;
}
private void ResizeOnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (sender == null || !ReferenceEquals(e.Pointer.Captured, sender) || e.InitialPressMouseButton != MouseButton.Left || ViewModel?.Layer == null)
return;
ViewModel.FinishResize();
ToolTip.SetTip((Control) sender, null);
ToolTip.SetIsOpen((Control) sender, false);
e.Pointer.Capture(null);
e.Handled = true;
}
private void UpdateCursors()
{
_resizeTopCenter.Cursor = GetCursorAtAngle(0f);
_resizeTopRight.Cursor = GetCursorAtAngle(45f);
_resizeRightCenter.Cursor = GetCursorAtAngle(90f);
_resizeBottomRight.Cursor = GetCursorAtAngle(135f);
_resizeBottomCenter.Cursor = GetCursorAtAngle(180f);
_resizeBottomLeft.Cursor = GetCursorAtAngle(225f);
_resizeLeftCenter.Cursor = GetCursorAtAngle(270f);
_resizeTopLeft.Cursor = GetCursorAtAngle(315f);
}
private Cursor GetCursorAtAngle(float angle, bool includeLayerRotation = true)
{
if (includeLayerRotation && ViewModel?.Layer != null)
angle = (angle + ViewModel.Layer.Transform.Rotation.CurrentValue) % 360;
if (angle is > 330 and <= 360 or >= 0 and <= 30)
return new Cursor(StandardCursorType.TopSide);
if (angle is > 30 and <= 60)
return new Cursor(StandardCursorType.TopRightCorner);
if (angle is > 60 and <= 120)
return new Cursor(StandardCursorType.RightSide);
if (angle is > 120 and <= 150)
return new Cursor(StandardCursorType.BottomRightCorner);
if (angle is > 150 and <= 210)
return new Cursor(StandardCursorType.BottomSide);
if (angle is > 210 and <= 240)
return new Cursor(StandardCursorType.BottomLeftCorner);
if (angle is > 240 and <= 300)
return new Cursor(StandardCursorType.LeftSide);
if (angle is > 300 and <= 330)
return new Cursor(StandardCursorType.TopLeftCorner);
return Cursor.Default;
}
#endregion
#region Rotation
private float _rotationDragOffset;
private void RotationOnPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender == null || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || ViewModel?.Layer == null)
return;
float startAngle = CalculateAngleToAnchor(e);
_rotationDragOffset = startAngle - ViewModel.Layer.Transform.Rotation;
ViewModel.StartRotation();
ToolTip.SetTip((Control)sender, $"{ViewModel.Layer.Transform.Rotation.CurrentValue:F3}°");
ToolTip.SetIsOpen((Control)sender, true);
e.Pointer.Capture((IInputElement?) sender);
e.Handled = true;
}
private void RotationOnPointerMoved(object? sender, PointerEventArgs e)
{
if (sender == null || !ReferenceEquals(e.Pointer.Captured, sender) || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
return;
float angle = CalculateAngleToAnchor(e);
angle -= _rotationDragOffset;
if (angle < 0)
angle += 360;
ViewModel?.UpdateRotation(angle, e.KeyModifiers.HasFlag(KeyModifiers.Control));
ToolTip.SetTip((Control)sender, $"{ViewModel.Layer.Transform.Rotation.CurrentValue:F3}°");
e.Handled = true;
}
private void RotationOnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (sender == null || !ReferenceEquals(e.Pointer.Captured, sender) || e.InitialPressMouseButton != MouseButton.Left)
return;
ViewModel?.FinishRotation();
ToolTip.SetTip((Control)sender, null);
ToolTip.SetIsOpen((Control)sender, false);
e.Pointer.Capture(null);
e.Handled = true;
}
private float CalculateAngleToAnchor(PointerEventArgs e)
{
if (ViewModel?.Layer == null)
return 0;
SKPoint start = ViewModel.Layer.GetLayerAnchorPosition();
SKPoint arrival = e.GetCurrentPoint(this).Position.ToSKPoint();
float radian = (float) Math.Atan2(start.Y - arrival.Y, start.X - arrival.X);
float angle = radian * (180f / (float) Math.PI);
return angle;
}
#endregion
}
@@ -0,0 +1,464 @@
using System;
using System.Reactive;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.UI.Exceptions;
using Artemis.UI.Shared.Extensions;
using Artemis.UI.Shared.Services.ProfileEditor;
using Artemis.UI.Shared.Services.ProfileEditor.Commands;
using Avalonia;
using Avalonia.Controls.Mixins;
using Material.Icons;
using ReactiveUI;
using SkiaSharp;
namespace Artemis.UI.Screens.ProfileEditor.VisualEditor.Tools;
public class TransformToolViewModel : ToolViewModel
{
private readonly ObservableAsPropertyHelper<bool> _isEnabled;
private readonly IProfileEditorService _profileEditorService;
private Point _anchor;
private ObservableAsPropertyHelper<Layer?>? _layer;
private RelativePoint _relativeAnchor;
private double _rotation;
private Rect _shapeBounds;
private TimeSpan _time;
/// <inheritdoc />
public TransformToolViewModel(IProfileEditorService profileEditorService)
{
_profileEditorService = profileEditorService;
// Not disposed when deactivated but when really disposed
_isEnabled = profileEditorService.ProfileElement.Select(p => p is Layer).ToProperty(this, vm => vm.IsEnabled);
this.WhenActivated(d =>
{
_layer = profileEditorService.ProfileElement.Select(p => p as Layer).ToProperty(this, vm => vm.Layer).DisposeWith(d);
this.WhenAnyValue(vm => vm.Layer)
.Select(l => l != null
? Observable.FromEventPattern(x => l.RenderPropertiesUpdated += x, x => l.RenderPropertiesUpdated -= x)
: Observable.Never<EventPattern<object>>())
.Switch()
.Subscribe(_ => Update())
.DisposeWith(d);
this.WhenAnyValue(vm => vm.Layer)
.Select(l => l != null
? Observable.FromEventPattern<LayerPropertyEventArgs>(x => l.Transform.Position.CurrentValueSet += x, x => l.Transform.Position.CurrentValueSet -= x)
: Observable.Never<EventPattern<LayerPropertyEventArgs>>())
.Switch()
.Subscribe(_ => Update())
.DisposeWith(d);
this.WhenAnyValue(vm => vm.Layer)
.Select(l => l != null
? Observable.FromEventPattern<LayerPropertyEventArgs>(x => l.Transform.Rotation.CurrentValueSet += x, x => l.Transform.Rotation.CurrentValueSet -= x)
: Observable.Never<EventPattern<LayerPropertyEventArgs>>())
.Switch()
.Subscribe(_ => Update())
.DisposeWith(d);
this.WhenAnyValue(vm => vm.Layer)
.Select(l => l != null
? Observable.FromEventPattern<LayerPropertyEventArgs>(x => l.Transform.Scale.CurrentValueSet += x, x => l.Transform.Scale.CurrentValueSet -= x)
: Observable.Never<EventPattern<LayerPropertyEventArgs>>())
.Switch()
.Subscribe(_ => Update())
.DisposeWith(d);
this.WhenAnyValue(vm => vm.Layer)
.Select(l => l != null
? Observable.FromEventPattern<LayerPropertyEventArgs>(x => l.Transform.AnchorPoint.CurrentValueSet += x, x => l.Transform.AnchorPoint.CurrentValueSet -= x)
: Observable.Never<EventPattern<LayerPropertyEventArgs>>())
.Switch()
.Subscribe(_ => Update())
.DisposeWith(d);
this.WhenAnyValue(vm => vm.Layer).Subscribe(_ => Update()).DisposeWith(d);
profileEditorService.Time.Subscribe(t =>
{
_time = t;
Update();
}).DisposeWith(d);
});
}
public Layer? Layer => _layer?.Value;
/// <inheritdoc />
public override bool IsEnabled => _isEnabled?.Value ?? false;
/// <inheritdoc />
public override bool IsExclusive => true;
/// <inheritdoc />
public override bool ShowInToolbar => true;
/// <inheritdoc />
public override int Order => 3;
/// <inheritdoc />
public override MaterialIconKind Icon => MaterialIconKind.TransitConnectionVariant;
/// <inheritdoc />
public override string ToolTip => "Transform the shape of the current layer";
public Rect ShapeBounds
{
get => _shapeBounds;
set => RaiseAndSetIfChanged(ref _shapeBounds, value);
}
public double Rotation
{
get => _rotation;
set => RaiseAndSetIfChanged(ref _rotation, value);
}
public RelativePoint RelativeAnchor
{
get => _relativeAnchor;
set => RaiseAndSetIfChanged(ref _relativeAnchor, value);
}
public Point Anchor
{
get => _anchor;
set => RaiseAndSetIfChanged(ref _anchor, value);
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
_isEnabled?.Dispose();
base.Dispose(disposing);
}
private void Update()
{
if (Layer == null)
return;
SKPath path = new();
SKRect layerBounds = Layer.GetLayerBounds();
path.AddRect(layerBounds);
path.Transform(Layer.GetTransformMatrix(false, true, true, false, layerBounds));
ShapeBounds = path.Bounds.ToRect();
Rotation = Layer.Transform.Rotation.CurrentValue;
SKPoint layerAnchor = Layer.Transform.AnchorPoint.CurrentValue;
RelativeAnchor = new RelativePoint(layerAnchor.X, layerAnchor.Y, RelativeUnit.Relative);
Anchor = new Point(ShapeBounds.Width * RelativeAnchor.Point.X, ShapeBounds.Height * RelativeAnchor.Point.Y);
}
[Flags]
public enum ResizeSide
{
Top = 1,
Right = 2,
Bottom = 4,
Left = 8
}
#region Movement
private LayerPropertyPreview<SKPoint>? _movementPreview;
public void StartMovement()
{
if (Layer == null)
return;
_movementPreview?.DiscardPreview();
_movementPreview = new LayerPropertyPreview<SKPoint>(Layer.Transform.Position, _time);
}
public void UpdateMovement(SKPoint position, bool stickToAxis, bool round)
{
if (Layer == null)
return;
if (_movementPreview == null)
throw new ArtemisUIException("Can't update movement without a preview having been started");
// Get a normalized point
SKPoint scaled = Layer.GetNormalizedPoint(position, true);
// Compensate for the anchor
scaled.X += ((Layer.Transform.AnchorPoint.CurrentValue.X) * (Layer.Transform.Scale.CurrentValue.Width/100f));
scaled.Y += ((Layer.Transform.AnchorPoint.CurrentValue.Y) * (Layer.Transform.Scale.CurrentValue.Height/100f));
_movementPreview.Preview(scaled);
}
public void FinishMovement()
{
if (Layer == null)
return;
if (_movementPreview == null)
throw new ArtemisUIException("Can't update movement without a preview having been started");
if (_movementPreview.DiscardPreview())
_profileEditorService.ExecuteCommand(new UpdateLayerProperty<SKPoint>(Layer.Transform.Position, _movementPreview.PreviewValue, _time));
_movementPreview = null;
}
#endregion
#region Anchor movement
private SKPoint _dragOffset;
private SKPoint _dragStartAnchor;
private LayerPropertyPreview<SKPoint>? _anchorMovementPreview;
public void StartAnchorMovement(SKPoint position)
{
if (Layer == null)
return;
// Mouse doesn't care about rotation so get the layer path without rotation
SKPath path = Layer.GetLayerPath(true, true, false);
SKPoint topLeft = path.Points[0];
// Measure from the top-left of the shape (without rotation)
_dragOffset = topLeft + (position - topLeft);
// Get the absolute layer anchor and make it relative to the unrotated shape
_dragStartAnchor = Layer.GetLayerAnchorPosition() - topLeft;
_movementPreview?.DiscardPreview();
_anchorMovementPreview?.DiscardPreview();
_movementPreview = new LayerPropertyPreview<SKPoint>(Layer.Transform.Position, _time);
_anchorMovementPreview = new LayerPropertyPreview<SKPoint>(Layer.Transform.AnchorPoint, _time);
}
public void UpdateAnchorMovement(SKPoint position, bool stickToAxis, bool round)
{
if (Layer == null)
return;
if (_movementPreview == null || _anchorMovementPreview == null)
throw new ArtemisUIException("Can't update movement without a preview having been started");
SKPoint topLeft = Layer.GetLayerPath(true, true, true)[0];
// The start anchor is relative to an unrotated version of the shape
SKPoint start = _dragStartAnchor;
// Add the current position to the start anchor to determine the new position
SKPoint current = start + (position - _dragOffset);
// In order to keep the mouse movement unrotated, counter-act the active rotation
SKPoint[] countered = UnTransformPoints(new[] {start, current}, Layer, start, true);
// If shift is held down, round down to 1 decimal to allow moving the anchor in big increments
int decimals = round ? 1 : 3;
SKPoint scaled = RoundPoint(Layer.GetNormalizedPoint(countered[1], false), decimals);
// Update the anchor point, this causes the shape to move
_anchorMovementPreview.Preview(scaled);
// TopLeft is not updated yet and acts as a snapshot of the top-left before changing the anchor
SKPath path = Layer.GetLayerPath(true, true, true);
// Calculate the (scaled) difference between the old and now position
SKPoint difference = Layer.GetNormalizedPoint(topLeft - path.Points[0], false);
// Apply the difference so that the shape effectively stays in place
_movementPreview.Preview(Layer.Transform.Position.CurrentValue + difference);
}
public void FinishAnchorMovement()
{
if (Layer == null)
return;
if (_movementPreview == null || _anchorMovementPreview == null)
throw new ArtemisUIException("Can't update movement without a preview having been started");
// Not interested in this one's return value but we do need to discard it
_movementPreview.DiscardPreview();
if (_anchorMovementPreview.DiscardPreview())
{
using ProfileEditorCommandScope commandScope = _profileEditorService.CreateCommandScope("Update anchor point");
_profileEditorService.ExecuteCommand(new UpdateLayerProperty<SKPoint>(Layer.Transform.Position, _movementPreview.PreviewValue, _time));
_profileEditorService.ExecuteCommand(new UpdateLayerProperty<SKPoint>(Layer.Transform.AnchorPoint, _anchorMovementPreview.PreviewValue, _time));
}
_movementPreview = null;
_anchorMovementPreview = null;
}
#endregion
#region Resizing
private LayerPropertyPreview<SKSize>? _resizePreview;
public void StartResize()
{
if (Layer == null)
return;
_resizePreview?.DiscardPreview();
_resizePreview = new LayerPropertyPreview<SKSize>(Layer.Transform.Scale, _time);
}
public void UpdateResize(SKPoint position, ResizeSide side, bool evenPercentages, bool evenPixels, bool round)
{
if (Layer == null)
return;
if (_resizePreview == null)
throw new ArtemisUIException("Can't update size without a preview having been started");
SKPoint normalizedAnchor = Layer.Transform.AnchorPoint;
normalizedAnchor = new SKPoint(normalizedAnchor.X, normalizedAnchor.Y);
// The anchor is used to ensure a side can't shrink past the anchor
SKPoint anchor = Layer.GetLayerAnchorPosition();
SKRect bounds = Layer.GetLayerBounds();
float width = Layer.Transform.Scale.CurrentValue.Width;
float height = Layer.Transform.Scale.CurrentValue.Height;
// Resize each side as requested, the sides of each axis are mutually exclusive
if (side.HasFlag(ResizeSide.Left) && normalizedAnchor.X != 0)
{
if (position.X > anchor.X)
position.X = anchor.X;
float anchorWeight = normalizedAnchor.X;
float requiredDistance = anchor.X - position.X;
float requiredSize = requiredDistance / anchorWeight;
width = requiredSize / bounds.Width * 100f;
if (round)
width = MathF.Round(width / 5f) * 5f;
}
else if (side.HasFlag(ResizeSide.Right) && Math.Abs(normalizedAnchor.X - 1) > 0.001)
{
if (position.X < anchor.X)
position.X = anchor.X;
float anchorWeight = 1f - normalizedAnchor.X;
float requiredDistance = position.X - anchor.X;
float requiredSize = requiredDistance / anchorWeight;
width = requiredSize / bounds.Width * 100f;
if (round)
width = MathF.Round(width / 5f) * 5f;
}
if (side.HasFlag(ResizeSide.Top) && normalizedAnchor.Y != 0)
{
if (position.Y > anchor.Y)
position.Y = anchor.Y;
float anchorWeight = normalizedAnchor.Y;
float requiredDistance = anchor.Y - position.Y;
float requiredSize = requiredDistance / anchorWeight;
height = requiredSize / bounds.Height * 100f;
if (round)
height = MathF.Round(height / 5f) * 5f;
}
else if (side.HasFlag(ResizeSide.Bottom) && Math.Abs(normalizedAnchor.Y - 1) > 0.001)
{
if (position.Y < anchor.Y)
position.Y = anchor.Y;
float anchorWeight = 1f - normalizedAnchor.Y;
float requiredDistance = position.Y - anchor.Y;
float requiredSize = requiredDistance / anchorWeight;
height = requiredSize / bounds.Height * 100f;
if (round)
height = MathF.Round(height / 5f) * 5f;
}
// Apply side evening but only when resizing on a corner
bool resizingCorner = (side.HasFlag(ResizeSide.Left) || side.HasFlag(ResizeSide.Right)) && (side.HasFlag(ResizeSide.Top) || side.HasFlag(ResizeSide.Bottom));
if (evenPercentages && resizingCorner)
{
if (width > height)
width = height;
else
height = width;
}
else if (evenPixels && resizingCorner)
{
if (width * bounds.Width > height * bounds.Height)
height = width * bounds.Width / bounds.Height;
else
width = height * bounds.Height / bounds.Width;
}
_resizePreview.Preview(new SKSize(width, height));
}
public void FinishResize()
{
if (Layer == null)
return;
if (_resizePreview == null)
throw new ArtemisUIException("Can't update size without a preview having been started");
if (_resizePreview.DiscardPreview())
_profileEditorService.ExecuteCommand(new UpdateLayerProperty<SKSize>(Layer.Transform.Scale, _resizePreview.PreviewValue, _time));
_resizePreview = null;
}
#endregion
#region Rotating
private LayerPropertyPreview<float>? _rotatePreview;
public void StartRotation()
{
if (Layer == null)
return;
_rotatePreview?.DiscardPreview();
_rotatePreview = new LayerPropertyPreview<float>(Layer.Transform.Rotation, _time);
}
public void UpdateRotation(float rotation, bool round)
{
if (_rotatePreview == null)
throw new ArtemisUIException("Can't update rotation without a preview having been started");
if (round)
rotation = MathF.Round(rotation / 5f) * 5f;
_rotatePreview.Preview(rotation);
}
public void FinishRotation()
{
if (Layer == null)
return;
if (_rotatePreview == null)
throw new ArtemisUIException("Can't update rotation without a preview having been started");
if (_rotatePreview.DiscardPreview())
_profileEditorService.ExecuteCommand(new UpdateLayerProperty<float>(Layer.Transform.Rotation, _rotatePreview.PreviewValue, _time));
_rotatePreview = null;
}
#endregion
#region Utilities
private static SKPoint RoundPoint(SKPoint point, int decimals)
{
return new SKPoint(
(float) Math.Round(point.X, decimals, MidpointRounding.AwayFromZero),
(float) Math.Round(point.Y, decimals, MidpointRounding.AwayFromZero)
);
}
private static SKPoint[] UnTransformPoints(SKPoint[] skPoints, Layer layer, SKPoint pivot, bool includeScale)
{
using SKPath counterRotatePath = new();
counterRotatePath.AddPoly(skPoints, false);
counterRotatePath.Transform(SKMatrix.CreateRotationDegrees(layer.Transform.Rotation.CurrentValue * -1, pivot.X, pivot.Y));
if (includeScale)
counterRotatePath.Transform(SKMatrix.CreateScale(1f / (layer.Transform.Scale.CurrentValue.Width / 100f), 1f / (layer.Transform.Scale.CurrentValue.Height / 100f)));
return counterRotatePath.Points;
}
#endregion
}
@@ -0,0 +1,84 @@
<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:core="clr-namespace:Artemis.Core;assembly=Artemis.Core"
xmlns:controls="clr-namespace:Artemis.UI.Shared.Controls;assembly=Artemis.UI.Shared"
xmlns:visualEditor="clr-namespace:Artemis.UI.Screens.ProfileEditor.VisualEditor"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.VisualEditor.VisualEditorView"
x:DataType="visualEditor:VisualEditorViewModel">
<Grid>
<paz:ZoomBorder Name="ZoomBorder"
Stretch="None"
ClipToBounds="True"
Focusable="True"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
Background="{DynamicResource LargeCheckerboardBrush}"
ZoomChanged="ZoomBorder_OnZoomChanged">
<Grid Name="ContainerGrid"
Background="Transparent">
<Grid.Transitions>
<Transitions>
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.2" Easing="CubicEaseOut"/>
</Transitions>
</Grid.Transitions>
<!-- The bottom layer consists of devices -->
<ItemsControl Items="{CompiledBinding Devices}" ClipToBounds="False">
<ItemsControl.Styles>
<Style Selector="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}" />
<Setter Property="Canvas.Top" Value="{Binding Y}" />
</Style>
</ItemsControl.Styles>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="core:ArtemisDevice">
<controls:DeviceVisualizer Device="{Binding}" ShowColors="True" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- The middle layer contains visualizers -->
<ItemsControl Items="{CompiledBinding Visualizers}" ClipToBounds="False">
<ItemsControl.Styles>
<Style Selector="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}" />
<Setter Property="Canvas.Top" Value="{Binding Y}" />
</Style>
</ItemsControl.Styles>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<!-- The top layer contains tools -->
<ItemsControl Items="{CompiledBinding Tools}" ClipToBounds="False">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Grid />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</Grid>
</paz:ZoomBorder>
<Border CornerRadius="0 0 8 0" VerticalAlignment="Top" HorizontalAlignment="Left">
<Border.Background>
<SolidColorBrush Color="{DynamicResource CardStrokeColorDefaultSolid}" Opacity="0.65"></SolidColorBrush>
</Border.Background>
<StackPanel Orientation="Horizontal" Margin="8">
<controls:ProfileConfigurationIcon ConfigurationIcon="{CompiledBinding ProfileConfiguration.Icon}" Margin="0 0 5 0"></controls:ProfileConfigurationIcon>
<TextBlock Text="{CompiledBinding ProfileConfiguration.Name}"/>
</StackPanel>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,45 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.PanAndZoom;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.VisualEditor
{
public class VisualEditorView : ReactiveUserControl<VisualEditorViewModel>
{
private readonly ZoomBorder _zoomBorder;
public VisualEditorView()
{
InitializeComponent();
_zoomBorder = this.Find<ZoomBorder>("ZoomBorder");
_zoomBorder.PropertyChanged += ZoomBorderOnPropertyChanged;
UpdateZoomBorderBackground();
}
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();
}
}
}
@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.Ninject.Factories;
using Artemis.UI.Screens.ProfileEditor.VisualEditor.Visualizers;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.ProfileEditor;
using DynamicData;
using DynamicData.Binding;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.VisualEditor;
public class VisualEditorViewModel : ActivatableViewModelBase
{
private readonly SourceList<IVisualizerViewModel> _visualizers;
private readonly IProfileEditorVmFactory _vmFactory;
private ObservableAsPropertyHelper<ProfileConfiguration?>? _profileConfiguration;
private ReadOnlyObservableCollection<IToolViewModel> _tools;
public VisualEditorViewModel(IProfileEditorService profileEditorService, IRgbService rgbService, IProfileEditorVmFactory vmFactory)
{
_vmFactory = vmFactory;
_visualizers = new SourceList<IVisualizerViewModel>();
_visualizers.Connect()
.Sort(SortExpressionComparer<IVisualizerViewModel>.Ascending(vm => vm.Order))
.Bind(out ReadOnlyObservableCollection<IVisualizerViewModel> visualizers)
.Subscribe();
Devices = new ObservableCollection<ArtemisDevice>(rgbService.EnabledDevices);
Visualizers = visualizers;
this.WhenActivated(d =>
{
_profileConfiguration = profileEditorService.ProfileConfiguration
.ToProperty(this, vm => vm.ProfileConfiguration)
.DisposeWith(d);
profileEditorService.ProfileConfiguration
.Subscribe(CreateVisualizers)
.DisposeWith(d);
profileEditorService.Tools
.Connect()
.AutoRefreshOnObservable(t => t.WhenAnyValue(vm => vm.IsSelected)).Filter(t => t.IsSelected).Bind(out ReadOnlyObservableCollection<IToolViewModel> tools)
.Subscribe()
.DisposeWith(d);
Tools = tools;
this.WhenAnyValue(vm => vm.ProfileConfiguration)
.Select(p => p?.Profile)
.Select(p => p != null
? Observable.FromEventPattern<ProfileElementEventArgs>(x => p.DescendentAdded += x, x => p.DescendentAdded -= x)
: Observable.Never<EventPattern<ProfileElementEventArgs>>())
.Switch()
.Subscribe(AddElement)
.DisposeWith(d);
this.WhenAnyValue(vm => vm.ProfileConfiguration)
.Select(p => p?.Profile)
.Select(p => p != null
? Observable.FromEventPattern<ProfileElementEventArgs>(x => p.DescendentRemoved += x, x => p.DescendentRemoved -= x)
: Observable.Never<EventPattern<ProfileElementEventArgs>>())
.Switch()
.Subscribe(RemoveElement)
.DisposeWith(d);
});
}
public ProfileConfiguration? ProfileConfiguration => _profileConfiguration?.Value;
public ObservableCollection<ArtemisDevice> Devices { get; }
public ReadOnlyObservableCollection<IVisualizerViewModel> Visualizers { get; }
public ReadOnlyObservableCollection<IToolViewModel> Tools
{
get => _tools;
set => RaiseAndSetIfChanged(ref _tools, value);
}
private void RemoveElement(EventPattern<ProfileElementEventArgs> eventPattern)
{
List<IVisualizerViewModel> visualizers = Visualizers.Where(v => v.ProfileElement == eventPattern.EventArgs.ProfileElement).ToList();
if (visualizers.Any())
_visualizers.RemoveMany(visualizers);
}
private void AddElement(EventPattern<ProfileElementEventArgs> eventPattern)
{
if (eventPattern.EventArgs.ProfileElement is Layer layer)
_visualizers.Edit(list => CreateVisualizer(list, layer));
}
private void CreateVisualizers(ProfileConfiguration? profileConfiguration)
{
_visualizers.Edit(list =>
{
list.Clear();
if (profileConfiguration?.Profile == null)
return;
foreach (Layer layer in profileConfiguration.Profile.GetAllLayers())
CreateVisualizer(list, layer);
});
}
private void CreateVisualizer(ICollection<IVisualizerViewModel> visualizerViewModels, Layer layer)
{
visualizerViewModels.Add(_vmFactory.LayerShapeVisualizerViewModel(layer));
visualizerViewModels.Add(_vmFactory.LayerVisualizerViewModel(layer));
}
}
@@ -0,0 +1,11 @@
using Artemis.Core;
namespace Artemis.UI.Screens.ProfileEditor.VisualEditor.Visualizers;
public interface IVisualizerViewModel
{
ProfileElement? ProfileElement { get; }
double X { get; }
double Y { get; }
int Order { get; }
}
@@ -0,0 +1,63 @@
<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:visualizers="clr-namespace:Artemis.UI.Screens.ProfileEditor.VisualEditor.Visualizers"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.VisualEditor.Visualizers.LayerShapeVisualizerView"
x:DataType="visualizers:LayerShapeVisualizerViewModel"
ClipToBounds="False">
<UserControl.Styles>
<Style Selector="Path.layer-visualizer">
<Setter Property="Stroke" Value="{StaticResource ButtonBorderBrushDisabled}" />
<Setter Property="Transitions">
<Setter.Value>
<Transitions>
<DoubleTransition Property="StrokeThickness" Duration="0:0:0.2" Easing="CubicEaseOut"></DoubleTransition>
<BrushTransition Property="Stroke" Duration="0:0:0.2" Easing="CubicEaseOut"></BrushTransition>
</Transitions>
</Setter.Value>
</Setter>
</Style>
<Style Selector="Path.layer-visualizer-selected">
<Setter Property="Stroke" Value="{StaticResource SystemAccentColorLight1}" />
</Style>
<Style Selector="Path.layer-visualizer-unbound">
<Setter Property="Stroke" Value="{StaticResource ButtonBorderBrushDisabled}" />
<Setter Property="Opacity" Value="0.50" />
<Setter Property="Transitions">
<Setter.Value>
<Transitions>
<DoubleTransition Property="StrokeThickness" Duration="0:0:0.2" Easing="CubicEaseOut"></DoubleTransition>
</Transitions>
</Setter.Value>
</Setter>
</Style>
<Style Selector="Path.layer-visualizer-unbound-selected">
<Setter Property="Opacity" Value="0.75" />
</Style>
</UserControl.Styles>
<Canvas>
<Path Name="LayerVisualizerUnbound"
Classes="layer-visualizer-unbound"
Classes.layer-visualizer-unbound-selected="{CompiledBinding Selected}"
Data="{CompiledBinding ShapeGeometry, Mode=OneWay}"
StrokeThickness="4"
StrokeJoin="Round">
</Path>
<Border Name="LayerVisualizerBounds"
ClipToBounds="True"
Width="{CompiledBinding LayerBounds.Width, Mode=OneWay}"
Height="{CompiledBinding LayerBounds.Height, Mode=OneWay}">
<Path Name="LayerVisualizer"
Classes="layer-visualizer"
Classes.layer-visualizer-selected="{CompiledBinding Selected}"
Data="{CompiledBinding ShapeGeometry, Mode=OneWay}"
StrokeThickness="4"
StrokeJoin="Round">
</Path>
</Border>
</Canvas>
</UserControl>
@@ -0,0 +1,80 @@
using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Mixins;
using Avalonia.Controls.PanAndZoom;
using Avalonia.Controls.Shapes;
using Avalonia.LogicalTree;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.VisualEditor.Visualizers;
public partial class LayerShapeVisualizerView : ReactiveUserControl<LayerShapeVisualizerViewModel>
{
private ZoomBorder? _zoomBorder;
private readonly Path _layerVisualizerUnbound;
private readonly Path _layerVisualizer;
public LayerShapeVisualizerView()
{
InitializeComponent();
_layerVisualizer = this.Get<Path>("LayerVisualizer");
_layerVisualizerUnbound = this.Get<Path>("LayerVisualizerUnbound");
this.WhenActivated(d => ViewModel.WhenAnyValue(vm => vm.Selected).Subscribe(_ => UpdateStrokeThickness()).DisposeWith(d));
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
#region Overrides of TemplatedControl
/// <inheritdoc />
protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e)
{
_zoomBorder = (ZoomBorder?) this.GetLogicalAncestors().FirstOrDefault(l => l is ZoomBorder);
if (_zoomBorder != null)
_zoomBorder.PropertyChanged += ZoomBorderOnPropertyChanged;
base.OnAttachedToLogicalTree(e);
}
/// <inheritdoc />
protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e)
{
if (_zoomBorder != null)
_zoomBorder.PropertyChanged -= ZoomBorderOnPropertyChanged;
base.OnDetachedFromLogicalTree(e);
}
private void ZoomBorderOnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property != ZoomBorder.ZoomXProperty || _zoomBorder == null)
return;
UpdateStrokeThickness();
}
private void UpdateStrokeThickness()
{
if (_zoomBorder == null)
return;
if (ViewModel != null && ViewModel.Selected)
{
_layerVisualizer.StrokeThickness = Math.Max(1, 4 / _zoomBorder.ZoomX);
_layerVisualizerUnbound.StrokeThickness = Math.Max(1, 4 / _zoomBorder.ZoomX);
}
else
{
_layerVisualizer.StrokeThickness = Math.Max(1, 4 / _zoomBorder.ZoomX) / 2;
_layerVisualizerUnbound.StrokeThickness = Math.Max(1, 4 / _zoomBorder.ZoomX) / 2;
}
}
#endregion
}
@@ -0,0 +1,106 @@
using System;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Extensions;
using Artemis.UI.Shared.Services.ProfileEditor;
using Avalonia;
using Avalonia.Controls.Mixins;
using Avalonia.Media;
using Avalonia.Skia;
using ReactiveUI;
using SkiaSharp;
namespace Artemis.UI.Screens.ProfileEditor.VisualEditor.Visualizers;
public class LayerShapeVisualizerViewModel : ActivatableViewModelBase, IVisualizerViewModel
{
private ObservableAsPropertyHelper<bool>? _selected;
private Rect _layerBounds;
private double _x;
private double _y;
private Geometry? _shapeGeometry;
public LayerShapeVisualizerViewModel(Layer layer, IProfileEditorService profileEditorService)
{
Layer = layer;
this.WhenActivated(d =>
{
Observable.FromEventPattern(x => Layer.RenderPropertiesUpdated += x, x => Layer.RenderPropertiesUpdated -= x).Subscribe(_ => Update()).DisposeWith(d);
Observable.FromEventPattern<LayerPropertyEventArgs>(x => Layer.Transform.Position.CurrentValueSet += x, x => Layer.Transform.Position.CurrentValueSet -= x)
.Subscribe(_ => UpdateTransform())
.DisposeWith(d);
Observable.FromEventPattern<LayerPropertyEventArgs>(x => Layer.Transform.Rotation.CurrentValueSet += x, x => Layer.Transform.Rotation.CurrentValueSet -= x)
.Subscribe(_ => UpdateTransform())
.DisposeWith(d);
Observable.FromEventPattern<LayerPropertyEventArgs>(x => Layer.Transform.Scale.CurrentValueSet += x, x => Layer.Transform.Scale.CurrentValueSet -= x)
.Subscribe(_ => UpdateTransform())
.DisposeWith(d);
Observable.FromEventPattern<LayerPropertyEventArgs>(x => Layer.Transform.AnchorPoint.CurrentValueSet += x, x => Layer.Transform.AnchorPoint.CurrentValueSet -= x)
.Subscribe(_ => UpdateTransform())
.DisposeWith(d);
_selected = profileEditorService.ProfileElement.Select(p => p == Layer).ToProperty(this, vm => vm.Selected).DisposeWith(d);
profileEditorService.Time.Subscribe(_ => UpdateTransform()).DisposeWith(d);
Update();
});
}
public Layer Layer { get; }
public ProfileElement ProfileElement => Layer;
public bool Selected => _selected?.Value ?? false;
public Rect LayerBounds
{
get => _layerBounds;
private set => RaiseAndSetIfChanged(ref _layerBounds, value);
}
public double X
{
get => _x;
set => RaiseAndSetIfChanged(ref _x, value);
}
public double Y
{
get => _y;
set => RaiseAndSetIfChanged(ref _y, value);
}
public Geometry? ShapeGeometry
{
get => _shapeGeometry;
set => RaiseAndSetIfChanged(ref _shapeGeometry, value);
}
public int Order => 2;
private void Update()
{
UpdateLayerBounds();
if (Layer.General.ShapeType.CurrentValue == LayerShapeType.Rectangle)
ShapeGeometry = new RectangleGeometry(LayerBounds);
else
ShapeGeometry = new EllipseGeometry(LayerBounds);
UpdateTransform();
}
private void UpdateLayerBounds()
{
SKRect bounds = Layer.GetLayerBounds();
LayerBounds = new Rect(0, 0, bounds.Width, bounds.Height);
X = bounds.Left;
Y = bounds.Top;
}
private void UpdateTransform()
{
if (ShapeGeometry != null)
ShapeGeometry.Transform = new MatrixTransform(Layer.GetTransformMatrix(false, true, true, true, LayerBounds.ToSKRect()).ToMatrix());
}
}
@@ -0,0 +1,37 @@
<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:visualizers="clr-namespace:Artemis.UI.Screens.ProfileEditor.VisualEditor.Visualizers"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.VisualEditor.Visualizers.LayerVisualizerView"
x:DataType="visualizers:LayerVisualizerViewModel"
ClipToBounds="False">
<UserControl.Styles>
<Style Selector="Path.layer-visualizer">
<Setter Property="Stroke" Value="{StaticResource SystemAccentColorDark2}" />
<Setter Property="Opacity" Value="0" />
<Setter Property="Transitions">
<Setter.Value>
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.2" Easing="CubicEaseOut"></DoubleTransition>
</Transitions>
</Setter.Value>
</Setter>
</Style>
<Style Selector="Path.layer-visualizer-selected">
<Setter Property="Stroke" Value="{StaticResource SystemAccentColorDark2}" />
<Setter Property="Opacity" Value="1" />
</Style>
</UserControl.Styles>
<Path Name="LayerVisualizer"
Classes="layer-visualizer"
Classes.layer-visualizer-selected="{CompiledBinding Selected}"
StrokeThickness="4"
StrokeDashArray="6,2"
StrokeJoin="Round">
<Path.Data>
<RectangleGeometry Rect="{CompiledBinding LayerBounds}"></RectangleGeometry>
</Path.Data>
</Path>
</UserControl>
@@ -0,0 +1,58 @@
using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.PanAndZoom;
using Avalonia.Controls.Shapes;
using Avalonia.LogicalTree;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
using Avalonia.VisualTree;
namespace Artemis.UI.Screens.ProfileEditor.VisualEditor.Visualizers
{
public partial class LayerVisualizerView : ReactiveUserControl<LayerVisualizerViewModel>
{
private ZoomBorder? _zoomBorder;
private readonly Path _layerVisualizer;
public LayerVisualizerView()
{
InitializeComponent();
_layerVisualizer = this.Get<Path>("LayerVisualizer");
}
#region Overrides of TemplatedControl
/// <inheritdoc />
protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e)
{
_zoomBorder = (ZoomBorder?) this.GetLogicalAncestors().FirstOrDefault(l => l is ZoomBorder);
if (_zoomBorder != null)
_zoomBorder.PropertyChanged += ZoomBorderOnPropertyChanged;
base.OnAttachedToLogicalTree(e);
}
/// <inheritdoc />
protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e)
{
if (_zoomBorder != null)
_zoomBorder.PropertyChanged -= ZoomBorderOnPropertyChanged;
base.OnDetachedFromLogicalTree(e);
}
private void ZoomBorderOnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property != ZoomBorder.ZoomXProperty || _zoomBorder == null)
return;
_layerVisualizer.StrokeThickness = Math.Max(1, 4 / _zoomBorder.ZoomX);
}
#endregion
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
}
@@ -0,0 +1,69 @@
using System;
using System.Reactive.Linq;
using Artemis.Core;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Extensions;
using Artemis.UI.Shared.Services.ProfileEditor;
using Avalonia;
using Avalonia.Controls.Mixins;
using ReactiveUI;
using SKRect = SkiaSharp.SKRect;
namespace Artemis.UI.Screens.ProfileEditor.VisualEditor.Visualizers;
public class LayerVisualizerViewModel : ActivatableViewModelBase, IVisualizerViewModel
{
private ObservableAsPropertyHelper<bool>? _selected;
private Rect _layerBounds;
private double _x;
private double _y;
public LayerVisualizerViewModel(Layer layer, IProfileEditorService profileEditorService)
{
Layer = layer;
this.WhenActivated(d =>
{
Observable.FromEventPattern(x => Layer.RenderPropertiesUpdated += x, x => Layer.RenderPropertiesUpdated -= x)
.Subscribe(_ => Update())
.DisposeWith(d);
_selected = profileEditorService.ProfileElement
.Select(p => p == Layer)
.ToProperty(this, vm => vm.Selected)
.DisposeWith(d);
Update();
});
}
public Layer Layer { get; }
public ProfileElement ProfileElement => Layer;
public bool Selected => _selected?.Value ?? false;
public Rect LayerBounds
{
get => _layerBounds;
private set => RaiseAndSetIfChanged(ref _layerBounds, value);
}
public double X
{
get => _x;
set => RaiseAndSetIfChanged(ref _x, value);
}
public double Y
{
get => _y;
set => RaiseAndSetIfChanged(ref _y, value);
}
public int Order => 1;
private void Update()
{
SKRect bounds = Layer.GetLayerBounds();
LayerBounds = new Rect(0, 0, bounds.Width, bounds.Height);
X = bounds.Left;
Y = bounds.Top;
}
}