mirror of
https://github.com/Artemis-RGB/Artemis
synced 2026-11-12 11:56:30 +00:00
Meta - Replace WPF projects with Avalonia projects
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+186
@@ -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;
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+31
@@ -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
|
||||
}
|
||||
+19
@@ -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>
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+80
@@ -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>
|
||||
+85
@@ -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();
|
||||
}
|
||||
}
|
||||
+168
@@ -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
|
||||
}
|
||||
|
||||
+58
@@ -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>
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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;
|
||||
}
|
||||
+80
@@ -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>
|
||||
+62
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -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();
|
||||
}
|
||||
}
|
||||
+28
@@ -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>
|
||||
+56
@@ -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>
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -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;
|
||||
}
|
||||
+174
@@ -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();
|
||||
}
|
||||
}
|
||||
+55
@@ -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>
|
||||
+17
@@ -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);
|
||||
}
|
||||
}
|
||||
+48
@@ -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));
|
||||
}
|
||||
}
|
||||
+21
@@ -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>
|
||||
+17
@@ -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);
|
||||
}
|
||||
}
|
||||
+103
@@ -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
|
||||
}
|
||||
+14
@@ -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 - 
|
||||
</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;
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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>
|
||||
+27
@@ -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;
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -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>
|
||||
+27
@@ -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;
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
+20
@@ -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>
|
||||
+25
@@ -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));
|
||||
}
|
||||
}
|
||||
+82
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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>
|
||||
+24
@@ -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());
|
||||
}
|
||||
}
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
+199
@@ -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>
|
||||
+411
@@ -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
|
||||
}
|
||||
+464
@@ -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));
|
||||
}
|
||||
}
|
||||
+11
@@ -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; }
|
||||
}
|
||||
+63
@@ -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>
|
||||
+80
@@ -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
|
||||
}
|
||||
+106
@@ -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());
|
||||
}
|
||||
}
|
||||
+37
@@ -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>
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user