mirror of
https://github.com/Artemis-RGB/Artemis
synced 2026-11-09 18:36:27 +00:00
Profile editor - Fix undo and editor service bugs found by the tests (#913)
* Profile editor - Fix undoing profile editor commands - Undoing a change to an existing keyframe's value restores the value instead of deleting the keyframe - Undoing "remove effect" puts the effect back in its place instead of below the next one - Undoing "toggle keyframes" restores what it was before - Undoing a brush change on a layer that had no brush removes the brush again, using the new Layer.RemoveLayerBrush - Undoing "add element" disables the element again - Undoing an event path change back to no path goes back to an event start node, like a new condition - MoveProfileElement says "Move folder" for folders - UpdateLayerProperty without a time no longer calls itself "Add ... keyframe" * Profile editor - Fix auto-saving, failing commands, suspended editing and smaller editor issues - A failed auto-save no longer stops auto-saving for the rest of the session - A failing command is no longer also reported to ReactiveUI as unhandled, on top of the error dialog - Resuming editing applies the editor's time again right away - Nothing is focused while editing is suspended - A layer created on another layer gets a name that isn't in use next to it - CanUndo and CanRedo start out false, so new subscribers know the state right away - An empty command scope doesn't add an undo step - New subscribers to History get the current history once - Expanding the keyframe selection with an already selected keyframe doesn't add it twice - Snapping can snap to a snap time of zero
This commit is contained in:
1 parent
63186ee06d
commit
295038f338
22 files changed
+140
-133
No files matched your search
@@ -173,15 +173,13 @@ public class EventCondition : CorePropertyChanged, INodeScriptCondition
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void UpdateEventNode(bool updateScript)
|
public void UpdateEventNode(bool updateScript)
|
||||||
{
|
{
|
||||||
if (EventPath == null)
|
Type? pathType = EventPath?.GetPropertyType();
|
||||||
|
if (EventPath != null && pathType == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
Type? pathType = EventPath.GetPropertyType();
|
// Create an event node if the path type is a data model event, without a path go back to the event node a new
|
||||||
if (pathType == null)
|
// condition starts with
|
||||||
return;
|
if (pathType == null || pathType.IsAssignableTo(typeof(IDataModelEvent)))
|
||||||
|
|
||||||
// Create an event node if the path type is a data model event
|
|
||||||
if (pathType.IsAssignableTo(typeof(IDataModelEvent)))
|
|
||||||
{
|
{
|
||||||
EventConditionEventStartNode eventNode;
|
EventConditionEventStartNode eventNode;
|
||||||
// Ensure the start node is an event node
|
// Ensure the start node is an event node
|
||||||
|
|||||||
@@ -857,6 +857,24 @@ public sealed class Layer : RenderProfileElement
|
|||||||
ActivateLayerBrush();
|
ActivateLayerBrush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the current layer brush, leaving the layer without a brush
|
||||||
|
/// </summary>
|
||||||
|
public void RemoveLayerBrush()
|
||||||
|
{
|
||||||
|
if (LayerBrush == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
BaseLayerBrush oldLayerBrush = LayerBrush;
|
||||||
|
General.BrushReference.SetCurrentValue(null!);
|
||||||
|
LayerBrush = null;
|
||||||
|
|
||||||
|
// Don't dispose the brush, only disable it
|
||||||
|
// That way an undo-action does not need to worry about disposed brushes
|
||||||
|
oldLayerBrush.InternalDisable();
|
||||||
|
ActivateLayerBrush();
|
||||||
|
}
|
||||||
|
|
||||||
private void ActivateLayerBrush()
|
private void ActivateLayerBrush()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ public class ConditionCommandTests : ProfileEditorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UpdateEventConditionPath_Undo_ToNoPath_KeepsTheNewStartNode()
|
public void UpdateEventConditionPath_Undo_ToNoPath_GoesBackToAnEventStartNode()
|
||||||
{
|
{
|
||||||
TestModule module = AddTestModule();
|
TestModule module = AddTestModule();
|
||||||
EventCondition condition = new(_layer);
|
EventCondition condition = new(_layer);
|
||||||
@@ -191,11 +191,11 @@ public class ConditionCommandTests : ProfileEditorTestBase
|
|||||||
command.Execute();
|
command.Execute();
|
||||||
command.Undo();
|
command.Undo();
|
||||||
|
|
||||||
// Pinned as-is: UpdateEventNode does nothing without a path, so undoing back to "no path" keeps the start node
|
// Like a new condition, rather than keeping the start node made for the path that was undone
|
||||||
// made for the path that was undone
|
|
||||||
condition.EventPath.ShouldBeNull();
|
condition.EventPath.ShouldBeNull();
|
||||||
condition.GetStartNode().ShouldNotBe(originalStartNode);
|
condition.GetStartNode().GetType().ShouldBe(originalStartNode.GetType());
|
||||||
condition.GetStartNode().GetType().Name.ShouldBe("EventConditionValueChangedStartNode");
|
condition.Script.Nodes.ShouldContain(condition.GetStartNode());
|
||||||
|
condition.Script.Nodes.ShouldNotContain(n => n.GetType().Name == "EventConditionValueChangedStartNode");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -219,15 +219,15 @@ public class KeyframeCommandTests : ProfileEditorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ToggleLayerPropertyKeyframes_Undo_DoesNotRememberWhatItWasBefore()
|
public void ToggleLayerPropertyKeyframes_Undo_RestoresWhatItWasBefore()
|
||||||
{
|
{
|
||||||
ToggleLayerPropertyKeyframes<float> enable = new(_opacity, true, TimeSpan.Zero);
|
ToggleLayerPropertyKeyframes<float> enable = new(_opacity, true, TimeSpan.Zero);
|
||||||
|
|
||||||
enable.Execute();
|
enable.Execute();
|
||||||
enable.Undo();
|
enable.Undo();
|
||||||
|
|
||||||
// Pinned as-is: undo sets the opposite of what the command did rather than restoring the earlier state, so
|
// "Enabling" keyframes that were already enabled leaves them enabled when undone
|
||||||
// "enabling" keyframes that were already enabled turns them off when undone
|
_opacity.KeyframesEnabled.ShouldBeTrue();
|
||||||
_opacity.KeyframesEnabled.ShouldBeFalse();
|
_opacity.Keyframes.ShouldBe([_first, _second]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ public class LayerBrushAndLedCommandTests : ProfileEditorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ChangeLayerBrush_Undo_OnALayerWithoutBrush_KeepsTheNewBrush()
|
public void ChangeLayerBrush_Undo_OnALayerWithoutBrush_RemovesTheNewBrush()
|
||||||
{
|
{
|
||||||
Layer layer = new(Root, "Layer without brush");
|
Layer layer = new(Root, "Layer without brush");
|
||||||
Root.AddChild(layer);
|
Root.AddChild(layer);
|
||||||
@@ -76,8 +76,8 @@ public class LayerBrushAndLedCommandTests : ProfileEditorTestBase
|
|||||||
command.Execute();
|
command.Execute();
|
||||||
command.Undo();
|
command.Undo();
|
||||||
|
|
||||||
// Pinned as-is: with no brush to go back to, undo leaves the new brush (and the brush reference) in place
|
layer.LayerBrush.ShouldBeNull();
|
||||||
layer.LayerBrush.ShouldBeOfType<GradientTestBrush>();
|
layer.General.BrushReference.CurrentValue.ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ public class LayerEffectCommandTests : ProfileEditorTestBase
|
|||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(0)]
|
[InlineData(0)]
|
||||||
[InlineData(1)]
|
[InlineData(1)]
|
||||||
public void RemoveLayerEffect_Undo_OfAnEffectAboveAnother_MovesItDown(int index)
|
public void RemoveLayerEffect_Undo_OfAnEffectAboveAnother_PutsItBackInItsPlace(int index)
|
||||||
{
|
{
|
||||||
Harness.AddEffect<TintTestEffect>(_layer);
|
Harness.AddEffect<TintTestEffect>(_layer);
|
||||||
Harness.AddEffect<OpacityTestEffect>(_layer);
|
Harness.AddEffect<OpacityTestEffect>(_layer);
|
||||||
@@ -124,13 +124,10 @@ public class LayerEffectCommandTests : ProfileEditorTestBase
|
|||||||
command.Execute();
|
command.Execute();
|
||||||
command.Undo();
|
command.Undo();
|
||||||
|
|
||||||
// Pinned as-is: removing renumbers the remaining effects, so the effect below takes over the removed effect's
|
// Effects apply in order, so this keeps the layer looking the same
|
||||||
// order. Adding it back sorts it after the effect that now has the same order, swapping the two. Since effects
|
|
||||||
// apply in order, undo can change what the layer looks like
|
|
||||||
effects.RemoveAt(index);
|
|
||||||
effects.Insert(index + 1, removed);
|
|
||||||
_layer.LayerEffects.ShouldBe(effects);
|
_layer.LayerEffects.ShouldBe(effects);
|
||||||
Snapshot().ShouldNotBe(before);
|
_layer.LayerEffects.Select(e => e.Order).ShouldBe([1, 2, 3]);
|
||||||
|
Snapshot().ShouldBe(before);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ public class LayerPropertyCommandTests : ProfileEditorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UpdateLayerProperty_WithOriginalValue_OnAKeyframe_RemovesTheKeyframeOnUndo()
|
public void UpdateLayerProperty_WithOriginalValue_OnAKeyframe_RestoresTheKeyframeOnUndo()
|
||||||
{
|
{
|
||||||
AddKeyframe(0, 0);
|
AddKeyframe(0, 0);
|
||||||
LayerPropertyKeyframe<float> keyframe = AddKeyframe(60, 1);
|
LayerPropertyKeyframe<float> keyframe = AddKeyframe(60, 1);
|
||||||
@@ -124,12 +124,10 @@ public class LayerPropertyCommandTests : ProfileEditorTestBase
|
|||||||
keyframe.Value.ShouldBe(40);
|
keyframe.Value.ShouldBe(40);
|
||||||
command.Undo();
|
command.Undo();
|
||||||
|
|
||||||
// Pinned as-is: this constructor never checks whether there already is a keyframe at the time. When keyframes are
|
_opacity.Keyframes.ShouldContain(keyframe);
|
||||||
// enabled, Execute treats the existing keyframe that SetCurrentValue updated as a new one, and Undo removes it
|
_opacity.Keyframes.Count.ShouldBe(2);
|
||||||
// instead of restoring its value. Changing a keyframe through a property input and undoing deletes the keyframe
|
keyframe.Value.ShouldBe(60);
|
||||||
_opacity.Keyframes.ShouldNotContain(keyframe);
|
command.DisplayName.ShouldBe("Update Opacity");
|
||||||
_opacity.Keyframes.Count.ShouldBe(1);
|
|
||||||
command.DisplayName.ShouldBe("Add Opacity keyframe");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -142,9 +140,7 @@ public class LayerPropertyCommandTests : ProfileEditorTestBase
|
|||||||
|
|
||||||
_opacity.BaseValue.ShouldBe(40);
|
_opacity.BaseValue.ShouldBe(40);
|
||||||
_opacity.Keyframes.Count.ShouldBe(1);
|
_opacity.Keyframes.Count.ShouldBe(1);
|
||||||
// Pinned as-is: with keyframes enabled the command expects to add a keyframe and renames itself, even though
|
command.DisplayName.ShouldBe("Update Opacity");
|
||||||
// without a time no keyframe is added
|
|
||||||
command.DisplayName.ShouldBe("Add Opacity keyframe");
|
|
||||||
|
|
||||||
command.Undo();
|
command.Undo();
|
||||||
_opacity.BaseValue.ShouldBe(0, 0.01f);
|
_opacity.BaseValue.ShouldBe(0, 0.01f);
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public class ProfileElementCommandTests : ProfileEditorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddProfileElement_Undo_LeavesTheElementEnabled()
|
public void AddProfileElement_Undo_DisablesTheElement()
|
||||||
{
|
{
|
||||||
Layer layer = new(_folder, "New layer");
|
Layer layer = new(_folder, "New layer");
|
||||||
AddProfileElement command = new(layer, _folder, 0);
|
AddProfileElement command = new(layer, _folder, 0);
|
||||||
@@ -61,9 +61,8 @@ public class ProfileElementCommandTests : ProfileEditorTestBase
|
|||||||
command.Execute();
|
command.Execute();
|
||||||
command.Undo();
|
command.Undo();
|
||||||
|
|
||||||
// Pinned as-is: Execute enables the element but Undo does not disable it again. It is no longer in the profile
|
// Like RemoveProfileElement, so its brush and effects don't stay enabled
|
||||||
// so it doesn't render, but its brush and effects stay enabled (unlike RemoveProfileElement, which disables)
|
layer.Enabled.ShouldBeFalse();
|
||||||
layer.Enabled.ShouldBeTrue();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -212,12 +211,11 @@ public class ProfileElementCommandTests : ProfileEditorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MoveProfileElement_DisplayName_AlwaysSaysLayer()
|
public void MoveProfileElement_DisplayName_SaysWhatIsMoved()
|
||||||
{
|
{
|
||||||
Folder other = Harness.AddFolder(Root, "Other folder");
|
Folder other = Harness.AddFolder(Root, "Other folder");
|
||||||
|
|
||||||
// Pinned as-is: the constructor sets "Move folder" for folders and then always overwrites it with "Move layer"
|
new MoveProfileElement(_folder, other, 0).DisplayName.ShouldBe("Move folder");
|
||||||
new MoveProfileElement(_folder, other, 0).DisplayName.ShouldBe("Move layer");
|
|
||||||
new MoveProfileElement(Root, _first, 0).DisplayName.ShouldBe("Move layer");
|
new MoveProfileElement(Root, _first, 0).DisplayName.ShouldBe("Move layer");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,17 +98,16 @@ public class ProfileEditorCreateElementTests : ProfileEditorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateAndAddLayer_OnALayer_CanRepeatANameAlreadyInUse()
|
public async Task CreateAndAddLayer_OnALayer_PicksANameNotInUseNextToIt()
|
||||||
{
|
{
|
||||||
Layer existing = AddLayer("New layer", null, LedId.Keyboard_A);
|
Layer existing = AddLayer("New layer", null, LedId.Keyboard_A);
|
||||||
await StartEditing();
|
await StartEditing();
|
||||||
|
|
||||||
Layer layer = Service.CreateAndAddLayer(existing);
|
Layer layer = Service.CreateAndAddLayer(existing);
|
||||||
|
|
||||||
// Pinned as-is: the name is picked from the children of the target layer (which has none) rather than from its
|
layer.Parent.ShouldBe(Root);
|
||||||
// parent, so the new layer gets the same name as its sibling. CreateAndAddFolder uses the parent.
|
layer.Name.ShouldNotBe("New layer");
|
||||||
layer.Name.ShouldBe("New layer");
|
Root.Children.Count(c => c.Name == "New layer").ShouldBe(1);
|
||||||
Root.Children.Count(c => c.Name == "New layer").ShouldBe(2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ public class ProfileEditorFocusModeTests : ProfileEditorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SuspendedEditing_StillFocusesTheSelection()
|
public async Task SuspendedEditing_DoesNotFocusAnything()
|
||||||
{
|
{
|
||||||
await StartEditing();
|
await StartEditing();
|
||||||
Service.ChangeCurrentProfileElement(_layer);
|
Service.ChangeCurrentProfileElement(_layer);
|
||||||
@@ -98,10 +98,8 @@ public class ProfileEditorFocusModeTests : ProfileEditorTestBase
|
|||||||
|
|
||||||
Service.ChangeSuspendedEditing(true);
|
Service.ChangeSuspendedEditing(true);
|
||||||
|
|
||||||
// Pinned as-is: while editing is suspended ApplyFocusMode clears the focus, but then immediately sets it again
|
// The profile plays normally while editing is suspended
|
||||||
// from the focus mode, so the clear has no effect. It looks like the intention was to not focus anything while
|
ProfileService.FocusProfileElementChanges.ShouldBe([null]);
|
||||||
// the profile plays normally.
|
ProfileService.FocusProfileElement.ShouldBeNull();
|
||||||
ProfileService.FocusProfileElementChanges.ShouldBe([null, _layer]);
|
|
||||||
ProfileService.FocusProfileElement.ShouldBe(_layer);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,11 +34,11 @@ public class ProfileEditorHistoryTests : ProfileEditorTestBase
|
|||||||
Service.ExecuteCommand(new RecordingCommand("A", _log));
|
Service.ExecuteCommand(new RecordingCommand("A", _log));
|
||||||
|
|
||||||
_log.ShouldBe(["execute A"]);
|
_log.ShouldBe(["execute A"]);
|
||||||
canUndo.ShouldBe([true]);
|
canUndo.ShouldBe([false, true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CanUndoAndCanRedo_SayNothingUntilTheHistoryChanges()
|
public async Task CanUndoAndCanRedo_StartOutFalse()
|
||||||
{
|
{
|
||||||
await StartEditing();
|
await StartEditing();
|
||||||
List<bool> canUndo = [];
|
List<bool> canUndo = [];
|
||||||
@@ -47,10 +47,8 @@ public class ProfileEditorHistoryTests : ProfileEditorTestBase
|
|||||||
using IDisposable undoSubscription = CurrentHistory.CanUndo.Subscribe(canUndo.Add);
|
using IDisposable undoSubscription = CurrentHistory.CanUndo.Subscribe(canUndo.Add);
|
||||||
using IDisposable redoSubscription = CurrentHistory.CanRedo.Subscribe(canRedo.Add);
|
using IDisposable redoSubscription = CurrentHistory.CanRedo.Subscribe(canRedo.Add);
|
||||||
|
|
||||||
// Pinned as-is: the subjects behind CanUndo and CanRedo have no initial value, a new subscriber only hears about
|
canUndo.ShouldBe([false]);
|
||||||
// the next change. Bindings made after a change show the wrong state until the next one
|
canRedo.ShouldBe([false]);
|
||||||
canUndo.ShouldBeEmpty();
|
|
||||||
canRedo.ShouldBeEmpty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -92,7 +90,7 @@ public class ProfileEditorHistoryTests : ProfileEditorTestBase
|
|||||||
Undo();
|
Undo();
|
||||||
Redo();
|
Redo();
|
||||||
|
|
||||||
canUndo.ShouldBe([true, false, true]);
|
canUndo.ShouldBe([false, true, false, true]);
|
||||||
canRedo.ShouldBe([false, true, false]);
|
canRedo.ShouldBe([false, true, false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,10 +150,8 @@ public class ProfileEditorHistoryTests : ProfileEditorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
WindowService.ExceptionDialogs.ShouldHaveSingleItem().Title.ShouldBe("Editor command failed");
|
WindowService.ExceptionDialogs.ShouldHaveSingleItem().Title.ShouldBe("Editor command failed");
|
||||||
// Pinned as-is: besides the error dialog, ReactiveUI treats the failure as unhandled because nothing subscribes to
|
// The dialog is enough, ReactiveUI doesn't also treat the failure as unhandled (a crash in the app)
|
||||||
// the ThrownExceptions of the history's Execute command. In the app that means a failing command is reported as
|
unhandled.ShouldBeEmpty();
|
||||||
// a crash on top of the dialog.
|
|
||||||
unhandled.ShouldHaveSingleItem().ShouldBeOfType<InvalidOperationException>();
|
|
||||||
Undo().ShouldBeNull();
|
Undo().ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,14 +213,13 @@ public class ProfileEditorHistoryTests : ProfileEditorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CommandScope_WithoutCommands_StillAddsAnEntryToTheHistory()
|
public async Task CommandScope_WithoutCommands_DoesNotAddAnEntryToTheHistory()
|
||||||
{
|
{
|
||||||
await StartEditing();
|
await StartEditing();
|
||||||
|
|
||||||
Service.CreateCommandScope("Empty").Dispose();
|
Service.CreateCommandScope("Empty").Dispose();
|
||||||
|
|
||||||
// Pinned as-is: an empty scope leaves an undo step that does nothing
|
Undo().ShouldBeNull();
|
||||||
Undo().ShouldBeOfType<CompositeCommand>().DisplayName.ShouldBe("Empty");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -155,18 +155,14 @@ public class ProfileEditorServiceTests : ProfileEditorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task History_GivesNewSubscribersTheCurrentHistoryTwice()
|
public async Task History_GivesNewSubscribersTheCurrentHistoryOnce()
|
||||||
{
|
{
|
||||||
await StartEditing();
|
await StartEditing();
|
||||||
List<ProfileEditorHistory?> received = [];
|
List<ProfileEditorHistory?> received = [];
|
||||||
|
|
||||||
using IDisposable _ = Service.History.Subscribe(received.Add);
|
using IDisposable _ = Service.History.Subscribe(received.Add);
|
||||||
|
|
||||||
// Pinned as-is: History first returns the current history and then subscribes to ProfileConfiguration, which
|
received.ShouldHaveSingleItem()!.ProfileConfiguration.ShouldBe(Configuration);
|
||||||
// repeats the current configuration. Harmless for the UI, but every subscriber gets the same history twice
|
|
||||||
received.Count.ShouldBe(2);
|
|
||||||
received.ShouldAllBe(h => h == received[0]);
|
|
||||||
received[0]!.ProfileConfiguration.ShouldBe(Configuration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -259,7 +255,7 @@ public class ProfileEditorServiceTests : ProfileEditorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AutoSave_AfterAFailedSave_StopsSaving()
|
public async Task AutoSave_AfterAFailedSave_KeepsSaving()
|
||||||
{
|
{
|
||||||
Layer layer = AddLayer("Layer", null, LedId.Keyboard_A);
|
Layer layer = AddLayer("Layer", null, LedId.Keyboard_A);
|
||||||
await StartEditing();
|
await StartEditing();
|
||||||
@@ -271,12 +267,13 @@ public class ProfileEditorServiceTests : ProfileEditorTestBase
|
|||||||
|
|
||||||
ProfileService.SaveException = null;
|
ProfileService.SaveException = null;
|
||||||
Service.ExecuteCommand(new RenameProfileElement(layer, "Second"));
|
Service.ExecuteCommand(new RenameProfileElement(layer, "Second"));
|
||||||
await Task.Delay(2000, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// Pinned as-is: the auto-save rethrows the exception after showing the dialog, which ends the observable that
|
await WaitUntil(() =>
|
||||||
// drives auto-saving. Nothing is auto-saved anymore for as long as the app runs, without telling the user
|
{
|
||||||
lock (ProfileService.SavedProfiles)
|
lock (ProfileService.SavedProfiles)
|
||||||
ProfileService.SavedProfiles.Count.ShouldBe(1);
|
return ProfileService.SavedProfiles.Count > 1;
|
||||||
|
});
|
||||||
|
layer.Name.ShouldBe("Second");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ public class ProfileEditorTimeTests : ProfileEditorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ResumingEditing_TakesBackControlButLeavesTheTimelinesAtTheStart()
|
public async Task ResumingEditing_TakesBackControlAndAppliesTheTime()
|
||||||
{
|
{
|
||||||
Layer layer = AddLayer("Layer", null, LedId.Keyboard_A);
|
Layer layer = AddLayer("Layer", null, LedId.Keyboard_A);
|
||||||
await StartEditing();
|
await StartEditing();
|
||||||
@@ -142,13 +142,7 @@ public class ProfileEditorTimeTests : ProfileEditorTestBase
|
|||||||
|
|
||||||
Current(Service.SuspendedEditing).ShouldBeFalse();
|
Current(Service.SuspendedEditing).ShouldBeFalse();
|
||||||
ProfileService.UpdateFocusProfile.ShouldBeFalse();
|
ProfileService.UpdateFocusProfile.ShouldBeFalse();
|
||||||
// Pinned as-is: resuming calls Tick to apply the editor's time again, but does so before marking editing as
|
|
||||||
// resumed, and Tick skips suspended editing. So the profile stays where suspending left it (at the start) until
|
|
||||||
// the time changes, while the editor shows 2 seconds
|
|
||||||
layer.Timeline.Position.ShouldBe(TimeSpan.Zero);
|
|
||||||
Current(Service.Time).ShouldBe(TimeSpan.FromSeconds(2));
|
Current(Service.Time).ShouldBe(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
Service.ChangeTime(TimeSpan.FromSeconds(2));
|
|
||||||
layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(2));
|
layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,14 +45,12 @@ public class ProfileEditorTimelineHelperTests : ProfileEditorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SelectKeyframe_ExpandingWithAKeyframeAlreadySelected_SelectsItTwice()
|
public void SelectKeyframe_ExpandingWithAKeyframeAlreadySelected_SelectsItOnce()
|
||||||
{
|
{
|
||||||
Service.SelectKeyframe(_first, false, false);
|
Service.SelectKeyframe(_first, false, false);
|
||||||
Service.SelectKeyframe(_first, true, false);
|
Service.SelectKeyframe(_first, true, false);
|
||||||
|
|
||||||
// Pinned as-is: unlike SelectKeyframes, expanding the selection does not check whether the keyframe is already
|
Service.SelectedKeyframes.ShouldBe([_first]);
|
||||||
// selected, so it ends up in the selection twice
|
|
||||||
Service.SelectedKeyframes.ShouldBe([_first, _first]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -153,14 +151,13 @@ public class ProfileEditorTimelineHelperTests : ProfileEditorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SnapToTimeline_NeverSnapsToASnapTimeOfZero()
|
public void SnapToTimeline_SnapsToASnapTimeOfZero()
|
||||||
{
|
{
|
||||||
|
// A keyframe at the very start of the timeline
|
||||||
List<TimeSpan> snapTimes = [TimeSpan.Zero];
|
List<TimeSpan> snapTimes = [TimeSpan.Zero];
|
||||||
|
|
||||||
// Pinned as-is: a snap time of zero can't be told apart from "no snap time found" (the default of TimeSpan), so
|
|
||||||
// a keyframe at the very start of the timeline is never snapped to
|
|
||||||
Service.SnapToTimeline(TimeSpan.FromMilliseconds(30), TimeSpan.FromMilliseconds(100), false, false, snapTimes)
|
Service.SnapToTimeline(TimeSpan.FromMilliseconds(30), TimeSpan.FromMilliseconds(100), false, false, snapTimes)
|
||||||
.ShouldBe(TimeSpan.FromMilliseconds(30));
|
.ShouldBe(TimeSpan.Zero);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ public class AddProfileElement : IProfileEditorCommand, IDisposable
|
|||||||
public void Undo()
|
public void Undo()
|
||||||
{
|
{
|
||||||
_isAdded = false;
|
_isAdded = false;
|
||||||
|
_subject.Disable();
|
||||||
_target.RemoveChild(_subject);
|
_target.RemoveChild(_subject);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ public class ChangeLayerBrush : IProfileEditorCommand, IDisposable
|
|||||||
{
|
{
|
||||||
if (_previousBrush != null)
|
if (_previousBrush != null)
|
||||||
_layer.ChangeLayerBrush(_previousBrush);
|
_layer.ChangeLayerBrush(_previousBrush);
|
||||||
|
else
|
||||||
|
_layer.RemoveLayerBrush();
|
||||||
_executed = false;
|
_executed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ public class MoveProfileElement : IProfileEditorCommand
|
|||||||
_originalIndex = _subject.Parent.Children.IndexOf(_subject);
|
_originalIndex = _subject.Parent.Children.IndexOf(_subject);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subject is Folder)
|
DisplayName = subject is Folder ? "Move folder" : "Move layer";
|
||||||
DisplayName = "Move folder";
|
|
||||||
DisplayName = "Move layer";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Implementation of IProfileEditorCommand
|
#region Implementation of IProfileEditorCommand
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using Artemis.Core;
|
using Artemis.Core;
|
||||||
using Artemis.Core.LayerEffects;
|
using Artemis.Core.LayerEffects;
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ public class RemoveLayerEffect : IProfileEditorCommand, IDisposable
|
|||||||
private readonly BaseLayerEffect _layerEffect;
|
private readonly BaseLayerEffect _layerEffect;
|
||||||
private readonly RenderProfileElement _renderProfileElement;
|
private readonly RenderProfileElement _renderProfileElement;
|
||||||
private bool _executed;
|
private bool _executed;
|
||||||
|
private int _order;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new instance of the <see cref="RemoveLayerEffect" /> class.
|
/// Creates a new instance of the <see cref="RemoveLayerEffect" /> class.
|
||||||
@@ -35,6 +37,7 @@ public class RemoveLayerEffect : IProfileEditorCommand, IDisposable
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Execute()
|
public void Execute()
|
||||||
{
|
{
|
||||||
|
_order = _layerEffect.Order;
|
||||||
_renderProfileElement.RemoveLayerEffect(_layerEffect);
|
_renderProfileElement.RemoveLayerEffect(_layerEffect);
|
||||||
_executed = true;
|
_executed = true;
|
||||||
}
|
}
|
||||||
@@ -42,6 +45,11 @@ public class RemoveLayerEffect : IProfileEditorCommand, IDisposable
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Undo()
|
public void Undo()
|
||||||
{
|
{
|
||||||
|
// Removing the effect renumbered the effects below it, make room so it goes back in its old place
|
||||||
|
foreach (BaseLayerEffect layerEffect in _renderProfileElement.LayerEffects.Where(e => e.Order >= _order))
|
||||||
|
layerEffect.Order++;
|
||||||
|
_layerEffect.Order = _order;
|
||||||
|
|
||||||
_renderProfileElement.AddLayerEffect(_layerEffect);
|
_renderProfileElement.AddLayerEffect(_layerEffect);
|
||||||
_executed = false;
|
_executed = false;
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-23
@@ -14,6 +14,8 @@ public class ToggleLayerPropertyKeyframes<T> : IProfileEditorCommand
|
|||||||
private readonly LayerProperty<T> _layerProperty;
|
private readonly LayerProperty<T> _layerProperty;
|
||||||
private readonly TimeSpan _time;
|
private readonly TimeSpan _time;
|
||||||
private LayerPropertyKeyframe<T>? _keyframe;
|
private LayerPropertyKeyframe<T>? _keyframe;
|
||||||
|
private bool _addedKeyframe;
|
||||||
|
private bool _wasEnabled;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new instance of the <see cref="ToggleLayerPropertyKeyframes{T}" /> class.
|
/// Creates a new instance of the <see cref="ToggleLayerPropertyKeyframes{T}" /> class.
|
||||||
@@ -33,37 +35,26 @@ public class ToggleLayerPropertyKeyframes<T> : IProfileEditorCommand
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Execute()
|
public void Execute()
|
||||||
{
|
{
|
||||||
if (_enable)
|
_wasEnabled = _layerProperty.KeyframesEnabled;
|
||||||
{
|
_addedKeyframe = false;
|
||||||
_layerProperty.KeyframesEnabled = true;
|
_layerProperty.KeyframesEnabled = _enable;
|
||||||
|
|
||||||
// If there weren't any keyframes yet, add one with the current value at the current time
|
// If there weren't any keyframes yet, add one with the current value at the current time
|
||||||
if (!_layerProperty.Keyframes.Any())
|
if (_enable && !_layerProperty.Keyframes.Any())
|
||||||
{
|
|
||||||
// If executed before, reuse the previous keyframe
|
|
||||||
_keyframe ??= new LayerPropertyKeyframe<T>(_layerProperty.CurrentValue, _time, Easings.Functions.Linear, _layerProperty);
|
|
||||||
_layerProperty.AddKeyframe(_keyframe);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
_layerProperty.KeyframesEnabled = false;
|
// If executed before, reuse the previous keyframe
|
||||||
|
_keyframe ??= new LayerPropertyKeyframe<T>(_layerProperty.CurrentValue, _time, Easings.Functions.Linear, _layerProperty);
|
||||||
|
_layerProperty.AddKeyframe(_keyframe);
|
||||||
|
_addedKeyframe = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Undo()
|
public void Undo()
|
||||||
{
|
{
|
||||||
if (_enable)
|
if (_addedKeyframe && _keyframe != null)
|
||||||
{
|
_layerProperty.RemoveKeyframe(_keyframe);
|
||||||
if (_keyframe != null)
|
_layerProperty.KeyframesEnabled = _wasEnabled;
|
||||||
_layerProperty.RemoveKeyframe(_keyframe);
|
|
||||||
_layerProperty.KeyframesEnabled = false;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_layerProperty.KeyframesEnabled = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public class UpdateLayerProperty<T> : IProfileEditorCommand
|
|||||||
_originalValue = originalValue;
|
_originalValue = originalValue;
|
||||||
_newValue = newValue;
|
_newValue = newValue;
|
||||||
_time = time;
|
_time = time;
|
||||||
|
_hasKeyframe = _layerProperty.Keyframes.Any(k => k.Position == time);
|
||||||
|
|
||||||
DisplayName = $"Update {_layerProperty.PropertyDescription.Name ?? "property"}";
|
DisplayName = $"Update {_layerProperty.PropertyDescription.Name ?? "property"}";
|
||||||
}
|
}
|
||||||
@@ -58,8 +59,9 @@ public class UpdateLayerProperty<T> : IProfileEditorCommand
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the layer had no keyframe yet but keyframes are enabled, a new keyframe will be returned
|
// If the layer had no keyframe yet but keyframes are enabled, a new keyframe will be returned. Without a time the
|
||||||
if (!_hasKeyframe && _layerProperty.KeyframesEnabled)
|
// base value is changed instead
|
||||||
|
if (!_hasKeyframe && _time != null && _layerProperty.KeyframesEnabled)
|
||||||
{
|
{
|
||||||
_newKeyframe = _layerProperty.SetCurrentValue(_newValue, _time);
|
_newKeyframe = _layerProperty.SetCurrentValue(_newValue, _time);
|
||||||
DisplayName = $"Add {_layerProperty.PropertyDescription.Name ?? "property"} keyframe";
|
DisplayName = $"Add {_layerProperty.PropertyDescription.Name ?? "property"} keyframe";
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ namespace Artemis.UI.Shared.Services.ProfileEditor;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class ProfileEditorHistory
|
public class ProfileEditorHistory
|
||||||
{
|
{
|
||||||
private readonly Subject<bool> _canRedo = new();
|
private readonly BehaviorSubject<bool> _canRedo = new(false);
|
||||||
private readonly Subject<bool> _canUndo = new();
|
private readonly BehaviorSubject<bool> _canUndo = new(false);
|
||||||
private readonly Stack<IProfileEditorCommand> _redoCommands = new();
|
private readonly Stack<IProfileEditorCommand> _redoCommands = new();
|
||||||
private readonly Stack<IProfileEditorCommand> _undoCommands = new();
|
private readonly Stack<IProfileEditorCommand> _undoCommands = new();
|
||||||
|
|
||||||
@@ -28,6 +28,9 @@ public class ProfileEditorHistory
|
|||||||
ProfileConfiguration = profileConfiguration;
|
ProfileConfiguration = profileConfiguration;
|
||||||
|
|
||||||
Execute = ReactiveCommand.Create<IProfileEditorCommand>(ExecuteEditorCommand);
|
Execute = ReactiveCommand.Create<IProfileEditorCommand>(ExecuteEditorCommand);
|
||||||
|
// Whoever executes a command reports its failure (the profile editor service shows a dialog), without this
|
||||||
|
// ReactiveUI would also treat it as unhandled
|
||||||
|
Execute.ThrownExceptions.Subscribe(_ => { });
|
||||||
Undo = ReactiveCommand.Create(ExecuteUndo, CanUndo);
|
Undo = ReactiveCommand.Create(ExecuteUndo, CanUndo);
|
||||||
Redo = ReactiveCommand.Create(ExecuteRedo, CanRedo);
|
Redo = ReactiveCommand.Create(ExecuteRedo, CanRedo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ internal class ProfileEditorService : IProfileEditorService
|
|||||||
ProfileConfiguration = _profileConfigurationSubject.AsObservable();
|
ProfileConfiguration = _profileConfigurationSubject.AsObservable();
|
||||||
ProfileElement = _profileElementSubject.AsObservable();
|
ProfileElement = _profileElementSubject.AsObservable();
|
||||||
LayerProperty = _layerPropertySubject.AsObservable();
|
LayerProperty = _layerPropertySubject.AsObservable();
|
||||||
History = Observable.Defer(() => Observable.Return(GetHistory(_profileConfigurationSubject.Value))).Concat(ProfileConfiguration.Select(GetHistory));
|
History = ProfileConfiguration.Select(GetHistory);
|
||||||
Time = _timeSubject.AsObservable();
|
Time = _timeSubject.AsObservable();
|
||||||
Playing = _playingSubject.AsObservable();
|
Playing = _playingSubject.AsObservable();
|
||||||
SuspendedEditing = _suspendedEditingSubject.AsObservable();
|
SuspendedEditing = _suspendedEditingSubject.AsObservable();
|
||||||
@@ -101,9 +101,9 @@ internal class ProfileEditorService : IProfileEditorService
|
|||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
// Don't rethrow, that would end the observable that drives auto-saving
|
||||||
_windowService.ShowExceptionDialog("Failed to auto-save profile", e);
|
_windowService.ShowExceptionDialog("Failed to auto-save profile", e);
|
||||||
_logger.Error(e, "Failed to auto-save profile");
|
_logger.Error(e, "Failed to auto-save profile");
|
||||||
throw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
@@ -138,8 +138,12 @@ internal class ProfileEditorService : IProfileEditorService
|
|||||||
|
|
||||||
private void ApplyFocusMode()
|
private void ApplyFocusMode()
|
||||||
{
|
{
|
||||||
|
// While editing is suspended the profile plays normally, without focus
|
||||||
if (_suspendedEditingSubject.Value)
|
if (_suspendedEditingSubject.Value)
|
||||||
|
{
|
||||||
_profileService.FocusProfileElement = null;
|
_profileService.FocusProfileElement = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_profileService.FocusProfileElement = _focusModeSubject.Value switch
|
_profileService.FocusProfileElement = _focusModeSubject.Value switch
|
||||||
{
|
{
|
||||||
@@ -235,10 +239,12 @@ internal class ProfileEditorService : IProfileEditorService
|
|||||||
{
|
{
|
||||||
if (_profileConfigurationSubject.Value != null)
|
if (_profileConfigurationSubject.Value != null)
|
||||||
_profileService.UpdateFocusProfile = false;
|
_profileService.UpdateFocusProfile = false;
|
||||||
Tick(_timeSubject.Value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_suspendedEditingSubject.OnNext(suspend);
|
_suspendedEditingSubject.OnNext(suspend);
|
||||||
|
// Apply the editor's time again, this only works once editing is no longer suspended
|
||||||
|
if (!suspend)
|
||||||
|
Tick(_timeSubject.Value);
|
||||||
ApplyFocusMode();
|
ApplyFocusMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +277,10 @@ internal class ProfileEditorService : IProfileEditorService
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (expand)
|
if (expand)
|
||||||
_selectedKeyframes.Add(keyframe);
|
{
|
||||||
|
if (!_selectedKeyframes.Items.Contains(keyframe))
|
||||||
|
_selectedKeyframes.Add(keyframe);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
_selectedKeyframes.Edit(l =>
|
_selectedKeyframes.Edit(l =>
|
||||||
{
|
{
|
||||||
@@ -324,9 +333,11 @@ internal class ProfileEditorService : IProfileEditorService
|
|||||||
if (snapTimes != null)
|
if (snapTimes != null)
|
||||||
{
|
{
|
||||||
// Find the closest keyframe
|
// Find the closest keyframe
|
||||||
TimeSpan closeSnapTime = snapTimes.FirstOrDefault(s => Math.Abs(time.TotalMilliseconds - s.TotalMilliseconds) < tolerance.TotalMilliseconds)!;
|
TimeSpan? closeSnapTime = snapTimes.Where(s => Math.Abs(time.TotalMilliseconds - s.TotalMilliseconds) < tolerance.TotalMilliseconds)
|
||||||
if (closeSnapTime != TimeSpan.Zero)
|
.Cast<TimeSpan?>()
|
||||||
return closeSnapTime;
|
.FirstOrDefault();
|
||||||
|
if (closeSnapTime != null)
|
||||||
|
return closeSnapTime.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return time;
|
return time;
|
||||||
@@ -368,7 +379,7 @@ internal class ProfileEditorService : IProfileEditorService
|
|||||||
{
|
{
|
||||||
if (target is Layer targetLayer)
|
if (target is Layer targetLayer)
|
||||||
{
|
{
|
||||||
Layer layer = new(targetLayer.Parent, targetLayer.GetNewLayerName());
|
Layer layer = new(targetLayer.Parent, targetLayer.Parent.GetNewLayerName());
|
||||||
_layerBrushService.ApplyDefaultBrush(layer);
|
_layerBrushService.ApplyDefaultBrush(layer);
|
||||||
|
|
||||||
layer.AddLeds(_deviceService.EnabledDevices.SelectMany(d => d.Leds));
|
layer.AddLeds(_deviceService.EnabledDevices.SelectMany(d => d.Leds));
|
||||||
@@ -463,6 +474,10 @@ internal class ProfileEditorService : IProfileEditorService
|
|||||||
ProfileEditorCommandScope scope = _profileEditorHistoryScope;
|
ProfileEditorCommandScope scope = _profileEditorHistoryScope;
|
||||||
_profileEditorHistoryScope = null;
|
_profileEditorHistoryScope = null;
|
||||||
|
|
||||||
|
// Don't add an undo step that does nothing
|
||||||
|
if (!scope.ProfileEditorCommands.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
// Executing the composite command won't do anything the first time (see last ctor variable)
|
// Executing the composite command won't do anything the first time (see last ctor variable)
|
||||||
// commands were already executed each time they were added to the scope
|
// commands were already executed each time they were added to the scope
|
||||||
ExecuteCommand(new CompositeCommand(scope.ProfileEditorCommands, scope.Name, true));
|
ExecuteCommand(new CompositeCommand(scope.ProfileEditorCommands, scope.Name, true));
|
||||||
|
|||||||
Reference in new issue
Block a user