Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/XTMF2.GUI/Resources/Strings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 49 additions & 12 deletions src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ public sealed partial class ModelSystemEditorViewModel : ObservableObject, IDisp
{
private bool _disposed;
private bool _suppressSearchRebuild;
private int _saveInProgress;

// ── Session / model ────────────────────────────────────────────────────
/// <summary>The active editing session for the model system.</summary>
Expand Down Expand Up @@ -618,6 +619,9 @@ public System.Collections.ObjectModel.ReadOnlyObservableCollection<Type> Availab
/// <summary>True when the model system changed since its last successful save.</summary>
public bool IsDirty => Session.IsDirty;

/// <summary>True while the model system is being saved.</summary>
public bool IsSaving => Volatile.Read(ref _saveInProgress) != 0;

// Unsubscribe from the outgoing element before the field changes.
partial void OnSelectedElementChanging(ICanvasElement? value)
{
Expand Down Expand Up @@ -885,6 +889,12 @@ public ModelSystemEditorViewModel(

private void OnSessionPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (!Avalonia.Threading.Dispatcher.UIThread.CheckAccess())
{
Avalonia.Threading.Dispatcher.UIThread.Post(() => OnSessionPropertyChanged(sender, e));
return;
}

if (e.PropertyName == nameof(Session.CanUndo))
OnPropertyChanged(nameof(CanUndo));
else if (e.PropertyName == nameof(Session.CanRedo))
Expand Down Expand Up @@ -3658,25 +3668,52 @@ private async Task RunCalibration()
}

/// <summary>Save the model system to its project file.</summary>
[RelayCommand]
[RelayCommand(CanExecute = nameof(CanSaveModelSystem))]
private Task SaveModelSystem() => SaveModelSystemAsync();

private bool CanSaveModelSystem() => !IsSaving;

public async Task<bool> SaveModelSystemAsync()
{
ShowToast(Strings.ModelSystemEditor_ToastSaving);
// Yield to let the UI render the "saving" toast before the synchronous save runs.
await Task.Yield();
if (Session.Save(out var error))
if (Interlocked.CompareExchange(ref _saveInProgress, 1, 0) != 0)
return false;

try
{
ShowToast(Strings.ModelSystemEditor_ToastSaved);
return true;
await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
{
OnPropertyChanged(nameof(IsSaving));
SaveModelSystemCommand.NotifyCanExecuteChanged();
});

var (saved, error) = await Task.Run(async () =>
{
var saveSucceeded = Session.Save(out var saveError);
return (saveSucceeded, saveError);
}).ConfigureAwait(false);

return await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
{
if (saved)
{
ShowToast(Strings.ModelSystemEditor_ToastSaved);
return true;
}

var msg = string.Format(Strings.ModelSystemEditor_ToastSaveFailed,
error?.Message ?? Strings.ModelSystems_UnknownError);
ShowToast(msg, isError: true, durationMs: 6000);
return false;
});
}
else
finally
{
var msg = string.Format(Strings.ModelSystemEditor_ToastSaveFailed,
error?.Message ?? Strings.ModelSystems_UnknownError);
ShowToast(msg, isError: true, durationMs: 6000);
return false;
await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
{
Volatile.Write(ref _saveInProgress, 0);
OnPropertyChanged(nameof(IsSaving));
SaveModelSystemCommand.NotifyCanExecuteChanged();
});
}
}

Expand Down
70 changes: 52 additions & 18 deletions src/XTMF2.GUI/Views/ModelSystemEditorView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,22 @@
<Style Selector="Border.neon-pill">
<Setter Property="BoxShadow" Value="0 0 14 3 #8000D4FF, 0 0 40 10 #4000A0FF, 0 6 24 0 #80000000"/>
</Style>
<Style Selector="Canvas.save-spinner">
<Setter Property="RenderTransformOrigin" Value="50%,50%"/>
<Setter Property="RenderTransform">
<RotateTransform Angle="0"/>
</Setter>
<Style.Animations>
<Animation Duration="0:0:0.9" IterationCount="Infinite">
<KeyFrame Cue="0%">
<Setter Property="RotateTransform.Angle" Value="0"/>
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="RotateTransform.Angle" Value="360"/>
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
</UserControl.Styles>

<UserControl.KeyBindings>
Expand Down Expand Up @@ -274,29 +290,47 @@
<!-- Save -->
<Button Command="{Binding SaveModelSystemCommand}"
ToolTip.Tip="{res:Localize ModelSystemEditor_SaveTooltip}">
<Viewbox Width="18" Height="18">
<Canvas Width="20" Height="20">
<!-- Floppy disk: outline body so details are visible in both themes -->
<!-- Outer shell (stroke only) -->
<Path Data="M 2,2 H 14 L 18,6 V 18 H 2 Z"
<Grid Width="24" Height="24">
<Viewbox Width="18" Height="18">
<Canvas Width="20" Height="20">
<!-- Floppy disk: outline body so details are visible in both themes -->
<!-- Outer shell (stroke only) -->
<Path Data="M 2,2 H 14 L 18,6 V 18 H 2 Z"
Stroke="{DynamicResource NeonIconPrimary}"
StrokeThickness="1.6" StrokeLineCap="Round" StrokeJoin="Round"
Fill="Transparent"/>
<!-- Corner fold crease -->
<Path Data="M 14,2 L 14,6 H 18"
Stroke="{DynamicResource NeonIconPrimary}"
StrokeThickness="1.6" StrokeLineCap="Round" StrokeJoin="Round"
Fill="Transparent"/>
<!-- Shutter strip (filled) -->
<Path Data="M 5,2.5 H 13 V 7 H 5 Z"
Fill="{DynamicResource NeonIconSecondary}" StrokeThickness="0"/>
<!-- Label sticker (outlined) -->
<Path Data="M 4,10.5 H 16 V 16.5 H 4 Z"
Stroke="{DynamicResource NeonIconSecondary}"
StrokeThickness="1.2" StrokeLineCap="Round" StrokeJoin="Round"
Fill="Transparent"/>
</Canvas>
</Viewbox>
<Canvas Width="36" Height="36"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Classes="save-spinner"
IsVisible="{Binding IsSaving}">
<Path Data="M 18,2 A 16,16 0 0 1 31.9,26"
Stroke="{DynamicResource NeonIconPrimary}"
StrokeThickness="1.6" StrokeLineCap="Round" StrokeJoin="Round"
StrokeThickness="2.4"
StrokeLineCap="Round"
Fill="Transparent"/>
<!-- Corner fold crease -->
<Path Data="M 14,2 L 14,6 H 18"
Stroke="{DynamicResource NeonIconPrimary}"
StrokeThickness="1.6" StrokeLineCap="Round" StrokeJoin="Round"
Fill="Transparent"/>
<!-- Shutter strip (filled) -->
<Path Data="M 5,2.5 H 13 V 7 H 5 Z"
Fill="{DynamicResource NeonIconSecondary}" StrokeThickness="0"/>
<!-- Label sticker (outlined) -->
<Path Data="M 4,10.5 H 16 V 16.5 H 4 Z"
<Path Data="M 18,34 A 16,16 0 0 1 4.1,10"
Stroke="{DynamicResource NeonIconSecondary}"
StrokeThickness="1.2" StrokeLineCap="Round" StrokeJoin="Round"
StrokeThickness="2.4"
StrokeLineCap="Round"
Fill="Transparent"/>
</Canvas>
</Viewbox>
</Grid>
</Button>

<!-- Export -->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using Avalonia.Headless;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using XTMF2.GUI.Tests.Modules;
using XTMF2.GUI.ViewModels;
using XTMF2.ModelSystemConstruct;

namespace XTMF2.GUI.Tests.ViewModels;

[TestClass]
public class ModelSystemEditorViewModelSaveTests
{
[TestMethod]
public void SaveModelSystemAsync_DisablesCommandAndRejectsOverlappingSave()
{
TestGuiHelper.RunInModelSystemContext(
nameof(SaveModelSystemAsync_DisablesCommandAndRejectsOverlappingSave),
(user, _, session) =>
{
Assert.IsTrue(session.AddNode(user, session.ModelSystem.GlobalBoundary,
"SaveTestNode", typeof(SimpleGuiTestModule), Rectangle.Hidden,
out var node, out var error), error?.Message);
Assert.IsNotNull(node);
Assert.IsTrue(session.IsDirty);

using var viewModel = new ModelSystemEditorViewModel(session, user, runController: null);
Task<bool>? overlappingSave = null;
bool commandDisabledWhileSaving = false;
viewModel.PropertyChanged += (_, args) =>
{
if (args.PropertyName == nameof(viewModel.IsSaving) && viewModel.IsSaving)
{
commandDisabledWhileSaving = !viewModel.SaveModelSystemCommand.CanExecute(null);
overlappingSave = viewModel.SaveModelSystemAsync();
}
};

Task<bool>? save = null;
HeadlessAppLifetime.HeadlessSession!.Dispatch(
() => save = viewModel.SaveModelSystemAsync(),
System.Threading.CancellationToken.None).GetAwaiter().GetResult();

Assert.IsTrue(save!.GetAwaiter().GetResult());
Assert.IsNotNull(overlappingSave);
Assert.IsFalse(overlappingSave!.GetAwaiter().GetResult());
Assert.IsTrue(commandDisabledWhileSaving);
Assert.IsFalse(viewModel.IsSaving);
Assert.IsFalse(session.IsDirty);
Assert.IsTrue(viewModel.SaveModelSystemCommand.CanExecute(null));
});
}
}
Loading