mirror of
https://github.com/Artemis-RGB/Artemis
synced 2025-12-12 13:28:33 +00:00
Merge branch 'development'
This commit is contained in:
commit
bbadef7a9a
@ -17,13 +17,12 @@ public sealed class Profile : ProfileElement
|
||||
private readonly ObservableCollection<ScriptConfiguration> _scriptConfigurations;
|
||||
private readonly ObservableCollection<ProfileScript> _scripts;
|
||||
private bool _isFreshImport;
|
||||
private ProfileElement? _lastSelectedProfileElement;
|
||||
|
||||
internal Profile(ProfileConfiguration configuration, ProfileEntity profileEntity) : base(null!)
|
||||
{
|
||||
_scripts = new ObservableCollection<ProfileScript>();
|
||||
_scriptConfigurations = new ObservableCollection<ScriptConfiguration>();
|
||||
|
||||
|
||||
Opacity = 0d;
|
||||
ShouldDisplay = true;
|
||||
Configuration = configuration;
|
||||
@ -67,15 +66,6 @@ public sealed class Profile : ProfileElement
|
||||
set => SetAndNotify(ref _isFreshImport, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the last selected profile element of this profile
|
||||
/// </summary>
|
||||
public ProfileElement? LastSelectedProfileElement
|
||||
{
|
||||
get => _lastSelectedProfileElement;
|
||||
set => SetAndNotify(ref _lastSelectedProfileElement, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the profile entity this profile uses for persistent storage
|
||||
/// </summary>
|
||||
@ -105,7 +95,7 @@ public sealed class Profile : ProfileElement
|
||||
profileScript.OnProfileUpdated(deltaTime);
|
||||
|
||||
const double OPACITY_PER_SECOND = 1;
|
||||
|
||||
|
||||
if (ShouldDisplay && Opacity < 1)
|
||||
Opacity = Math.Clamp(Opacity + OPACITY_PER_SECOND * deltaTime, 0d, 1d);
|
||||
if (!ShouldDisplay && Opacity > 0)
|
||||
@ -123,14 +113,14 @@ public sealed class Profile : ProfileElement
|
||||
|
||||
foreach (ProfileScript profileScript in Scripts)
|
||||
profileScript.OnProfileRendering(canvas, canvas.LocalClipBounds);
|
||||
|
||||
|
||||
SKPaint? opacityPaint = null;
|
||||
bool applyOpacityLayer = Configuration.FadeInAndOut && Opacity < 1;
|
||||
|
||||
|
||||
if (applyOpacityLayer)
|
||||
{
|
||||
opacityPaint = new SKPaint();
|
||||
opacityPaint.Color = new SKColor(0, 0, 0, (byte)(255d * Easings.CubicEaseInOut(Opacity)));
|
||||
opacityPaint.Color = new SKColor(0, 0, 0, (byte) (255d * Easings.CubicEaseInOut(Opacity)));
|
||||
canvas.SaveLayer(opacityPaint);
|
||||
}
|
||||
|
||||
@ -242,20 +232,13 @@ public sealed class Profile : ProfileElement
|
||||
AddChild(new Folder(this, this, rootFolder));
|
||||
}
|
||||
|
||||
List<RenderProfileElement> renderElements = GetAllRenderElements();
|
||||
|
||||
if (ProfileEntity.LastSelectedProfileElement != Guid.Empty)
|
||||
LastSelectedProfileElement = renderElements.FirstOrDefault(f => f.EntityId == ProfileEntity.LastSelectedProfileElement);
|
||||
else
|
||||
LastSelectedProfileElement = null;
|
||||
|
||||
while (_scriptConfigurations.Any())
|
||||
RemoveScriptConfiguration(_scriptConfigurations[0]);
|
||||
foreach (ScriptConfiguration scriptConfiguration in ProfileEntity.ScriptConfigurations.Select(e => new ScriptConfiguration(e)))
|
||||
AddScriptConfiguration(scriptConfiguration);
|
||||
|
||||
// Load node scripts last since they may rely on the profile structure being in place
|
||||
foreach (RenderProfileElement renderProfileElement in renderElements)
|
||||
foreach (RenderProfileElement renderProfileElement in GetAllRenderElements())
|
||||
renderProfileElement.LoadNodeScript();
|
||||
}
|
||||
|
||||
@ -312,7 +295,6 @@ public sealed class Profile : ProfileElement
|
||||
ProfileEntity.Id = EntityId;
|
||||
ProfileEntity.Name = Configuration.Name;
|
||||
ProfileEntity.IsFreshImport = IsFreshImport;
|
||||
ProfileEntity.LastSelectedProfileElement = LastSelectedProfileElement?.EntityId ?? Guid.Empty;
|
||||
|
||||
foreach (ProfileElement profileElement in Children)
|
||||
profileElement.Save();
|
||||
|
||||
@ -98,6 +98,7 @@ public class ProfileCategory : CorePropertyChanged, IStorageModel
|
||||
/// </summary>
|
||||
public void AddProfileConfiguration(ProfileConfiguration configuration, int? targetIndex)
|
||||
{
|
||||
// TODO: Look into this, it doesn't seem to make sense
|
||||
// Removing the original will shift every item in the list forwards, keep that in mind with the target index
|
||||
if (configuration.Category == this && targetIndex != null && targetIndex.Value > _profileConfigurations.IndexOf(configuration))
|
||||
targetIndex -= 1;
|
||||
|
||||
@ -147,16 +147,7 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable
|
||||
get => _activationConditionMet;
|
||||
private set => SetAndNotify(ref _activationConditionMet, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a boolean indicating whether this profile configuration is being edited
|
||||
/// </summary>
|
||||
public bool IsBeingEdited
|
||||
{
|
||||
get => _isBeingEdited;
|
||||
set => SetAndNotify(ref _isBeingEdited, value);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets the profile of this profile configuration
|
||||
/// </summary>
|
||||
@ -243,8 +234,6 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable
|
||||
{
|
||||
if (_disposed)
|
||||
throw new ObjectDisposedException("ProfileConfiguration");
|
||||
if (IsBeingEdited)
|
||||
return true;
|
||||
if (Category.IsSuspended || IsSuspended || IsMissingModule)
|
||||
return false;
|
||||
|
||||
|
||||
@ -15,7 +15,6 @@ public class ProfileConfigurationIcon : CorePropertyChanged, IStorageModel
|
||||
private string? _iconName;
|
||||
private Stream? _iconStream;
|
||||
private ProfileConfigurationIconType _iconType;
|
||||
private string? _originalFileName;
|
||||
|
||||
internal ProfileConfigurationIcon(ProfileConfigurationEntity entity)
|
||||
{
|
||||
|
||||
@ -28,19 +28,26 @@ public interface IProfileService : IArtemisService
|
||||
ReadOnlyCollection<ProfileConfiguration> ProfileConfigurations { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a boolean indicating whether hotkeys are enabled.
|
||||
/// Gets or sets the focused profile configuration which is rendered exclusively.
|
||||
/// </summary>
|
||||
bool HotkeysEnabled { get; set; }
|
||||
ProfileConfiguration? FocusProfile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a boolean indicating whether rendering should only be done for profiles being edited.
|
||||
/// Gets or sets the profile element which is rendered exclusively.
|
||||
/// </summary>
|
||||
bool RenderForEditor { get; set; }
|
||||
ProfileElement? FocusProfileElement { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the profile element to focus on while rendering for the editor.
|
||||
/// Gets or sets a value indicating whether the currently focused profile should receive updates.
|
||||
/// </summary>
|
||||
ProfileElement? EditorFocus { get; set; }
|
||||
bool UpdateFocusProfile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of the provided profile configuration.
|
||||
/// </summary>
|
||||
/// <param name="profileConfiguration">The profile configuration to clone.</param>
|
||||
/// <returns>The resulting clone.</returns>
|
||||
ProfileConfiguration CloneProfileConfiguration(ProfileConfiguration profileConfiguration);
|
||||
|
||||
/// <summary>
|
||||
/// Activates the profile of the given <see cref="ProfileConfiguration" /> with the currently active surface.
|
||||
@ -71,8 +78,9 @@ public interface IProfileService : IArtemisService
|
||||
/// Creates a new profile category and saves it to persistent storage.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the new profile category, must be unique.</param>
|
||||
/// <param name="addToTop">A boolean indicating whether or not to add the category to the top.</param>
|
||||
/// <returns>The newly created profile category.</returns>
|
||||
ProfileCategory CreateProfileCategory(string name);
|
||||
ProfileCategory CreateProfileCategory(string name, bool addToTop = false);
|
||||
|
||||
/// <summary>
|
||||
/// Permanently deletes the provided profile category.
|
||||
@ -119,7 +127,7 @@ public interface IProfileService : IArtemisService
|
||||
Task<Stream> ExportProfile(ProfileConfiguration profileConfiguration);
|
||||
|
||||
/// <summary>
|
||||
/// Imports the provided base64 encoded GZIPed JSON as a profile configuration.
|
||||
/// Imports the provided ZIP archive stream as a profile configuration.
|
||||
/// </summary>
|
||||
/// <param name="archiveStream">The zip archive containing the profile to import.</param>
|
||||
/// <param name="category">The <see cref="ProfileCategory" /> in which to import the profile.</param>
|
||||
@ -129,8 +137,17 @@ public interface IProfileService : IArtemisService
|
||||
/// any changes are made to it.
|
||||
/// </param>
|
||||
/// <param name="nameAffix">Text to add after the name of the profile (separated by a dash).</param>
|
||||
/// <param name="targetIndex">The index at which to import the profile into the category.</param>
|
||||
/// <returns>The resulting profile configuration.</returns>
|
||||
Task<ProfileConfiguration> ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix = "imported");
|
||||
Task<ProfileConfiguration> ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix = "imported", int targetIndex = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Imports the provided ZIP archive stream into the provided profile configuration
|
||||
/// </summary>
|
||||
/// <param name="archiveStream">The zip archive containing the profile to import.</param>
|
||||
/// <param name="profileConfiguration">The profile configuration to overwrite.</param>
|
||||
/// <returns>The resulting profile configuration.</returns>
|
||||
Task<ProfileConfiguration> OverwriteProfile(MemoryStream archiveStream, ProfileConfiguration profileConfiguration);
|
||||
|
||||
/// <summary>
|
||||
/// Adapts a given profile to the currently active devices.
|
||||
@ -168,4 +185,5 @@ public interface IProfileService : IArtemisService
|
||||
/// Occurs whenever a profile category is removed.
|
||||
/// </summary>
|
||||
public event EventHandler<ProfileCategoryEventArgs>? ProfileCategoryRemoved;
|
||||
|
||||
}
|
||||
@ -19,14 +19,14 @@ namespace Artemis.Core.Services;
|
||||
internal class ProfileService : IProfileService
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly IRgbService _rgbService;
|
||||
private readonly IProfileCategoryRepository _profileCategoryRepository;
|
||||
private readonly IPluginManagementService _pluginManagementService;
|
||||
|
||||
private readonly List<ArtemisKeyboardKeyEventArgs> _pendingKeyboardEvents = new();
|
||||
private readonly IPluginManagementService _pluginManagementService;
|
||||
private readonly List<ProfileCategory> _profileCategories;
|
||||
private readonly IProfileCategoryRepository _profileCategoryRepository;
|
||||
private readonly IProfileRepository _profileRepository;
|
||||
private readonly List<Exception> _renderExceptions = new();
|
||||
private readonly IRgbService _rgbService;
|
||||
private readonly List<Exception> _updateExceptions = new();
|
||||
private DateTime _lastRenderExceptionLog;
|
||||
private DateTime _lastUpdateExceptionLog;
|
||||
@ -49,7 +49,6 @@ internal class ProfileService : IProfileService
|
||||
_pluginManagementService.PluginFeatureEnabled += PluginManagementServiceOnPluginFeatureToggled;
|
||||
_pluginManagementService.PluginFeatureDisabled += PluginManagementServiceOnPluginFeatureToggled;
|
||||
|
||||
HotkeysEnabled = true;
|
||||
inputService.KeyboardKeyUp += InputServiceOnKeyboardKeyUp;
|
||||
|
||||
if (!_profileCategories.Any())
|
||||
@ -57,11 +56,488 @@ internal class ProfileService : IProfileService
|
||||
UpdateModules();
|
||||
}
|
||||
|
||||
private void InputServiceOnKeyboardKeyUp(object? sender, ArtemisKeyboardKeyEventArgs e)
|
||||
public ProfileConfiguration? FocusProfile { get; set; }
|
||||
public ProfileElement? FocusProfileElement { get; set; }
|
||||
public bool UpdateFocusProfile { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpdateProfiles(double deltaTime)
|
||||
{
|
||||
if (!HotkeysEnabled)
|
||||
// If there is a focus profile update only that, and only if UpdateFocusProfile is true
|
||||
if (FocusProfile != null)
|
||||
{
|
||||
if (UpdateFocusProfile)
|
||||
FocusProfile.Profile?.Update(deltaTime);
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_profileCategories)
|
||||
{
|
||||
// Iterate the children in reverse because the first category must be rendered last to end up on top
|
||||
for (int i = _profileCategories.Count - 1; i > -1; i--)
|
||||
{
|
||||
ProfileCategory profileCategory = _profileCategories[i];
|
||||
for (int j = profileCategory.ProfileConfigurations.Count - 1; j > -1; j--)
|
||||
{
|
||||
ProfileConfiguration profileConfiguration = profileCategory.ProfileConfigurations[j];
|
||||
|
||||
// Process hotkeys that where pressed since this profile last updated
|
||||
ProcessPendingKeyEvents(profileConfiguration);
|
||||
|
||||
bool shouldBeActive = profileConfiguration.ShouldBeActive(false);
|
||||
if (shouldBeActive)
|
||||
{
|
||||
profileConfiguration.Update();
|
||||
shouldBeActive = profileConfiguration.ActivationConditionMet;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Make sure the profile is active or inactive according to the parameters above
|
||||
if (shouldBeActive && profileConfiguration.Profile == null && profileConfiguration.BrokenState != "Failed to activate profile")
|
||||
profileConfiguration.TryOrBreak(() => ActivateProfile(profileConfiguration), "Failed to activate profile");
|
||||
if (shouldBeActive && profileConfiguration.Profile != null && !profileConfiguration.Profile.ShouldDisplay)
|
||||
profileConfiguration.Profile.ShouldDisplay = true;
|
||||
else if (!shouldBeActive && profileConfiguration.Profile != null)
|
||||
{
|
||||
if (!profileConfiguration.FadeInAndOut)
|
||||
DeactivateProfile(profileConfiguration);
|
||||
else if (!profileConfiguration.Profile.ShouldDisplay && profileConfiguration.Profile.Opacity <= 0)
|
||||
DeactivateProfile(profileConfiguration);
|
||||
else if (profileConfiguration.Profile.Opacity > 0)
|
||||
RequestDeactivation(profileConfiguration);
|
||||
}
|
||||
|
||||
profileConfiguration.Profile?.Update(deltaTime);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_updateExceptions.Add(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LogProfileUpdateExceptions();
|
||||
_pendingKeyboardEvents.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RenderProfiles(SKCanvas canvas)
|
||||
{
|
||||
// If there is a focus profile, render only that
|
||||
if (FocusProfile != null)
|
||||
{
|
||||
FocusProfile.Profile?.Render(canvas, SKPointI.Empty, FocusProfileElement);
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_profileCategories)
|
||||
{
|
||||
// Iterate the children in reverse because the first category must be rendered last to end up on top
|
||||
for (int i = _profileCategories.Count - 1; i > -1; i--)
|
||||
{
|
||||
ProfileCategory profileCategory = _profileCategories[i];
|
||||
for (int j = profileCategory.ProfileConfigurations.Count - 1; j > -1; j--)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProfileConfiguration profileConfiguration = profileCategory.ProfileConfigurations[j];
|
||||
// Ensure all criteria are met before rendering
|
||||
bool fadingOut = profileConfiguration.Profile?.ShouldDisplay == false && profileConfiguration.Profile?.Opacity > 0;
|
||||
if (!profileConfiguration.IsSuspended && !profileConfiguration.IsMissingModule && (profileConfiguration.ActivationConditionMet || fadingOut))
|
||||
profileConfiguration.Profile?.Render(canvas, SKPointI.Empty, null);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_renderExceptions.Add(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LogProfileRenderExceptions();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ReadOnlyCollection<ProfileCategory> ProfileCategories
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_profileRepository)
|
||||
{
|
||||
return _profileCategories.AsReadOnly();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ReadOnlyCollection<ProfileConfiguration> ProfileConfigurations
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_profileRepository)
|
||||
{
|
||||
return _profileCategories.SelectMany(c => c.ProfileConfigurations).ToList().AsReadOnly();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void LoadProfileConfigurationIcon(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
if (profileConfiguration.Icon.IconType == ProfileConfigurationIconType.MaterialIcon)
|
||||
return;
|
||||
|
||||
using Stream? stream = _profileCategoryRepository.GetProfileIconStream(profileConfiguration.Entity.FileIconId);
|
||||
if (stream != null)
|
||||
profileConfiguration.Icon.SetIconByStream(stream);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SaveProfileConfigurationIcon(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
if (profileConfiguration.Icon.IconType == ProfileConfigurationIconType.MaterialIcon)
|
||||
return;
|
||||
|
||||
using Stream? stream = profileConfiguration.Icon.GetIconStream();
|
||||
if (stream != null)
|
||||
_profileCategoryRepository.SaveProfileIconStream(profileConfiguration.Entity, stream);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ProfileConfiguration CloneProfileConfiguration(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
return new ProfileConfiguration(profileConfiguration.Category, profileConfiguration.Entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Profile ActivateProfile(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
if (profileConfiguration.Profile != null)
|
||||
{
|
||||
profileConfiguration.Profile.ShouldDisplay = true;
|
||||
return profileConfiguration.Profile;
|
||||
}
|
||||
|
||||
ProfileEntity profileEntity;
|
||||
try
|
||||
{
|
||||
profileEntity = _profileRepository.Get(profileConfiguration.Entity.ProfileId);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
profileConfiguration.SetBrokenState("Failed to activate profile", e);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (profileEntity == null)
|
||||
throw new ArtemisCoreException($"Cannot find profile named: {profileConfiguration.Name} ID: {profileConfiguration.Entity.ProfileId}");
|
||||
|
||||
Profile profile = new(profileConfiguration, profileEntity);
|
||||
profile.PopulateLeds(_rgbService.EnabledDevices);
|
||||
|
||||
if (profile.IsFreshImport)
|
||||
{
|
||||
_logger.Debug("Profile is a fresh import, adapting to surface - {profile}", profile);
|
||||
AdaptProfile(profile);
|
||||
}
|
||||
|
||||
profileConfiguration.Profile = profile;
|
||||
|
||||
OnProfileActivated(new ProfileConfigurationEventArgs(profileConfiguration));
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void DeactivateProfile(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
if (FocusProfile == profileConfiguration)
|
||||
throw new ArtemisCoreException("Cannot disable a profile that is being edited, that's rude");
|
||||
if (profileConfiguration.Profile == null)
|
||||
return;
|
||||
|
||||
Profile profile = profileConfiguration.Profile;
|
||||
profileConfiguration.Profile = null;
|
||||
profile.Dispose();
|
||||
|
||||
OnProfileDeactivated(new ProfileConfigurationEventArgs(profileConfiguration));
|
||||
}
|
||||
|
||||
private void RequestDeactivation(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
if (FocusProfile == profileConfiguration)
|
||||
throw new ArtemisCoreException("Cannot disable a profile that is being edited, that's rude");
|
||||
if (profileConfiguration.Profile == null)
|
||||
return;
|
||||
|
||||
profileConfiguration.Profile.ShouldDisplay = false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void DeleteProfile(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
DeactivateProfile(profileConfiguration);
|
||||
|
||||
ProfileEntity profileEntity = _profileRepository.Get(profileConfiguration.Entity.ProfileId);
|
||||
if (profileEntity == null)
|
||||
return;
|
||||
|
||||
profileConfiguration.Category.RemoveProfileConfiguration(profileConfiguration);
|
||||
_profileRepository.Remove(profileEntity);
|
||||
SaveProfileCategory(profileConfiguration.Category);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ProfileCategory CreateProfileCategory(string name, bool addToTop = false)
|
||||
{
|
||||
ProfileCategory profileCategory;
|
||||
lock (_profileRepository)
|
||||
{
|
||||
if (addToTop)
|
||||
{
|
||||
profileCategory = new ProfileCategory(name, 1);
|
||||
foreach (ProfileCategory category in _profileCategories)
|
||||
{
|
||||
category.Order++;
|
||||
category.Save();
|
||||
_profileCategoryRepository.Save(category.Entity);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
profileCategory = new ProfileCategory(name, _profileCategories.Count + 1);
|
||||
}
|
||||
|
||||
_profileCategories.Add(profileCategory);
|
||||
SaveProfileCategory(profileCategory);
|
||||
}
|
||||
|
||||
OnProfileCategoryAdded(new ProfileCategoryEventArgs(profileCategory));
|
||||
return profileCategory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void DeleteProfileCategory(ProfileCategory profileCategory)
|
||||
{
|
||||
List<ProfileConfiguration> profileConfigurations = profileCategory.ProfileConfigurations.ToList();
|
||||
foreach (ProfileConfiguration profileConfiguration in profileConfigurations)
|
||||
RemoveProfileConfiguration(profileConfiguration);
|
||||
|
||||
lock (_profileRepository)
|
||||
{
|
||||
_profileCategories.Remove(profileCategory);
|
||||
_profileCategoryRepository.Remove(profileCategory.Entity);
|
||||
}
|
||||
|
||||
OnProfileCategoryRemoved(new ProfileCategoryEventArgs(profileCategory));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ProfileConfiguration CreateProfileConfiguration(ProfileCategory category, string name, string icon)
|
||||
{
|
||||
ProfileConfiguration configuration = new(category, name, icon);
|
||||
ProfileEntity entity = new();
|
||||
_profileRepository.Add(entity);
|
||||
|
||||
configuration.Entity.ProfileId = entity.Id;
|
||||
category.AddProfileConfiguration(configuration, 0);
|
||||
return configuration;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RemoveProfileConfiguration(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
profileConfiguration.Category.RemoveProfileConfiguration(profileConfiguration);
|
||||
|
||||
DeactivateProfile(profileConfiguration);
|
||||
SaveProfileCategory(profileConfiguration.Category);
|
||||
ProfileEntity profileEntity = _profileRepository.Get(profileConfiguration.Entity.ProfileId);
|
||||
if (profileEntity != null)
|
||||
_profileRepository.Remove(profileEntity);
|
||||
|
||||
profileConfiguration.Dispose();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SaveProfileCategory(ProfileCategory profileCategory)
|
||||
{
|
||||
profileCategory.Save();
|
||||
_profileCategoryRepository.Save(profileCategory.Entity);
|
||||
|
||||
lock (_profileCategories)
|
||||
{
|
||||
_profileCategories.Sort((a, b) => a.Order - b.Order);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SaveProfile(Profile profile, bool includeChildren)
|
||||
{
|
||||
_logger.Debug("Updating profile - Saving {Profile}", profile);
|
||||
profile.Save();
|
||||
if (includeChildren)
|
||||
{
|
||||
foreach (RenderProfileElement child in profile.GetAllRenderElements())
|
||||
child.Save();
|
||||
}
|
||||
|
||||
// At this point the user made actual changes, save that
|
||||
profile.IsFreshImport = false;
|
||||
profile.ProfileEntity.IsFreshImport = false;
|
||||
|
||||
_profileRepository.Save(profile.ProfileEntity);
|
||||
|
||||
// If the provided profile is external (cloned or from the workshop?) but it is loaded locally too, reload the local instance
|
||||
// A bit dodge but it ensures local instances always represent the latest stored version
|
||||
ProfileConfiguration? localInstance = ProfileConfigurations.FirstOrDefault(p => p.Profile != null && p.Profile != profile && p.ProfileId == profile.ProfileEntity.Id);
|
||||
if (localInstance == null)
|
||||
return;
|
||||
DeactivateProfile(localInstance);
|
||||
ActivateProfile(localInstance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> ExportProfile(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
ProfileEntity? profileEntity = _profileRepository.Get(profileConfiguration.Entity.ProfileId);
|
||||
if (profileEntity == null)
|
||||
throw new ArtemisCoreException("Could not locate profile entity");
|
||||
|
||||
string configurationJson = JsonConvert.SerializeObject(profileConfiguration.Entity, IProfileService.ExportSettings);
|
||||
string profileJson = JsonConvert.SerializeObject(profileEntity, IProfileService.ExportSettings);
|
||||
|
||||
MemoryStream archiveStream = new();
|
||||
|
||||
// Create a ZIP archive
|
||||
using (ZipArchive archive = new(archiveStream, ZipArchiveMode.Create, true))
|
||||
{
|
||||
ZipArchiveEntry configurationEntry = archive.CreateEntry("configuration.json");
|
||||
await using (Stream entryStream = configurationEntry.Open())
|
||||
{
|
||||
await entryStream.WriteAsync(Encoding.Default.GetBytes(configurationJson));
|
||||
}
|
||||
|
||||
ZipArchiveEntry profileEntry = archive.CreateEntry("profile.json");
|
||||
await using (Stream entryStream = profileEntry.Open())
|
||||
{
|
||||
await entryStream.WriteAsync(Encoding.Default.GetBytes(profileJson));
|
||||
}
|
||||
|
||||
await using Stream? iconStream = profileConfiguration.Icon.GetIconStream();
|
||||
if (iconStream != null)
|
||||
{
|
||||
ZipArchiveEntry iconEntry = archive.CreateEntry("icon.png");
|
||||
await using Stream entryStream = iconEntry.Open();
|
||||
await iconStream.CopyToAsync(entryStream);
|
||||
}
|
||||
}
|
||||
|
||||
archiveStream.Seek(0, SeekOrigin.Begin);
|
||||
return archiveStream;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProfileConfiguration> ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix, int targetIndex = 0)
|
||||
{
|
||||
using ZipArchive archive = new(archiveStream, ZipArchiveMode.Read, true);
|
||||
|
||||
// There should be a configuration.json and profile.json
|
||||
ZipArchiveEntry? configurationEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith("configuration.json"));
|
||||
ZipArchiveEntry? profileEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith("profile.json"));
|
||||
ZipArchiveEntry? iconEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith("icon.png"));
|
||||
|
||||
if (configurationEntry == null)
|
||||
throw new ArtemisCoreException("Could not import profile, configuration.json missing");
|
||||
if (profileEntry == null)
|
||||
throw new ArtemisCoreException("Could not import profile, profile.json missing");
|
||||
|
||||
await using Stream configurationStream = configurationEntry.Open();
|
||||
using StreamReader configurationReader = new(configurationStream);
|
||||
ProfileConfigurationEntity? configurationEntity = JsonConvert.DeserializeObject<ProfileConfigurationEntity>(await configurationReader.ReadToEndAsync(), IProfileService.ExportSettings);
|
||||
if (configurationEntity == null)
|
||||
throw new ArtemisCoreException("Could not import profile, failed to deserialize configuration.json");
|
||||
|
||||
await using Stream profileStream = profileEntry.Open();
|
||||
using StreamReader profileReader = new(profileStream);
|
||||
ProfileEntity? profileEntity = JsonConvert.DeserializeObject<ProfileEntity>(await profileReader.ReadToEndAsync(), IProfileService.ExportSettings);
|
||||
if (profileEntity == null)
|
||||
throw new ArtemisCoreException("Could not import profile, failed to deserialize profile.json");
|
||||
|
||||
// Assign a new GUID to make sure it is unique in case of a previous import of the same content
|
||||
if (makeUnique)
|
||||
profileEntity.UpdateGuid(Guid.NewGuid());
|
||||
else
|
||||
{
|
||||
// If the profile already exists and this one is not to be made unique, return the existing profile
|
||||
ProfileConfiguration? existing = ProfileCategories.SelectMany(c => c.ProfileConfigurations).FirstOrDefault(p => p.ProfileId == profileEntity.Id);
|
||||
if (existing != null)
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (nameAffix != null)
|
||||
profileEntity.Name = $"{profileEntity.Name} - {nameAffix}";
|
||||
if (markAsFreshImport)
|
||||
profileEntity.IsFreshImport = true;
|
||||
|
||||
if (_profileRepository.Get(profileEntity.Id) == null)
|
||||
_profileRepository.Add(profileEntity);
|
||||
else
|
||||
throw new ArtemisCoreException($"Cannot import this profile without {nameof(makeUnique)} being true");
|
||||
|
||||
// A new GUID will be given on save
|
||||
configurationEntity.FileIconId = Guid.Empty;
|
||||
ProfileConfiguration profileConfiguration = new(category, configurationEntity);
|
||||
if (nameAffix != null)
|
||||
profileConfiguration.Name = $"{profileConfiguration.Name} - {nameAffix}";
|
||||
|
||||
// If an icon was provided, import that as well
|
||||
if (iconEntry != null)
|
||||
{
|
||||
await using Stream iconStream = iconEntry.Open();
|
||||
profileConfiguration.Icon.SetIconByStream(iconStream);
|
||||
SaveProfileConfigurationIcon(profileConfiguration);
|
||||
}
|
||||
|
||||
profileConfiguration.Entity.ProfileId = profileEntity.Id;
|
||||
category.AddProfileConfiguration(profileConfiguration, targetIndex);
|
||||
|
||||
List<Module> modules = _pluginManagementService.GetFeaturesOfType<Module>();
|
||||
profileConfiguration.LoadModules(modules);
|
||||
SaveProfileCategory(category);
|
||||
|
||||
return profileConfiguration;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProfileConfiguration> OverwriteProfile(MemoryStream archiveStream, ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
ProfileConfiguration imported = await ImportProfile(archiveStream, profileConfiguration.Category, true, true, null, profileConfiguration.Order + 1);
|
||||
|
||||
DeleteProfile(profileConfiguration);
|
||||
SaveProfileCategory(imported.Category);
|
||||
|
||||
return imported;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AdaptProfile(Profile profile)
|
||||
{
|
||||
List<ArtemisDevice> devices = _rgbService.EnabledDevices.ToList();
|
||||
foreach (Layer layer in profile.GetAllLayers())
|
||||
layer.Adapter.Adapt(devices);
|
||||
|
||||
profile.Save();
|
||||
|
||||
foreach (RenderProfileElement renderProfileElement in profile.GetAllRenderElements())
|
||||
renderProfileElement.Save();
|
||||
|
||||
_logger.Debug("Adapt profile - Saving " + profile);
|
||||
_profileRepository.Save(profile.ProfileEntity);
|
||||
}
|
||||
|
||||
private void InputServiceOnKeyboardKeyUp(object? sender, ArtemisKeyboardKeyEventArgs e)
|
||||
{
|
||||
lock (_profileCategories)
|
||||
{
|
||||
_pendingKeyboardEvents.Add(e);
|
||||
@ -129,7 +605,7 @@ internal class ProfileService : IProfileService
|
||||
profileConfiguration.IsSuspended = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If suspension was changed, save the category
|
||||
if (before != profileConfiguration.IsSuspended)
|
||||
SaveProfileCategory(profileConfiguration.Category);
|
||||
@ -185,432 +661,6 @@ internal class ProfileService : IProfileService
|
||||
_renderExceptions.Clear();
|
||||
}
|
||||
|
||||
public bool HotkeysEnabled { get; set; }
|
||||
public bool RenderForEditor { get; set; }
|
||||
public ProfileElement? EditorFocus { get; set; }
|
||||
|
||||
public void UpdateProfiles(double deltaTime)
|
||||
{
|
||||
lock (_profileCategories)
|
||||
{
|
||||
// Iterate the children in reverse because the first category must be rendered last to end up on top
|
||||
for (int i = _profileCategories.Count - 1; i > -1; i--)
|
||||
{
|
||||
ProfileCategory profileCategory = _profileCategories[i];
|
||||
for (int j = profileCategory.ProfileConfigurations.Count - 1; j > -1; j--)
|
||||
{
|
||||
ProfileConfiguration profileConfiguration = profileCategory.ProfileConfigurations[j];
|
||||
|
||||
// Process hotkeys that where pressed since this profile last updated
|
||||
ProcessPendingKeyEvents(profileConfiguration);
|
||||
|
||||
// Profiles being edited are updated at their own leisure
|
||||
if (profileConfiguration.IsBeingEdited && RenderForEditor)
|
||||
continue;
|
||||
|
||||
bool shouldBeActive = profileConfiguration.ShouldBeActive(false);
|
||||
if (shouldBeActive)
|
||||
{
|
||||
profileConfiguration.Update();
|
||||
if (!profileConfiguration.IsBeingEdited)
|
||||
shouldBeActive = profileConfiguration.ActivationConditionMet;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Make sure the profile is active or inactive according to the parameters above
|
||||
if (shouldBeActive && profileConfiguration.Profile == null && profileConfiguration.BrokenState != "Failed to activate profile")
|
||||
profileConfiguration.TryOrBreak(() => ActivateProfile(profileConfiguration), "Failed to activate profile");
|
||||
if (shouldBeActive && profileConfiguration.Profile != null && !profileConfiguration.Profile.ShouldDisplay)
|
||||
profileConfiguration.Profile.ShouldDisplay = true;
|
||||
else if (!shouldBeActive && profileConfiguration.Profile != null)
|
||||
{
|
||||
if (!profileConfiguration.FadeInAndOut)
|
||||
DeactivateProfile(profileConfiguration);
|
||||
else if (!profileConfiguration.Profile.ShouldDisplay && profileConfiguration.Profile.Opacity <= 0)
|
||||
DeactivateProfile(profileConfiguration);
|
||||
else if (profileConfiguration.Profile.Opacity > 0)
|
||||
RequestDeactivation(profileConfiguration);
|
||||
}
|
||||
|
||||
profileConfiguration.Profile?.Update(deltaTime);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_updateExceptions.Add(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LogProfileUpdateExceptions();
|
||||
_pendingKeyboardEvents.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public void RenderProfiles(SKCanvas canvas)
|
||||
{
|
||||
lock (_profileCategories)
|
||||
{
|
||||
ProfileConfiguration? editedProfileConfiguration = _profileCategories.SelectMany(c => c.ProfileConfigurations).FirstOrDefault(p => p.IsBeingEdited);
|
||||
if (editedProfileConfiguration != null)
|
||||
{
|
||||
editedProfileConfiguration.Profile?.Render(canvas, SKPointI.Empty, RenderForEditor ? EditorFocus : null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (RenderForEditor)
|
||||
return;
|
||||
|
||||
// Iterate the children in reverse because the first category must be rendered last to end up on top
|
||||
for (int i = _profileCategories.Count - 1; i > -1; i--)
|
||||
{
|
||||
ProfileCategory profileCategory = _profileCategories[i];
|
||||
for (int j = profileCategory.ProfileConfigurations.Count - 1; j > -1; j--)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProfileConfiguration profileConfiguration = profileCategory.ProfileConfigurations[j];
|
||||
// Ensure all criteria are met before rendering
|
||||
bool fadingOut = profileConfiguration.Profile?.ShouldDisplay == false && profileConfiguration.Profile?.Opacity > 0;
|
||||
if (!profileConfiguration.IsSuspended && !profileConfiguration.IsMissingModule && (profileConfiguration.ActivationConditionMet || fadingOut))
|
||||
profileConfiguration.Profile?.Render(canvas, SKPointI.Empty, null);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_renderExceptions.Add(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LogProfileRenderExceptions();
|
||||
}
|
||||
}
|
||||
|
||||
public ReadOnlyCollection<ProfileCategory> ProfileCategories
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_profileRepository)
|
||||
{
|
||||
return _profileCategories.AsReadOnly();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ReadOnlyCollection<ProfileConfiguration> ProfileConfigurations
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_profileRepository)
|
||||
{
|
||||
return _profileCategories.SelectMany(c => c.ProfileConfigurations).ToList().AsReadOnly();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void LoadProfileConfigurationIcon(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
if (profileConfiguration.Icon.IconType == ProfileConfigurationIconType.MaterialIcon)
|
||||
return;
|
||||
|
||||
using Stream? stream = _profileCategoryRepository.GetProfileIconStream(profileConfiguration.Entity.FileIconId);
|
||||
if (stream != null)
|
||||
profileConfiguration.Icon.SetIconByStream(stream);
|
||||
}
|
||||
|
||||
public void SaveProfileConfigurationIcon(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
if (profileConfiguration.Icon.IconType == ProfileConfigurationIconType.MaterialIcon)
|
||||
return;
|
||||
|
||||
using Stream? stream = profileConfiguration.Icon.GetIconStream();
|
||||
if (stream != null)
|
||||
{
|
||||
_profileCategoryRepository.SaveProfileIconStream(profileConfiguration.Entity, stream);
|
||||
}
|
||||
}
|
||||
|
||||
public Profile ActivateProfile(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
if (profileConfiguration.Profile != null)
|
||||
{
|
||||
profileConfiguration.Profile.ShouldDisplay = true;
|
||||
return profileConfiguration.Profile;
|
||||
}
|
||||
|
||||
ProfileEntity profileEntity;
|
||||
try
|
||||
{
|
||||
profileEntity = _profileRepository.Get(profileConfiguration.Entity.ProfileId);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
profileConfiguration.SetBrokenState("Failed to activate profile", e);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (profileEntity == null)
|
||||
throw new ArtemisCoreException($"Cannot find profile named: {profileConfiguration.Name} ID: {profileConfiguration.Entity.ProfileId}");
|
||||
|
||||
Profile profile = new(profileConfiguration, profileEntity);
|
||||
profile.PopulateLeds(_rgbService.EnabledDevices);
|
||||
|
||||
if (profile.IsFreshImport)
|
||||
{
|
||||
_logger.Debug("Profile is a fresh import, adapting to surface - {profile}", profile);
|
||||
AdaptProfile(profile);
|
||||
}
|
||||
|
||||
profileConfiguration.Profile = profile;
|
||||
|
||||
OnProfileActivated(new ProfileConfigurationEventArgs(profileConfiguration));
|
||||
return profile;
|
||||
}
|
||||
|
||||
public void DeactivateProfile(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
if (profileConfiguration.IsBeingEdited)
|
||||
throw new ArtemisCoreException("Cannot disable a profile that is being edited, that's rude");
|
||||
if (profileConfiguration.Profile == null)
|
||||
return;
|
||||
|
||||
Profile profile = profileConfiguration.Profile;
|
||||
profileConfiguration.Profile = null;
|
||||
profile.Dispose();
|
||||
|
||||
OnProfileDeactivated(new ProfileConfigurationEventArgs(profileConfiguration));
|
||||
}
|
||||
|
||||
private void RequestDeactivation(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
if (profileConfiguration.IsBeingEdited)
|
||||
throw new ArtemisCoreException("Cannot disable a profile that is being edited, that's rude");
|
||||
if (profileConfiguration.Profile == null)
|
||||
return;
|
||||
|
||||
profileConfiguration.Profile.ShouldDisplay = false;
|
||||
}
|
||||
|
||||
public void DeleteProfile(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
DeactivateProfile(profileConfiguration);
|
||||
|
||||
ProfileEntity profileEntity = _profileRepository.Get(profileConfiguration.Entity.ProfileId);
|
||||
if (profileEntity == null)
|
||||
return;
|
||||
|
||||
profileConfiguration.Category.RemoveProfileConfiguration(profileConfiguration);
|
||||
_profileRepository.Remove(profileEntity);
|
||||
SaveProfileCategory(profileConfiguration.Category);
|
||||
}
|
||||
|
||||
public ProfileCategory CreateProfileCategory(string name)
|
||||
{
|
||||
ProfileCategory profileCategory;
|
||||
lock (_profileRepository)
|
||||
{
|
||||
profileCategory = new ProfileCategory(name, _profileCategories.Count + 1);
|
||||
_profileCategories.Add(profileCategory);
|
||||
SaveProfileCategory(profileCategory);
|
||||
}
|
||||
|
||||
OnProfileCategoryAdded(new ProfileCategoryEventArgs(profileCategory));
|
||||
return profileCategory;
|
||||
}
|
||||
|
||||
public void DeleteProfileCategory(ProfileCategory profileCategory)
|
||||
{
|
||||
List<ProfileConfiguration> profileConfigurations = profileCategory.ProfileConfigurations.ToList();
|
||||
foreach (ProfileConfiguration profileConfiguration in profileConfigurations)
|
||||
RemoveProfileConfiguration(profileConfiguration);
|
||||
|
||||
lock (_profileRepository)
|
||||
{
|
||||
_profileCategories.Remove(profileCategory);
|
||||
_profileCategoryRepository.Remove(profileCategory.Entity);
|
||||
}
|
||||
|
||||
OnProfileCategoryRemoved(new ProfileCategoryEventArgs(profileCategory));
|
||||
}
|
||||
|
||||
public ProfileConfiguration CreateProfileConfiguration(ProfileCategory category, string name, string icon)
|
||||
{
|
||||
ProfileConfiguration configuration = new(category, name, icon);
|
||||
ProfileEntity entity = new();
|
||||
_profileRepository.Add(entity);
|
||||
|
||||
configuration.Entity.ProfileId = entity.Id;
|
||||
category.AddProfileConfiguration(configuration, 0);
|
||||
return configuration;
|
||||
}
|
||||
|
||||
public void RemoveProfileConfiguration(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
profileConfiguration.Category.RemoveProfileConfiguration(profileConfiguration);
|
||||
|
||||
DeactivateProfile(profileConfiguration);
|
||||
SaveProfileCategory(profileConfiguration.Category);
|
||||
ProfileEntity profileEntity = _profileRepository.Get(profileConfiguration.Entity.ProfileId);
|
||||
if (profileEntity != null)
|
||||
_profileRepository.Remove(profileEntity);
|
||||
|
||||
profileConfiguration.Dispose();
|
||||
}
|
||||
|
||||
public void SaveProfileCategory(ProfileCategory profileCategory)
|
||||
{
|
||||
profileCategory.Save();
|
||||
_profileCategoryRepository.Save(profileCategory.Entity);
|
||||
|
||||
lock (_profileCategories)
|
||||
{
|
||||
_profileCategories.Sort((a, b) => a.Order - b.Order);
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveProfile(Profile profile, bool includeChildren)
|
||||
{
|
||||
_logger.Debug("Updating profile - Saving {Profile}", profile);
|
||||
profile.Save();
|
||||
if (includeChildren)
|
||||
foreach (RenderProfileElement child in profile.GetAllRenderElements())
|
||||
child.Save();
|
||||
|
||||
// At this point the user made actual changes, save that
|
||||
profile.IsFreshImport = false;
|
||||
profile.ProfileEntity.IsFreshImport = false;
|
||||
|
||||
_profileRepository.Save(profile.ProfileEntity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> ExportProfile(ProfileConfiguration profileConfiguration)
|
||||
{
|
||||
ProfileEntity? profileEntity = _profileRepository.Get(profileConfiguration.Entity.ProfileId);
|
||||
if (profileEntity == null)
|
||||
throw new ArtemisCoreException("Could not locate profile entity");
|
||||
|
||||
string configurationJson = JsonConvert.SerializeObject(profileConfiguration.Entity, IProfileService.ExportSettings);
|
||||
string profileJson = JsonConvert.SerializeObject(profileEntity, IProfileService.ExportSettings);
|
||||
|
||||
MemoryStream archiveStream = new();
|
||||
|
||||
// Create a ZIP archive
|
||||
using (ZipArchive archive = new(archiveStream, ZipArchiveMode.Create, true))
|
||||
{
|
||||
ZipArchiveEntry configurationEntry = archive.CreateEntry("configuration.json");
|
||||
await using (Stream entryStream = configurationEntry.Open())
|
||||
{
|
||||
await entryStream.WriteAsync(Encoding.Default.GetBytes(configurationJson));
|
||||
}
|
||||
|
||||
ZipArchiveEntry profileEntry = archive.CreateEntry("profile.json");
|
||||
await using (Stream entryStream = profileEntry.Open())
|
||||
{
|
||||
await entryStream.WriteAsync(Encoding.Default.GetBytes(profileJson));
|
||||
}
|
||||
|
||||
await using Stream? iconStream = profileConfiguration.Icon.GetIconStream();
|
||||
if (iconStream != null)
|
||||
{
|
||||
ZipArchiveEntry iconEntry = archive.CreateEntry("icon.png");
|
||||
await using Stream entryStream = iconEntry.Open();
|
||||
await iconStream.CopyToAsync(entryStream);
|
||||
}
|
||||
}
|
||||
|
||||
archiveStream.Seek(0, SeekOrigin.Begin);
|
||||
return archiveStream;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProfileConfiguration> ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix)
|
||||
{
|
||||
using ZipArchive archive = new(archiveStream, ZipArchiveMode.Read, true);
|
||||
|
||||
// There should be a configuration.json and profile.json
|
||||
ZipArchiveEntry? configurationEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith("configuration.json"));
|
||||
ZipArchiveEntry? profileEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith("profile.json"));
|
||||
ZipArchiveEntry? iconEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith("icon.png"));
|
||||
|
||||
if (configurationEntry == null)
|
||||
throw new ArtemisCoreException("Could not import profile, configuration.json missing");
|
||||
if (profileEntry == null)
|
||||
throw new ArtemisCoreException("Could not import profile, profile.json missing");
|
||||
|
||||
await using Stream configurationStream = configurationEntry.Open();
|
||||
using StreamReader configurationReader = new(configurationStream);
|
||||
ProfileConfigurationEntity? configurationEntity = JsonConvert.DeserializeObject<ProfileConfigurationEntity>(await configurationReader.ReadToEndAsync(), IProfileService.ExportSettings);
|
||||
if (configurationEntity == null)
|
||||
throw new ArtemisCoreException("Could not import profile, failed to deserialize configuration.json");
|
||||
|
||||
await using Stream profileStream = profileEntry.Open();
|
||||
using StreamReader profileReader = new(profileStream);
|
||||
ProfileEntity? profileEntity = JsonConvert.DeserializeObject<ProfileEntity>(await profileReader.ReadToEndAsync(), IProfileService.ExportSettings);
|
||||
if (profileEntity == null)
|
||||
throw new ArtemisCoreException("Could not import profile, failed to deserialize profile.json");
|
||||
|
||||
// Assign a new GUID to make sure it is unique in case of a previous import of the same content
|
||||
if (makeUnique)
|
||||
profileEntity.UpdateGuid(Guid.NewGuid());
|
||||
else
|
||||
{
|
||||
// If the profile already exists and this one is not to be made unique, return the existing profile
|
||||
ProfileConfiguration? existing = ProfileCategories.SelectMany(c => c.ProfileConfigurations).FirstOrDefault(p => p.ProfileId == profileEntity.Id);
|
||||
if (existing != null)
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (nameAffix != null)
|
||||
profileEntity.Name = $"{profileEntity.Name} - {nameAffix}";
|
||||
if (markAsFreshImport)
|
||||
profileEntity.IsFreshImport = true;
|
||||
|
||||
if (_profileRepository.Get(profileEntity.Id) == null)
|
||||
_profileRepository.Add(profileEntity);
|
||||
else
|
||||
throw new ArtemisCoreException($"Cannot import this profile without {nameof(makeUnique)} being true");
|
||||
|
||||
// A new GUID will be given on save
|
||||
configurationEntity.FileIconId = Guid.Empty;
|
||||
ProfileConfiguration profileConfiguration = new(category, configurationEntity);
|
||||
if (nameAffix != null)
|
||||
profileConfiguration.Name = $"{profileConfiguration.Name} - {nameAffix}";
|
||||
|
||||
// If an icon was provided, import that as well
|
||||
if (iconEntry != null)
|
||||
{
|
||||
await using Stream iconStream = iconEntry.Open();
|
||||
profileConfiguration.Icon.SetIconByStream(iconStream);
|
||||
SaveProfileConfigurationIcon(profileConfiguration);
|
||||
}
|
||||
|
||||
profileConfiguration.Entity.ProfileId = profileEntity.Id;
|
||||
category.AddProfileConfiguration(profileConfiguration, 0);
|
||||
|
||||
List<Module> modules = _pluginManagementService.GetFeaturesOfType<Module>();
|
||||
profileConfiguration.LoadModules(modules);
|
||||
SaveProfileCategory(category);
|
||||
|
||||
return profileConfiguration;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AdaptProfile(Profile profile)
|
||||
{
|
||||
List<ArtemisDevice> devices = _rgbService.EnabledDevices.ToList();
|
||||
foreach (Layer layer in profile.GetAllLayers())
|
||||
layer.Adapter.Adapt(devices);
|
||||
|
||||
profile.Save();
|
||||
|
||||
foreach (RenderProfileElement renderProfileElement in profile.GetAllRenderElements())
|
||||
renderProfileElement.Save();
|
||||
|
||||
_logger.Debug("Adapt profile - Saving " + profile);
|
||||
_profileRepository.Save(profile.ProfileEntity);
|
||||
}
|
||||
|
||||
#region Events
|
||||
|
||||
public event EventHandler<ProfileConfigurationEventArgs>? ProfileActivated;
|
||||
|
||||
@ -18,7 +18,6 @@ public class ProfileEntity
|
||||
|
||||
public string Name { get; set; }
|
||||
public bool IsFreshImport { get; set; }
|
||||
public Guid LastSelectedProfileElement { get; set; }
|
||||
|
||||
public List<FolderEntity> Folders { get; set; }
|
||||
public List<LayerEntity> Layers { get; set; }
|
||||
|
||||
21
src/Artemis.Storage/Entities/Workshop/EntryEntity.cs
Normal file
21
src/Artemis.Storage/Entities/Workshop/EntryEntity.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using System;
|
||||
|
||||
namespace Artemis.Storage.Entities.Workshop;
|
||||
|
||||
public class EntryEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public long EntryId { get; set; }
|
||||
public int EntryType { get; set; }
|
||||
|
||||
public string Author { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
public long ReleaseId { get; set; }
|
||||
public string ReleaseVersion { get; set; }
|
||||
public DateTimeOffset InstalledAt { get; set; }
|
||||
|
||||
public string LocalReference { get; set; }
|
||||
}
|
||||
54
src/Artemis.Storage/Repositories/EntryRepository.cs
Normal file
54
src/Artemis.Storage/Repositories/EntryRepository.cs
Normal file
@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Artemis.Storage.Entities.Workshop;
|
||||
using Artemis.Storage.Repositories.Interfaces;
|
||||
using LiteDB;
|
||||
|
||||
namespace Artemis.Storage.Repositories;
|
||||
|
||||
internal class EntryRepository : IEntryRepository
|
||||
{
|
||||
private readonly LiteRepository _repository;
|
||||
|
||||
public EntryRepository(LiteRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
_repository.Database.GetCollection<EntryEntity>().EnsureIndex(s => s.Id);
|
||||
_repository.Database.GetCollection<EntryEntity>().EnsureIndex(s => s.EntryId);
|
||||
}
|
||||
|
||||
public void Add(EntryEntity entryEntity)
|
||||
{
|
||||
_repository.Insert(entryEntity);
|
||||
}
|
||||
|
||||
public void Remove(EntryEntity entryEntity)
|
||||
{
|
||||
_repository.Delete<EntryEntity>(entryEntity.Id);
|
||||
}
|
||||
|
||||
public EntryEntity Get(Guid id)
|
||||
{
|
||||
return _repository.FirstOrDefault<EntryEntity>(s => s.Id == id);
|
||||
}
|
||||
|
||||
public EntryEntity GetByEntryId(long entryId)
|
||||
{
|
||||
return _repository.FirstOrDefault<EntryEntity>(s => s.EntryId == entryId);
|
||||
}
|
||||
|
||||
public List<EntryEntity> GetAll()
|
||||
{
|
||||
return _repository.Query<EntryEntity>().ToList();
|
||||
}
|
||||
|
||||
public void Save(EntryEntity entryEntity)
|
||||
{
|
||||
_repository.Upsert(entryEntity);
|
||||
}
|
||||
|
||||
public void Save(IEnumerable<EntryEntity> entryEntities)
|
||||
{
|
||||
_repository.Upsert(entryEntities);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Artemis.Storage.Entities.Workshop;
|
||||
|
||||
namespace Artemis.Storage.Repositories.Interfaces;
|
||||
|
||||
public interface IEntryRepository : IRepository
|
||||
{
|
||||
void Add(EntryEntity entryEntity);
|
||||
void Remove(EntryEntity entryEntity);
|
||||
EntryEntity Get(Guid id);
|
||||
EntryEntity GetByEntryId(long entryId);
|
||||
List<EntryEntity> GetAll();
|
||||
void Save(EntryEntity entryEntity);
|
||||
void Save(IEnumerable<EntryEntity> entryEntities);
|
||||
}
|
||||
@ -18,7 +18,7 @@
|
||||
<TrayIcon.Menu>
|
||||
<NativeMenu>
|
||||
<NativeMenuItem Header="Home" Command="{CompiledBinding OpenScreen}" CommandParameter="home" />
|
||||
<!-- <NativeMenuItem Header="Workshop" Command="{CompiledBinding OpenScreen}" CommandParameter="workshop" /> -->
|
||||
<NativeMenuItem Header="Workshop" Command="{CompiledBinding OpenScreen}" CommandParameter="workshop" />
|
||||
<NativeMenuItem Header="Surface Editor" Command="{CompiledBinding OpenScreen}" CommandParameter="surface-editor" />
|
||||
<NativeMenuItem Header="Settings" Command="{CompiledBinding OpenScreen}" CommandParameter="settings" />
|
||||
<NativeMenuItemSeparator />
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
<TrayIcon.Menu>
|
||||
<NativeMenu>
|
||||
<NativeMenuItem Header="Home" Command="{CompiledBinding OpenScreen}" CommandParameter="home" />
|
||||
<!-- <NativeMenuItem Header="Workshop" Command="{CompiledBinding OpenScreen}" CommandParameter="workshop" /> -->
|
||||
<NativeMenuItem Header="Workshop" Command="{CompiledBinding OpenScreen}" CommandParameter="workshop" />
|
||||
<NativeMenuItem Header="Surface Editor" Command="{CompiledBinding OpenScreen}" CommandParameter="surface-editor" />
|
||||
<NativeMenuItem Header="Settings" Command="{CompiledBinding OpenScreen}" CommandParameter="settings" />
|
||||
<NativeMenuItemSeparator />
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
<PackageReference Include="Avalonia" Version="$(AvaloniaVersion)" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="$(AvaloniaVersion)" />
|
||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="$(AvaloniaVersion)" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="$(AvaloniaVersion)" />
|
||||
<PackageReference Include="Avalonia.Xaml.Behaviors" Version="$(AvaloniaBehavioursVersion)" />
|
||||
<PackageReference Include="DynamicData" Version="7.13.1" />
|
||||
|
||||
54
src/Artemis.UI.Shared/Controls/NotificationHost.cs
Normal file
54
src/Artemis.UI.Shared/Controls/NotificationHost.cs
Normal file
@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Layout;
|
||||
|
||||
namespace Artemis.UI.Shared;
|
||||
|
||||
internal class NotificationHost : ContentControl
|
||||
{
|
||||
private IDisposable? _rootBoundsWatcher;
|
||||
|
||||
public NotificationHost()
|
||||
{
|
||||
Background = null;
|
||||
HorizontalAlignment = HorizontalAlignment.Center;
|
||||
VerticalAlignment = VerticalAlignment.Center;
|
||||
}
|
||||
|
||||
protected override Type StyleKeyOverride => typeof(OverlayPopupHost);
|
||||
|
||||
protected override Size MeasureOverride(Size availableSize)
|
||||
{
|
||||
_ = base.MeasureOverride(availableSize);
|
||||
|
||||
if (VisualRoot is TopLevel tl)
|
||||
return tl.ClientSize;
|
||||
if (VisualRoot is Control c)
|
||||
return c.Bounds.Size;
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnAttachedToVisualTree(e);
|
||||
if (e.Root is Control wb)
|
||||
// OverlayLayer is a Canvas, so we won't get a signal to resize if the window
|
||||
// bounds change. Subscribe to force update
|
||||
_rootBoundsWatcher = wb.GetObservable(BoundsProperty).Subscribe(_ => OnRootBoundsChanged());
|
||||
}
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
_rootBoundsWatcher?.Dispose();
|
||||
_rootBoundsWatcher = null;
|
||||
}
|
||||
|
||||
private void OnRootBoundsChanged()
|
||||
{
|
||||
InvalidateMeasure();
|
||||
}
|
||||
}
|
||||
78
src/Artemis.UI.Shared/Controls/TagsInput/TagsInput.cs
Normal file
78
src/Artemis.UI.Shared/Controls/TagsInput/TagsInput.cs
Normal file
@ -0,0 +1,78 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows.Input;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Metadata;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace Artemis.UI.Shared.TagsInput;
|
||||
|
||||
[TemplatePart("PART_TagInputBox", typeof(TextBox))]
|
||||
public partial class TagsInput : TemplatedControl
|
||||
{
|
||||
public TextBox? TagInputBox { get; set; }
|
||||
public ICommand RemoveTag { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public TagsInput()
|
||||
{
|
||||
RemoveTag = ReactiveCommand.Create<string>(ExecuteRemoveTag);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||
{
|
||||
if (TagInputBox != null)
|
||||
{
|
||||
TagInputBox.KeyDown -= TagInputBoxOnKeyDown;
|
||||
TagInputBox.TextChanging -= TagInputBoxOnTextChanging;
|
||||
}
|
||||
|
||||
TagInputBox = e.NameScope.Find<TextBox>("PART_TagInputBox");
|
||||
|
||||
if (TagInputBox != null)
|
||||
{
|
||||
TagInputBox.KeyDown += TagInputBoxOnKeyDown;
|
||||
TagInputBox.TextChanging += TagInputBoxOnTextChanging;
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteRemoveTag(string t)
|
||||
{
|
||||
Tags.Remove(t);
|
||||
|
||||
if (TagInputBox != null)
|
||||
TagInputBox.IsEnabled = Tags.Count < MaxLength;
|
||||
}
|
||||
|
||||
private void TagInputBoxOnTextChanging(object? sender, TextChangingEventArgs e)
|
||||
{
|
||||
if (TagInputBox?.Text == null)
|
||||
return;
|
||||
|
||||
TagInputBox.Text = CleanTagRegex().Replace(TagInputBox.Text.ToLower(), "");
|
||||
}
|
||||
|
||||
private void TagInputBoxOnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (TagInputBox == null)
|
||||
return;
|
||||
|
||||
if (e.Key == Key.Space)
|
||||
e.Handled = true;
|
||||
if (e.Key != Key.Enter)
|
||||
return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(TagInputBox.Text) || Tags.Contains(TagInputBox.Text) || Tags.Count >= MaxLength)
|
||||
return;
|
||||
|
||||
Tags.Add(CleanTagRegex().Replace(TagInputBox.Text.ToLower(), ""));
|
||||
|
||||
TagInputBox.Text = "";
|
||||
TagInputBox.IsEnabled = Tags.Count < MaxLength;
|
||||
}
|
||||
|
||||
[GeneratedRegex("[\\s\\-]+")]
|
||||
private static partial Regex CleanTagRegex();
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Data;
|
||||
|
||||
namespace Artemis.UI.Shared.TagsInput;
|
||||
|
||||
public partial class TagsInput : TemplatedControl
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="Tags" /> property
|
||||
/// </summary>
|
||||
public static readonly StyledProperty<ObservableCollection<string>> TagsProperty =
|
||||
AvaloniaProperty.Register<TagsInput, ObservableCollection<string>>(nameof(Tags), new ObservableCollection<string>());
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the selected tags.
|
||||
/// </summary>
|
||||
public ObservableCollection<string> Tags
|
||||
{
|
||||
get => GetValue(TagsProperty);
|
||||
set => SetValue(TagsProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines the <see cref="MaxLength" /> property
|
||||
/// </summary>
|
||||
public static readonly StyledProperty<int> MaxLengthProperty =
|
||||
AvaloniaProperty.Register<TagsInput, int>(nameof(MaxLength), 20);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the max length of each tag
|
||||
/// </summary>
|
||||
public int MaxLength
|
||||
{
|
||||
get => GetValue(MaxLengthProperty);
|
||||
set => SetValue(MaxLengthProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines the <see cref="MaxTags" /> property
|
||||
/// </summary>
|
||||
public static readonly StyledProperty<int> MaxTagsProperty =
|
||||
AvaloniaProperty.Register<TagsInput, int>(nameof(MaxTags), 20);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the max amount of tags to be added
|
||||
/// </summary>
|
||||
public int MaxTags
|
||||
{
|
||||
get => GetValue(MaxTagsProperty);
|
||||
set => SetValue(MaxTagsProperty, value);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:tagsInput="clr-namespace:Artemis.UI.Shared.TagsInput"
|
||||
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
x:CompileBindings="True">
|
||||
<Design.PreviewWith>
|
||||
<Border Padding="30" Width="400">
|
||||
<StackPanel Spacing="20">
|
||||
<tagsInput:TagsInput Name="TagsInput"/>
|
||||
<ItemsControl ItemsSource="{CompiledBinding Path=Tags, ElementName=TagsInput}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Design.PreviewWith>
|
||||
|
||||
<ControlTheme x:Key="{x:Type tagsInput:TagsInput}" TargetType="tagsInput:TagsInput">
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<StackPanel>
|
||||
<TextBox Watermark="Enter tags" Name="PART_TagInputBox" MaxLines="1" MaxLength="{TemplateBinding MaxLength}">
|
||||
<TextBox.InnerLeftContent>
|
||||
<avalonia:MaterialIcon Kind="Tags" Margin="8 0 -2 0"></avalonia:MaterialIcon>
|
||||
</TextBox.InnerLeftContent>
|
||||
</TextBox>
|
||||
|
||||
<ItemsControl ItemsSource="{CompiledBinding Tags, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type tagsInput:TagsInput}}}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.DataTemplates>
|
||||
<DataTemplate DataType="x:String">
|
||||
<Button Margin="0 5 5 0"
|
||||
Command="{CompiledBinding RemoveTag, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type tagsInput:TagsInput}}}"
|
||||
CommandParameter="{CompiledBinding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<avalonia:MaterialIcon Kind="Close" Margin="-5 0 0 0" Foreground="Gray" />
|
||||
<TextBlock Text="{CompiledBinding}"></TextBlock>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.DataTemplates>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</ControlTheme>
|
||||
</ResourceDictionary>
|
||||
@ -3,12 +3,13 @@ using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Artemis.UI.Shared.Utilities;
|
||||
|
||||
namespace Artemis.UI.Extensions
|
||||
namespace Artemis.UI.Shared.Extensions
|
||||
{
|
||||
public static class HttpClientProgressExtensions
|
||||
{
|
||||
public static async Task DownloadDataAsync(this HttpClient client, string requestUrl, Stream destination, IProgress<float>? progress, CancellationToken cancellationToken)
|
||||
public static async Task DownloadDataAsync(this HttpClient client, string requestUrl, Stream destination, IProgress<StreamProgress>? progress, CancellationToken cancellationToken)
|
||||
{
|
||||
using HttpResponseMessage response = await client.GetAsync(requestUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@ -23,13 +24,10 @@ namespace Artemis.UI.Extensions
|
||||
}
|
||||
|
||||
// Such progress and contentLength much reporting Wow!
|
||||
Progress<long> progressWrapper = new(totalBytes => progress.Report(GetProgressPercentage(totalBytes, contentLength.Value)));
|
||||
await download.CopyToAsync(destination, 81920, progressWrapper, cancellationToken);
|
||||
|
||||
float GetProgressPercentage(float totalBytes, float currentBytes) => (totalBytes / currentBytes) * 100f;
|
||||
await download.CopyToAsync(destination, 81920, progress, contentLength, cancellationToken);
|
||||
}
|
||||
|
||||
static async Task CopyToAsync(this Stream source, Stream destination, int bufferSize, IProgress<long> progress, CancellationToken cancellationToken)
|
||||
static async Task CopyToAsync(this Stream source, Stream destination, int bufferSize, IProgress<StreamProgress> progress, long? contentLength, CancellationToken cancellationToken)
|
||||
{
|
||||
if (bufferSize < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(bufferSize));
|
||||
@ -49,7 +47,7 @@ namespace Artemis.UI.Extensions
|
||||
{
|
||||
await destination.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false);
|
||||
totalBytesRead += bytesRead;
|
||||
progress?.Report(totalBytesRead);
|
||||
progress?.Report(new StreamProgress(totalBytesRead, contentLength ?? totalBytesRead));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
namespace Artemis.UI.Shared.Routing;
|
||||
|
||||
/// <summary>
|
||||
/// For internal use.
|
||||
/// </summary>
|
||||
/// <seealso cref="RoutableHostScreen{TScreen}" />
|
||||
/// <seealso cref="RoutableHostScreen{TScreen,TParam}" />
|
||||
internal interface IRoutableHostScreen : IRoutableScreen
|
||||
{
|
||||
bool RecycleScreen { get; }
|
||||
IRoutableScreen? InternalScreen { get; }
|
||||
void InternalChangeScreen(IRoutableScreen? screen);
|
||||
}
|
||||
14
src/Artemis.UI.Shared/Routing/Routable/IRoutableScreen.cs
Normal file
14
src/Artemis.UI.Shared/Routing/Routable/IRoutableScreen.cs
Normal file
@ -0,0 +1,14 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace Artemis.UI.Shared.Routing;
|
||||
|
||||
/// <summary>
|
||||
/// For internal use.
|
||||
/// </summary>
|
||||
internal interface IRoutableScreen : IActivatableViewModel
|
||||
{
|
||||
Task InternalOnNavigating(NavigationArguments args, CancellationToken cancellationToken);
|
||||
Task InternalOnClosing(NavigationArguments args);
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
namespace Artemis.UI.Shared.Routing;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a view model to which routing can take place and which in turn can host another view model.
|
||||
/// </summary>
|
||||
/// <typeparam name="TScreen">The type of view model the screen can host.</typeparam>
|
||||
public abstract class RoutableHostScreen<TScreen> : RoutableScreen, IRoutableHostScreen where TScreen : RoutableScreen
|
||||
{
|
||||
private bool _recycleScreen = true;
|
||||
private TScreen? _screen;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently active child screen.
|
||||
/// </summary>
|
||||
public TScreen? Screen
|
||||
{
|
||||
get => _screen;
|
||||
private set => RaiseAndSetIfChanged(ref _screen, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RecycleScreen
|
||||
{
|
||||
get => _recycleScreen;
|
||||
protected set => RaiseAndSetIfChanged(ref _recycleScreen, value);
|
||||
}
|
||||
|
||||
IRoutableScreen? IRoutableHostScreen.InternalScreen => Screen;
|
||||
|
||||
void IRoutableHostScreen.InternalChangeScreen(IRoutableScreen? screen)
|
||||
{
|
||||
if (screen == null)
|
||||
{
|
||||
Screen = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (screen is not TScreen typedScreen)
|
||||
throw new ArtemisRoutingException($"Screen cannot be hosted, {screen.GetType().Name} is not assignable to {typeof(TScreen).Name}.");
|
||||
Screen = typedScreen;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
namespace Artemis.UI.Shared.Routing;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a view model to which routing with parameters can take place and which in turn can host another view
|
||||
/// model.
|
||||
/// </summary>
|
||||
/// <typeparam name="TScreen">The type of view model the screen can host.</typeparam>
|
||||
/// <typeparam name="TParam">The type of parameters the screen expects. It must have a parameterless constructor.</typeparam>
|
||||
public abstract class RoutableHostScreen<TScreen, TParam> : RoutableScreen<TParam>, IRoutableHostScreen where TScreen : RoutableScreen where TParam : new()
|
||||
{
|
||||
private bool _recycleScreen = true;
|
||||
private TScreen? _screen;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently active child screen.
|
||||
/// </summary>
|
||||
public TScreen? Screen
|
||||
{
|
||||
get => _screen;
|
||||
private set => RaiseAndSetIfChanged(ref _screen, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RecycleScreen
|
||||
{
|
||||
get => _recycleScreen;
|
||||
protected set => RaiseAndSetIfChanged(ref _recycleScreen, value);
|
||||
}
|
||||
|
||||
IRoutableScreen? IRoutableHostScreen.InternalScreen => Screen;
|
||||
|
||||
void IRoutableHostScreen.InternalChangeScreen(IRoutableScreen? screen)
|
||||
{
|
||||
if (screen == null)
|
||||
{
|
||||
Screen = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (screen is not TScreen typedScreen)
|
||||
throw new ArtemisRoutingException($"Screen cannot be hosted, {screen.GetType().Name} is not assignable to {typeof(TScreen).Name}.");
|
||||
Screen = typedScreen;
|
||||
}
|
||||
}
|
||||
@ -1,24 +1,55 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace Artemis.UI.Shared.Routing;
|
||||
|
||||
/// <summary>
|
||||
/// For internal use.
|
||||
/// Represents a view model to which routing can take place.
|
||||
/// </summary>
|
||||
/// <seealso cref="RoutableScreen{TScreen}"/>
|
||||
/// <seealso cref="RoutableScreen{TScreen, TParam}"/>
|
||||
internal interface IRoutableScreen : IActivatableViewModel
|
||||
public abstract class RoutableScreen : ActivatableViewModelBase, IRoutableScreen
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether or not to reuse the child screen instance if the type has not changed.
|
||||
/// Called before navigating to this screen.
|
||||
/// </summary>
|
||||
/// <remarks>Defaults to <see langword="true"/>.</remarks>
|
||||
bool RecycleScreen { get; }
|
||||
/// <param name="args">Navigation arguments containing information about the navigation action.</param>
|
||||
public virtual Task BeforeNavigating(NavigationArguments args)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
object? InternalScreen { get; }
|
||||
void InternalChangeScreen(object? screen);
|
||||
Task InternalOnNavigating(NavigationArguments args, CancellationToken cancellationToken);
|
||||
Task InternalOnClosing(NavigationArguments args);
|
||||
/// <summary>
|
||||
/// Called while navigating to this screen.
|
||||
/// </summary>
|
||||
/// <param name="args">Navigation arguments containing information about the navigation action.</param>
|
||||
/// <param name="cancellationToken">
|
||||
/// A cancellation token that can be used by other objects or threads to receive notice of
|
||||
/// cancellation.
|
||||
/// </param>
|
||||
public virtual Task OnNavigating(NavigationArguments args, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called before navigating away from this screen.
|
||||
/// </summary>
|
||||
/// <param name="args">Navigation arguments containing information about the navigation action.</param>
|
||||
public virtual Task OnClosing(NavigationArguments args)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#region Overrides of RoutableScreen
|
||||
|
||||
async Task IRoutableScreen.InternalOnNavigating(NavigationArguments args, CancellationToken cancellationToken)
|
||||
{
|
||||
await OnNavigating(args, cancellationToken);
|
||||
}
|
||||
|
||||
async Task IRoutableScreen.InternalOnClosing(NavigationArguments args)
|
||||
{
|
||||
await OnClosing(args);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@ -4,39 +4,15 @@ using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Platform;
|
||||
|
||||
namespace Artemis.UI.Shared.Routing;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a view model to which routing with parameters can take place and which in turn can host another view
|
||||
/// model.
|
||||
/// Represents a view model to which routing with parameters can take place.
|
||||
/// </summary>
|
||||
/// <typeparam name="TScreen">The type of view model the screen can host.</typeparam>
|
||||
/// <typeparam name="TParam">The type of parameters the screen expects. It must have a parameterless constructor.</typeparam>
|
||||
public abstract class RoutableScreen<TScreen, TParam> : ActivatableViewModelBase, IRoutableScreen where TScreen : class where TParam : new()
|
||||
public abstract class RoutableScreen<TParam> : RoutableScreen, IRoutableScreen where TParam : new()
|
||||
{
|
||||
private bool _recycleScreen = true;
|
||||
private TScreen? _screen;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently active child screen.
|
||||
/// </summary>
|
||||
public TScreen? Screen
|
||||
{
|
||||
get => _screen;
|
||||
private set => RaiseAndSetIfChanged(ref _screen, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called before navigating to this screen.
|
||||
/// </summary>
|
||||
/// <param name="args">Navigation arguments containing information about the navigation action.</param>
|
||||
public virtual Task BeforeNavigating(NavigationArguments args)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called while navigating to this screen.
|
||||
/// </summary>
|
||||
@ -50,40 +26,7 @@ public abstract class RoutableScreen<TScreen, TParam> : ActivatableViewModelBase
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called before navigating away from this screen.
|
||||
/// </summary>
|
||||
/// <param name="args">Navigation arguments containing information about the navigation action.</param>
|
||||
public virtual Task OnClosing(NavigationArguments args)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RecycleScreen
|
||||
{
|
||||
get => _recycleScreen;
|
||||
protected set => RaiseAndSetIfChanged(ref _recycleScreen, value);
|
||||
}
|
||||
|
||||
#region Overrides of RoutableScreen
|
||||
|
||||
object? IRoutableScreen.InternalScreen => Screen;
|
||||
|
||||
void IRoutableScreen.InternalChangeScreen(object? screen)
|
||||
{
|
||||
if (screen == null)
|
||||
{
|
||||
Screen = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (screen is not TScreen typedScreen)
|
||||
throw new ArtemisRoutingException($"Provided screen is not assignable to {typeof(TScreen).FullName}");
|
||||
Screen = typedScreen;
|
||||
}
|
||||
|
||||
|
||||
async Task IRoutableScreen.InternalOnNavigating(NavigationArguments args, CancellationToken cancellationToken)
|
||||
{
|
||||
Func<object[], TParam> activator = GetParameterActivator();
|
||||
@ -92,6 +35,7 @@ public abstract class RoutableScreen<TScreen, TParam> : ActivatableViewModelBase
|
||||
throw new ArtemisRoutingException($"Did not retrieve the required amount of parameters, expects {_parameterPropertyCount}, got {args.SegmentParameters.Length}.");
|
||||
|
||||
TParam parameters = activator(args.SegmentParameters);
|
||||
await OnNavigating(args, cancellationToken);
|
||||
await OnNavigating(parameters, args, cancellationToken);
|
||||
}
|
||||
|
||||
@ -100,8 +44,6 @@ public abstract class RoutableScreen<TScreen, TParam> : ActivatableViewModelBase
|
||||
await OnClosing(args);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Parameter generation
|
||||
|
||||
// ReSharper disable once StaticMemberInGenericType - That's intentional, each kind of TParam should have its own property count
|
||||
@ -1,87 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Artemis.UI.Shared.Routing;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a view model to which routing can take place and which in turn can host another view model.
|
||||
/// </summary>
|
||||
/// <typeparam name="TScreen">The type of view model the screen can host.</typeparam>
|
||||
public abstract class RoutableScreen<TScreen> : ActivatableViewModelBase, IRoutableScreen where TScreen : class
|
||||
{
|
||||
private TScreen? _screen;
|
||||
private bool _recycleScreen = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently active child screen.
|
||||
/// </summary>
|
||||
public TScreen? Screen
|
||||
{
|
||||
get => _screen;
|
||||
private set => RaiseAndSetIfChanged(ref _screen, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RecycleScreen
|
||||
{
|
||||
get => _recycleScreen;
|
||||
protected set => RaiseAndSetIfChanged(ref _recycleScreen, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called before navigating to this screen.
|
||||
/// </summary>
|
||||
/// <param name="args">Navigation arguments containing information about the navigation action.</param>
|
||||
public virtual Task BeforeNavigating(NavigationArguments args)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called while navigating to this screen.
|
||||
/// </summary>
|
||||
/// <param name="args">Navigation arguments containing information about the navigation action.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
|
||||
public virtual Task OnNavigating(NavigationArguments args, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called before navigating away from this screen.
|
||||
/// </summary>
|
||||
/// <param name="args">Navigation arguments containing information about the navigation action.</param>
|
||||
public virtual Task OnClosing(NavigationArguments args)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#region Overrides of RoutableScreen
|
||||
|
||||
object? IRoutableScreen.InternalScreen => Screen;
|
||||
|
||||
void IRoutableScreen.InternalChangeScreen(object? screen)
|
||||
{
|
||||
if (screen == null)
|
||||
{
|
||||
Screen = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (screen is not TScreen typedScreen)
|
||||
throw new ArtemisRoutingException($"Screen cannot be hosted, {screen.GetType().Name} is not assignable to {typeof(TScreen).Name}.");
|
||||
Screen = typedScreen;
|
||||
}
|
||||
|
||||
async Task IRoutableScreen.InternalOnNavigating(NavigationArguments args, CancellationToken cancellationToken)
|
||||
{
|
||||
await OnNavigating(args, cancellationToken);
|
||||
}
|
||||
|
||||
async Task IRoutableScreen.InternalOnClosing(NavigationArguments args)
|
||||
{
|
||||
await OnClosing(args);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
|
||||
namespace Artemis.UI.Shared.Routing.ParameterParsers;
|
||||
|
||||
internal class LongParameterParser : IRouteParameterParser
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool IsMatch(RouteSegment segment, string source)
|
||||
{
|
||||
return long.TryParse(source, out _);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public object GetValue(RouteSegment segment, string source)
|
||||
{
|
||||
return long.Parse(source);
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,7 @@ namespace Artemis.UI.Shared.Routing;
|
||||
/// Represents a registration for a route and its associated view model.
|
||||
/// </summary>
|
||||
/// <typeparam name="TViewModel">The type of the view model associated with the route.</typeparam>
|
||||
public class RouteRegistration<TViewModel> : IRouterRegistration where TViewModel : ViewModelBase
|
||||
public class RouteRegistration<TViewModel> : IRouterRegistration where TViewModel : RoutableScreen
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RouteRegistration{TViewModel}" /> class.
|
||||
|
||||
@ -74,19 +74,12 @@ internal class RouteResolution
|
||||
};
|
||||
}
|
||||
|
||||
public object GetViewModel(IContainer container)
|
||||
public RoutableScreen GetViewModel(IContainer container)
|
||||
{
|
||||
if (ViewModel == null)
|
||||
throw new ArtemisRoutingException("Cannot get a view model of a non-success route resolution");
|
||||
|
||||
object? viewModel = container.Resolve(ViewModel);
|
||||
if (viewModel == null)
|
||||
throw new ArtemisRoutingException($"Could not resolve view model of type {ViewModel}");
|
||||
|
||||
return viewModel;
|
||||
return GetViewModel<RoutableScreen>(container);
|
||||
}
|
||||
|
||||
public T GetViewModel<T>(IContainer container)
|
||||
public T GetViewModel<T>(IContainer container) where T : RoutableScreen
|
||||
{
|
||||
if (ViewModel == null)
|
||||
throw new ArtemisRoutingException("Cannot get a view model of a non-success route resolution");
|
||||
|
||||
@ -79,6 +79,7 @@ public partial class RouteSegment
|
||||
return parameterType switch
|
||||
{
|
||||
"guid" => new GuidParameterParser(),
|
||||
"long" => new LongParameterParser(),
|
||||
"int" => new IntParameterParser(),
|
||||
_ => new StringParameterParser()
|
||||
};
|
||||
|
||||
@ -50,7 +50,7 @@ public interface IRouter
|
||||
/// </summary>
|
||||
/// <param name="root">The root screen to set.</param>
|
||||
/// <typeparam name="TScreen">The type of the root screen. It must be a class.</typeparam>
|
||||
void SetRoot<TScreen>(RoutableScreen<TScreen> root) where TScreen : class;
|
||||
void SetRoot<TScreen>(RoutableHostScreen<TScreen> root) where TScreen : RoutableScreen;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the root screen from which navigation takes place.
|
||||
@ -58,7 +58,7 @@ public interface IRouter
|
||||
/// <param name="root">The root screen to set.</param>
|
||||
/// <typeparam name="TScreen">The type of the root screen. It must be a class.</typeparam>
|
||||
/// <typeparam name="TParam">The type of the parameters for the root screen. It must have a parameterless constructor.</typeparam>
|
||||
void SetRoot<TScreen, TParam>(RoutableScreen<TScreen, TParam> root) where TScreen : class where TParam : new();
|
||||
void SetRoot<TScreen, TParam>(RoutableHostScreen<TScreen, TParam> root) where TScreen : RoutableScreen where TParam : new();
|
||||
|
||||
/// <summary>
|
||||
/// Clears the route used by the previous window, so that it is not restored when the main window opens.
|
||||
|
||||
@ -14,12 +14,12 @@ internal class Navigation
|
||||
private readonly IContainer _container;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private readonly IRoutableScreen _root;
|
||||
private readonly IRoutableHostScreen _root;
|
||||
private readonly RouteResolution _resolution;
|
||||
private readonly RouterNavigationOptions _options;
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
public Navigation(IContainer container, ILogger logger, IRoutableScreen root, RouteResolution resolution, RouterNavigationOptions options)
|
||||
public Navigation(IContainer container, ILogger logger, IRoutableHostScreen root, RouteResolution resolution, RouterNavigationOptions options)
|
||||
{
|
||||
_container = container;
|
||||
_logger = logger;
|
||||
@ -54,21 +54,21 @@ internal class Navigation
|
||||
_cts.Cancel();
|
||||
}
|
||||
|
||||
private async Task NavigateResolution(RouteResolution resolution, NavigationArguments args, IRoutableScreen host)
|
||||
private async Task NavigateResolution(RouteResolution resolution, NavigationArguments args, IRoutableHostScreen host)
|
||||
{
|
||||
if (Cancelled)
|
||||
return;
|
||||
|
||||
// Reuse the screen if its type has not changed, if a new one must be created, don't do so on the UI thread
|
||||
object screen;
|
||||
IRoutableScreen screen;
|
||||
if (_options.RecycleScreens && host.RecycleScreen && host.InternalScreen != null && host.InternalScreen.GetType() == resolution.ViewModel)
|
||||
screen = host.InternalScreen;
|
||||
else
|
||||
screen = await Task.Run(() => resolution.GetViewModel(_container));
|
||||
|
||||
// If resolution has a child, ensure the screen can host it
|
||||
if (resolution.Child != null && screen is not IRoutableScreen)
|
||||
throw new ArtemisRoutingException($"Route resolved with a child but view model of type {resolution.ViewModel} is does mot implement {nameof(IRoutableScreen)}.");
|
||||
if (resolution.Child != null && screen is not IRoutableHostScreen)
|
||||
throw new ArtemisRoutingException($"Route resolved with a child but view model of type {resolution.ViewModel} is does mot implement {nameof(IRoutableHostScreen)}.");
|
||||
|
||||
// Only change the screen if it wasn't reused
|
||||
if (!ReferenceEquals(host.InternalScreen, screen))
|
||||
@ -87,27 +87,24 @@ internal class Navigation
|
||||
|
||||
if (CancelIfRequested(args, "ChangeScreen", screen))
|
||||
return;
|
||||
|
||||
// If the screen implements some form of Navigable, activate it
|
||||
|
||||
// Navigate on the screen
|
||||
args.SegmentParameters = resolution.Parameters ?? Array.Empty<object>();
|
||||
if (screen is IRoutableScreen routableScreen)
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
await routableScreen.InternalOnNavigating(args, _cts.Token);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Cancel();
|
||||
if (e is not TaskCanceledException)
|
||||
_logger.Error(e, "Failed to navigate to {Path}", resolution.Path);
|
||||
}
|
||||
|
||||
if (CancelIfRequested(args, "OnNavigating", screen))
|
||||
return;
|
||||
await screen.InternalOnNavigating(args, _cts.Token);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Cancel();
|
||||
if (e is not TaskCanceledException)
|
||||
_logger.Error(e, "Failed to navigate to {Path}", resolution.Path);
|
||||
}
|
||||
|
||||
if (screen is IRoutableScreen childScreen)
|
||||
if (CancelIfRequested(args, "OnNavigating", screen))
|
||||
return;
|
||||
|
||||
if (screen is IRoutableHostScreen childScreen)
|
||||
{
|
||||
// Navigate the child too
|
||||
if (resolution.Child != null)
|
||||
@ -121,11 +118,9 @@ internal class Navigation
|
||||
Completed = true;
|
||||
}
|
||||
|
||||
public bool PathEquals(string path, bool allowPartialMatch)
|
||||
public bool PathEquals(string path, RouterNavigationOptions options)
|
||||
{
|
||||
if (allowPartialMatch)
|
||||
return _resolution.Path.StartsWith(path, StringComparison.InvariantCultureIgnoreCase);
|
||||
return string.Equals(_resolution.Path, path, StringComparison.InvariantCultureIgnoreCase);
|
||||
return options.PathEquals(_resolution.Path, path);
|
||||
}
|
||||
|
||||
private bool CancelIfRequested(NavigationArguments args, string stage, object screen)
|
||||
|
||||
@ -14,15 +14,15 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable
|
||||
private readonly Stack<string> _backStack = new();
|
||||
private readonly BehaviorSubject<string?> _currentRouteSubject;
|
||||
private readonly Stack<string> _forwardStack = new();
|
||||
private readonly Func<IRoutableScreen, RouteResolution, RouterNavigationOptions, Navigation> _getNavigation;
|
||||
private readonly Func<IRoutableHostScreen, RouteResolution, RouterNavigationOptions, Navigation> _getNavigation;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IMainWindowService _mainWindowService;
|
||||
private Navigation? _currentNavigation;
|
||||
|
||||
private IRoutableScreen? _root;
|
||||
private IRoutableHostScreen? _root;
|
||||
private string? _previousWindowRoute;
|
||||
|
||||
public Router(ILogger logger, IMainWindowService mainWindowService, Func<IRoutableScreen, RouteResolution, RouterNavigationOptions, Navigation> getNavigation)
|
||||
public Router(ILogger logger, IMainWindowService mainWindowService, Func<IRoutableHostScreen, RouteResolution, RouterNavigationOptions, Navigation> getNavigation)
|
||||
{
|
||||
_logger = logger;
|
||||
_mainWindowService = mainWindowService;
|
||||
@ -45,28 +45,22 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable
|
||||
return RouteResolution.AsFailure(path);
|
||||
}
|
||||
|
||||
private async Task<bool> RequestClose(object screen, NavigationArguments args)
|
||||
private async Task<bool> RequestClose(IRoutableScreen screen, NavigationArguments args)
|
||||
{
|
||||
if (screen is not IRoutableScreen routableScreen)
|
||||
return true;
|
||||
|
||||
await routableScreen.InternalOnClosing(args);
|
||||
if (args.Cancelled)
|
||||
{
|
||||
_logger.Debug("Navigation to {Path} cancelled during RequestClose by {Screen}", args.Path, screen.GetType().Name);
|
||||
// Drill down to child screens first
|
||||
if (screen is IRoutableHostScreen hostScreen && hostScreen.InternalScreen != null && !await RequestClose(hostScreen.InternalScreen, args))
|
||||
return false;
|
||||
}
|
||||
|
||||
if (routableScreen.InternalScreen == null)
|
||||
await screen.InternalOnClosing(args);
|
||||
if (!args.Cancelled)
|
||||
return true;
|
||||
return await RequestClose(routableScreen.InternalScreen, args);
|
||||
_logger.Debug("Navigation to {Path} cancelled during RequestClose by {Screen}", args.Path, screen.GetType().Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool PathEquals(string path, bool allowPartialMatch)
|
||||
private bool PathEquals(string path, RouterNavigationOptions options)
|
||||
{
|
||||
if (allowPartialMatch)
|
||||
return _currentRouteSubject.Value != null && _currentRouteSubject.Value.StartsWith(path, StringComparison.InvariantCultureIgnoreCase);
|
||||
return string.Equals(_currentRouteSubject.Value, path, StringComparison.InvariantCultureIgnoreCase);
|
||||
return _currentRouteSubject.Value != null && options.PathEquals(_currentRouteSubject.Value, path);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@ -88,7 +82,7 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable
|
||||
{
|
||||
if (_root == null)
|
||||
throw new ArtemisRoutingException("Cannot navigate without a root having been set");
|
||||
if (PathEquals(path, options.IgnoreOnPartialMatch) || (_currentNavigation != null && _currentNavigation.PathEquals(path, options.IgnoreOnPartialMatch)))
|
||||
if (PathEquals(path, options) || (_currentNavigation != null && _currentNavigation.PathEquals(path, options)))
|
||||
return;
|
||||
|
||||
string? previousPath = _currentRouteSubject.Value;
|
||||
@ -161,13 +155,13 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetRoot<TScreen>(RoutableScreen<TScreen> root) where TScreen : class
|
||||
public void SetRoot<TScreen>(RoutableHostScreen<TScreen> root) where TScreen : RoutableScreen
|
||||
{
|
||||
_root = root;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetRoot<TScreen, TParam>(RoutableScreen<TScreen, TParam> root) where TScreen : class where TParam : new()
|
||||
public void SetRoot<TScreen, TParam>(RoutableHostScreen<TScreen, TParam> root) where TScreen : RoutableScreen where TParam : new()
|
||||
{
|
||||
_root = root;
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Artemis.UI.Shared.Routing;
|
||||
|
||||
/// <summary>
|
||||
@ -21,9 +23,31 @@ public class RouterNavigationOptions
|
||||
/// <example>If set to true, a route change from <c>page/subpage1/subpage2</c> to <c>page/subpage1</c> will be ignored.</example>
|
||||
public bool IgnoreOnPartialMatch { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path to use when determining whether the path is a partial match,
|
||||
/// only has any effect if <see cref="IgnoreOnPartialMatch"/> is <see langword="true"/>.
|
||||
/// </summary>
|
||||
public string? PartialMatchOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a boolean value indicating whether logging should be enabled.
|
||||
/// <remarks>Errors and warnings are always logged.</remarks>
|
||||
/// </summary>
|
||||
public bool EnableLogging { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the given two paths are considered equal using these navigation options.
|
||||
/// </summary>
|
||||
/// <param name="current">The current path.</param>
|
||||
/// <param name="target">The target path.</param>
|
||||
/// <returns><see langword="true"/> if the paths are considered equal; otherwise <see langword="false"/>.</returns>
|
||||
internal bool PathEquals(string current, string target)
|
||||
{
|
||||
if (PartialMatchOverride != null && IgnoreOnPartialMatch)
|
||||
target = PartialMatchOverride;
|
||||
|
||||
if (IgnoreOnPartialMatch)
|
||||
return current.StartsWith(target, StringComparison.InvariantCultureIgnoreCase);
|
||||
return string.Equals(current, target, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
}
|
||||
@ -2,11 +2,11 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Threading;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using ReactiveUI;
|
||||
using Button = Avalonia.Controls.Button;
|
||||
|
||||
namespace Artemis.UI.Shared.Services.Builders;
|
||||
|
||||
@ -117,34 +117,34 @@ public class NotificationBuilder
|
||||
/// <exception cref="ArtemisSharedUIException" />
|
||||
public Action Show()
|
||||
{
|
||||
Panel? panel = _parent.Find<Panel>("NotificationContainer");
|
||||
if (panel == null)
|
||||
throw new ArtemisSharedUIException("Can't display a notification on a window without a NotificationContainer.");
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
panel.Children.Add(_infoBar);
|
||||
OverlayLayer? overlayLayer = OverlayLayer.GetOverlayLayer(_parent);
|
||||
if (overlayLayer == null)
|
||||
throw new ArtemisSharedUIException("Can't display a notification on a window an overlay layer.");
|
||||
|
||||
NotificationHost container = new() {Content = _infoBar};
|
||||
overlayLayer.Children.Add(container);
|
||||
_infoBar.Closed += InfoBarOnClosed;
|
||||
_infoBar.IsOpen = true;
|
||||
});
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(_timeout);
|
||||
Dispatcher.UIThread.Post(() => _infoBar.IsOpen = false);
|
||||
Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
await Task.Delay(_timeout);
|
||||
_infoBar.IsOpen = false;
|
||||
});
|
||||
|
||||
return;
|
||||
|
||||
void InfoBarOnClosed(InfoBar sender, InfoBarClosedEventArgs args)
|
||||
{
|
||||
overlayLayer.Children.Remove(container);
|
||||
_infoBar.Closed -= InfoBarOnClosed;
|
||||
}
|
||||
});
|
||||
|
||||
return () => Dispatcher.UIThread.Post(() => _infoBar.IsOpen = false);
|
||||
}
|
||||
|
||||
private void InfoBarOnClosed(InfoBar sender, InfoBarClosedEventArgs args)
|
||||
{
|
||||
_infoBar.Closed -= InfoBarOnClosed;
|
||||
if (_parent.Content is not Panel panel)
|
||||
return;
|
||||
|
||||
panel.Children.Remove(_infoBar);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -180,7 +180,7 @@ public class NotificationButtonBuilder
|
||||
_action = action;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Changes action that is called when the button is clicked.
|
||||
/// </summary>
|
||||
@ -222,9 +222,13 @@ public class NotificationButtonBuilder
|
||||
button.Classes.Add("AppBarButton");
|
||||
|
||||
if (_action != null)
|
||||
{
|
||||
button.Command = ReactiveCommand.Create(() => _action());
|
||||
}
|
||||
else if (_asyncAction != null)
|
||||
{
|
||||
button.Command = ReactiveCommand.CreateFromTask(() => _asyncAction());
|
||||
}
|
||||
else if (_command != null)
|
||||
{
|
||||
button.Command = _command;
|
||||
|
||||
@ -16,7 +16,7 @@ public interface IWindowService : IArtemisSharedUIService
|
||||
/// </summary>
|
||||
/// <typeparam name="TViewModel">The type of view model to create</typeparam>
|
||||
/// <returns>The created view model</returns>
|
||||
TViewModel ShowWindow<TViewModel>(params object[] parameters);
|
||||
Window ShowWindow<TViewModel>(out TViewModel viewModel, params object[] parameters);
|
||||
|
||||
/// <summary>
|
||||
/// Given a ViewModel, show its corresponding View as a window
|
||||
|
||||
@ -10,7 +10,6 @@ using Artemis.Core;
|
||||
using Artemis.Core.Services;
|
||||
using Artemis.UI.Shared.Services.MainWindow;
|
||||
using Artemis.UI.Shared.Services.ProfileEditor.Commands;
|
||||
using Avalonia.Threading;
|
||||
using DynamicData;
|
||||
using Serilog;
|
||||
|
||||
@ -140,14 +139,14 @@ internal class ProfileEditorService : IProfileEditorService
|
||||
private void ApplyFocusMode()
|
||||
{
|
||||
if (_suspendedEditingSubject.Value)
|
||||
_profileService.EditorFocus = null;
|
||||
_profileService.FocusProfileElement = null;
|
||||
|
||||
_profileService.EditorFocus = _focusModeSubject.Value switch
|
||||
_profileService.FocusProfileElement = _focusModeSubject.Value switch
|
||||
{
|
||||
ProfileEditorFocusMode.None => null,
|
||||
ProfileEditorFocusMode.Folder => _profileElementSubject.Value?.Parent,
|
||||
ProfileEditorFocusMode.Selection => _profileElementSubject.Value,
|
||||
_ => _profileService.EditorFocus
|
||||
_ => _profileService.FocusProfileElement
|
||||
};
|
||||
}
|
||||
|
||||
@ -164,52 +163,38 @@ internal class ProfileEditorService : IProfileEditorService
|
||||
|
||||
public async Task ChangeCurrentProfileConfiguration(ProfileConfiguration? profileConfiguration)
|
||||
{
|
||||
if (ReferenceEquals(_profileConfigurationSubject.Value, profileConfiguration))
|
||||
ProfileConfiguration? previous = _profileConfigurationSubject.Value;
|
||||
if (ReferenceEquals(previous, profileConfiguration))
|
||||
return;
|
||||
|
||||
_logger.Verbose("ChangeCurrentProfileConfiguration {profile}", profileConfiguration);
|
||||
|
||||
// Stop playing and save the current profile
|
||||
Pause();
|
||||
if (_profileConfigurationSubject.Value?.Profile != null)
|
||||
{
|
||||
_profileConfigurationSubject.Value.Profile.Reset();
|
||||
_profileConfigurationSubject.Value.Profile.LastSelectedProfileElement = _profileElementSubject.Value;
|
||||
}
|
||||
|
||||
await SaveProfileAsync();
|
||||
|
||||
// No need to deactivate the profile, if needed it will be deactivated next update
|
||||
if (_profileConfigurationSubject.Value != null)
|
||||
_profileConfigurationSubject.Value.IsBeingEdited = false;
|
||||
|
||||
// Deselect whatever profile element was active
|
||||
ChangeCurrentProfileElement(null);
|
||||
ChangeSuspendedEditing(false);
|
||||
|
||||
// Close the command scope if one was open
|
||||
_profileEditorHistoryScope?.Dispose();
|
||||
|
||||
// The new profile may need activation
|
||||
if (profileConfiguration != null)
|
||||
// Activate the profile and it's mode off of the UI thread
|
||||
await Task.Run(() =>
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
profileConfiguration.IsBeingEdited = true;
|
||||
_moduleService.SetActivationOverride(profileConfiguration.Module);
|
||||
// Activate the profile if one was provided
|
||||
if (profileConfiguration != null)
|
||||
_profileService.ActivateProfile(profileConfiguration);
|
||||
_profileService.RenderForEditor = true;
|
||||
});
|
||||
if (profileConfiguration.Profile?.LastSelectedProfileElement is RenderProfileElement renderProfileElement)
|
||||
ChangeCurrentProfileElement(renderProfileElement);
|
||||
}
|
||||
else
|
||||
{
|
||||
_moduleService.SetActivationOverride(null);
|
||||
_profileService.RenderForEditor = false;
|
||||
}
|
||||
// If there is no profile configuration or module, deliberately set the override to null
|
||||
_moduleService.SetActivationOverride(profileConfiguration?.Module);
|
||||
});
|
||||
|
||||
_profileService.FocusProfile = profileConfiguration;
|
||||
_profileConfigurationSubject.OnNext(profileConfiguration);
|
||||
|
||||
ChangeTime(TimeSpan.Zero);
|
||||
previous?.Profile?.Reset();
|
||||
}
|
||||
|
||||
public void ChangeCurrentProfileElement(RenderProfileElement? renderProfileElement)
|
||||
@ -238,23 +223,23 @@ internal class ProfileEditorService : IProfileEditorService
|
||||
if (_suspendedEditingSubject.Value == suspend)
|
||||
return;
|
||||
|
||||
_suspendedEditingSubject.OnNext(suspend);
|
||||
if (suspend)
|
||||
{
|
||||
Pause();
|
||||
_profileService.RenderForEditor = false;
|
||||
_profileService.UpdateFocusProfile = true;
|
||||
_profileConfigurationSubject.Value?.Profile?.Reset();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_profileConfigurationSubject.Value != null)
|
||||
_profileService.RenderForEditor = true;
|
||||
_profileService.UpdateFocusProfile = false;
|
||||
Tick(_timeSubject.Value);
|
||||
}
|
||||
|
||||
_suspendedEditingSubject.OnNext(suspend);
|
||||
ApplyFocusMode();
|
||||
}
|
||||
|
||||
|
||||
public void ChangeFocusMode(ProfileEditorFocusMode focusMode)
|
||||
{
|
||||
if (_focusModeSubject.Value == focusMode)
|
||||
@ -411,10 +396,8 @@ internal class ProfileEditorService : IProfileEditorService
|
||||
public void SaveProfile()
|
||||
{
|
||||
Profile? profile = _profileConfigurationSubject.Value?.Profile;
|
||||
if (profile == null)
|
||||
return;
|
||||
|
||||
_profileService.SaveProfile(profile, true);
|
||||
if (profile != null)
|
||||
_profileService.SaveProfile(profile, true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
<TextBox Text="{CompiledBinding Exception, Mode=OneTime}"
|
||||
AcceptsReturn="True"
|
||||
IsReadOnly="True"
|
||||
FontFamily="Consolas"
|
||||
FontFamily="{StaticResource RobotoMono}"
|
||||
FontSize="12"
|
||||
BorderThickness="0" />
|
||||
</ScrollViewer>
|
||||
|
||||
@ -21,14 +21,13 @@ internal class WindowService : IWindowService
|
||||
_container = container;
|
||||
}
|
||||
|
||||
public T ShowWindow<T>(params object[] parameters)
|
||||
public Window ShowWindow<T>(out T viewModel, params object[] parameters)
|
||||
{
|
||||
T viewModel = _container.Resolve<T>(parameters);
|
||||
viewModel = _container.Resolve<T>(parameters);
|
||||
if (viewModel == null)
|
||||
throw new ArtemisSharedUIException($"Failed to show window for VM of type {typeof(T).Name}, could not create instance.");
|
||||
|
||||
ShowWindow(viewModel);
|
||||
return viewModel;
|
||||
return ShowWindow(viewModel);
|
||||
}
|
||||
|
||||
public Window ShowWindow(object viewModel)
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<MergeResourceInclude Source="/Controls/Pagination/PaginationStyles.axaml" />
|
||||
<MergeResourceInclude Source="/Controls/TagsInput/TagsInputStyles.axaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Styles.Resources>
|
||||
@ -31,6 +32,7 @@
|
||||
<!-- Custom styles -->
|
||||
<StyleInclude Source="/Styles/Border.axaml" />
|
||||
<StyleInclude Source="/Styles/BrokenState.axaml" />
|
||||
<StyleInclude Source="/Styles/Control.axaml" />
|
||||
<StyleInclude Source="/Styles/Skeleton.axaml" />
|
||||
<StyleInclude Source="/Styles/Button.axaml" />
|
||||
<StyleInclude Source="/Styles/Condensed.axaml" />
|
||||
|
||||
@ -23,15 +23,11 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Design.PreviewWith>
|
||||
|
||||
<Styles.Resources>
|
||||
<CornerRadius x:Key="CardCornerRadius">8</CornerRadius>
|
||||
</Styles.Resources>
|
||||
|
||||
|
||||
<!-- Add Styles Here -->
|
||||
<Style Selector="Border.router-container">
|
||||
<Setter Property="Background" Value="{DynamicResource ControlFillColorDefaultBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource CardStrokeColorDefaultBrush}" />
|
||||
<Setter Property="Background" Value="{DynamicResource NavigationViewContentBackground}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource NavigationViewContentGridBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="8 0 0 0" />
|
||||
<Setter Property="ClipToBounds" Value="True" />
|
||||
@ -39,18 +35,18 @@
|
||||
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="Padding" Value="16" />
|
||||
<Setter Property="Background" Value="{DynamicResource ControlFillColorDefaultBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource CardStrokeColorDefaultBrush}" />
|
||||
<Setter Property="Background" Value="{DynamicResource NavigationViewContentBackground}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource NavigationViewContentGridBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource CardCornerRadius}" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource ControlCornerRadius}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.card-condensed">
|
||||
<Setter Property="Padding" Value="8" />
|
||||
<Setter Property="Background" Value="{DynamicResource ControlFillColorDefaultBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource CardStrokeColorDefaultBrush}" />
|
||||
<Setter Property="Background" Value="{DynamicResource NavigationViewContentBackground}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource NavigationViewContentGridBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource CardCornerRadius}" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource ControlCornerRadius}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.card-separator">
|
||||
|
||||
@ -39,15 +39,14 @@
|
||||
<Button Classes="title-bar-button">
|
||||
<avalonia:MaterialIcon Kind="WindowMinimize" />
|
||||
</Button>
|
||||
|
||||
<TextBlock Margin="0 5 0 0">ToggleButton.window-button</TextBlock>
|
||||
<ToggleButton Classes="icon-button">
|
||||
<avalonia:MaterialIcon Kind="BlockChain" />
|
||||
</ToggleButton>
|
||||
|
||||
<Button Classes="icon-button">
|
||||
<avalonia:MaterialIcon Kind="Cog" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="danger">
|
||||
Oohohoho daanger!
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Design.PreviewWith>
|
||||
@ -109,28 +108,29 @@
|
||||
<Style Selector="Button.title-bar-button:pointerover">
|
||||
<Setter Property="Background" Value="Red"></Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.danger">
|
||||
<Setter Property="Background" Value="#D64848"></Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.danger:pointerover">
|
||||
<Style Selector="^ /template/ controls|FABorder#Root">
|
||||
<Setter Property="Background" Value="#D65757"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushPointerOver}" />
|
||||
</Style>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ToggleButton:checked:pointerover /template/ Border#BorderElement">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource ToggleButtonBorderBrushCheckedPointerOver}" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton:checked:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource ToggleButtonBackgroundCheckedPointerOver}" />
|
||||
<Setter Property="TextBlock.Foreground" Value="{DynamicResource ToggleButtonForegroundCheckedPointerOver}" />
|
||||
</Style>
|
||||
<Style Selector="Button.danger:pressed">
|
||||
<Style Selector="^ /template/ controls|FABorder#Root">
|
||||
<Setter Property="Background" Value="#D64848" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushPressed}" />
|
||||
</Style>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ToggleButton:checked:pressed /template/ Border#BorderElement">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource ToggleButtonBorderBrushCheckedPressed}" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton:checked:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource ToggleButtonBackgroundCheckedPressed}" />
|
||||
<Setter Property="TextBlock.Foreground" Value="{DynamicResource ToggleButtonForegroundCheckedPressed}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ToggleButton:checked:disabled /template/ Border#BorderElement">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource ToggleButtonBorderBrushCheckedDisabled}" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton:checked:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource ToggleButtonBackgroundCheckedDisabled}" />
|
||||
<Setter Property="TextBlock.Foreground" Value="{DynamicResource ToggleButtonForegroundCheckedDisabled}" />
|
||||
</Style>
|
||||
<Style Selector="Button.danger:disabled">
|
||||
<Style Selector="^ /template/ controls|FABorder#Root">
|
||||
<Setter Property="Background" Value="#D79D9C" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
|
||||
</Style>
|
||||
</Style>
|
||||
</Styles>
|
||||
25
src/Artemis.UI.Shared/Styles/Control.axaml
Normal file
25
src/Artemis.UI.Shared/Styles/Control.axaml
Normal file
@ -0,0 +1,25 @@
|
||||
<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>
|
||||
|
||||
<!-- Add Styles Here -->
|
||||
<Style Selector=":is(Control).fade-in">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
|
||||
<Setter Property="Transitions">
|
||||
<Setter.Value>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Delay="0:0:0.2" Duration="0:0:0.2" Easing="CubicEaseOut"/>
|
||||
</Transitions>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector=":is(Control).faded-in">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</Style>
|
||||
</Styles>
|
||||
@ -66,7 +66,8 @@
|
||||
<TextBlock Grid.Column="0" Text="{CompiledBinding PropertyDescription.Name}" ToolTip.Tip="{CompiledBinding PropertyDescription.Description}" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{CompiledBinding DisplayValue}"
|
||||
FontFamily="Consolas"
|
||||
FontFamily="{StaticResource RobotoMono}"
|
||||
FontSize="13"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0 0 10 0" />
|
||||
</Grid>
|
||||
@ -81,7 +82,7 @@
|
||||
IsVisible="{CompiledBinding IsEventPicker, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type dataModelPicker:DataModelPicker}}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<ContentControl Grid.Column="1" Content="{CompiledBinding DisplayViewModel}" FontFamily="Consolas" Margin="0 0 10 0" />
|
||||
<ContentControl Grid.Column="1" Content="{CompiledBinding DisplayViewModel}" FontFamily="{StaticResource RobotoMono}" FontSize="13" Margin="0 0 10 0" />
|
||||
</Grid>
|
||||
</TreeDataTemplate>
|
||||
|
||||
@ -90,7 +91,8 @@
|
||||
<TextBlock Grid.Column="0" Text="{CompiledBinding PropertyDescription.Name}" ToolTip.Tip="{CompiledBinding PropertyDescription.Description}" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{CompiledBinding CountDisplay, Mode=OneWay}"
|
||||
FontFamily="Consolas"
|
||||
FontFamily="{StaticResource RobotoMono}"
|
||||
FontSize="13"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0 0 10 0" />
|
||||
</Grid>
|
||||
|
||||
@ -17,6 +17,8 @@
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
<Setter Property="MaxWidth" Value="600" />
|
||||
<Setter Property="Margin" Value="15"/>
|
||||
<Setter Property="VerticalAlignment" Value="Bottom"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Right"/>
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.2"/>
|
||||
|
||||
@ -1,17 +1,14 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia">
|
||||
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
|
||||
xmlns:shared="clr-namespace:Artemis.UI.Shared">
|
||||
<Design.PreviewWith>
|
||||
<Border Padding="20">
|
||||
<!-- Add Controls for Previewer Here -->
|
||||
</Border>
|
||||
</Design.PreviewWith>
|
||||
|
||||
<Style Selector="StackPanel.notification-container">
|
||||
<Setter Property="Spacing" Value="-25" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.notification-container controls|InfoBar">
|
||||
<Style Selector="shared|NotificationHost controls|InfoBar">
|
||||
<Setter Property="MaxHeight" Value="0" />
|
||||
<Style.Animations>
|
||||
<Animation Duration="0:0:0.2" Easing="CubicEaseOut" FillMode="Forward">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Design.PreviewWith>
|
||||
<Border Padding="20">
|
||||
@ -10,15 +10,10 @@
|
||||
<TextBlock Classes="h5">This is heading 5</TextBlock>
|
||||
<TextBlock Classes="h6">This is heading 6</TextBlock>
|
||||
<TextBlock Classes="subtitle">This is a subtitle</TextBlock>
|
||||
<TextBlock>
|
||||
<Run Classes="h1">This is heading 1</Run>
|
||||
<Run Classes="h2">This is heading 2</Run>
|
||||
<Run Classes="h3">This is heading 3</Run>
|
||||
<Run Classes="h4">This is heading 4</Run>
|
||||
<Run Classes="h5">This is heading 5</Run>
|
||||
<Run Classes="h6">This is heading 6</Run>
|
||||
<Run Classes="subtitle">This is a subtitle</Run>
|
||||
</TextBlock>
|
||||
<TextBlock Classes="danger">Danger</TextBlock>
|
||||
<TextBlock Classes="warning">Warning</TextBlock>
|
||||
<TextBlock Classes="success">Success</TextBlock>
|
||||
<TextBlock Classes="info">Info</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Design.PreviewWith>
|
||||
@ -82,6 +77,32 @@
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorTertiaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.danger">
|
||||
<Setter Property="Foreground" Value="#FF5C5C"></Setter>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.warning">
|
||||
<Setter Property="Foreground" Value="#DAA520"></Setter>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.success">
|
||||
<Setter Property="Foreground" Value="#12B775"></Setter>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.info">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AccentButtonBackground}"></Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Run.danger">
|
||||
<Setter Property="Foreground" Value="#FF5C5C"></Setter>
|
||||
</Style>
|
||||
<Style Selector="Run.warning">
|
||||
<Setter Property="Foreground" Value="#DAA520"></Setter>
|
||||
</Style>
|
||||
<Style Selector="Run.success">
|
||||
<Setter Property="Foreground" Value="#12B775"></Setter>
|
||||
</Style>
|
||||
<Style Selector="Run.info">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AccentButtonBackground}"></Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="SelectableTextBlock">
|
||||
<Setter Property="SelectionBrush" Value="{DynamicResource TextControlSelectionHighlightColor}" />
|
||||
</Style>
|
||||
|
||||
119
src/Artemis.UI.Shared/Utilities/ProgressableStreamContent.cs
Normal file
119
src/Artemis.UI.Shared/Utilities/ProgressableStreamContent.cs
Normal file
@ -0,0 +1,119 @@
|
||||
// Heavily based on:
|
||||
// SkyClip
|
||||
// - ProgressableStreamContent.cs
|
||||
// --------------------------------------------------------------------
|
||||
// Author: Jeff Hansen <jeff@jeffijoe.com>
|
||||
// Copyright (C) Jeff Hansen 2015. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Artemis.UI.Shared.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// Provides HTTP content based on a stream with support for IProgress.
|
||||
/// </summary>
|
||||
public class ProgressableStreamContent : StreamContent
|
||||
{
|
||||
private const int DEFAULT_BUFFER_SIZE = 4096;
|
||||
|
||||
private readonly int _bufferSize;
|
||||
private readonly IProgress<StreamProgress> _progress;
|
||||
private readonly Stream _streamToWrite;
|
||||
private bool _contentConsumed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProgressableStreamContent" /> class.
|
||||
/// </summary>
|
||||
/// <param name="streamToWrite">The stream to write.</param>
|
||||
/// <param name="progress">The downloader.</param>
|
||||
public ProgressableStreamContent(Stream streamToWrite, IProgress<StreamProgress> progress) : this(streamToWrite, DEFAULT_BUFFER_SIZE, progress)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProgressableStreamContent" /> class.
|
||||
/// </summary>
|
||||
/// <param name="streamToWrite">The stream to write.</param>
|
||||
/// <param name="bufferSize">The buffer size.</param>
|
||||
/// <param name="progress">The downloader.</param>
|
||||
public ProgressableStreamContent(Stream streamToWrite, int bufferSize, IProgress<StreamProgress> progress) : base(streamToWrite, bufferSize)
|
||||
{
|
||||
if (bufferSize <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(bufferSize));
|
||||
|
||||
_streamToWrite = streamToWrite;
|
||||
_bufferSize = bufferSize;
|
||||
_progress = progress;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
_streamToWrite.Dispose();
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context)
|
||||
{
|
||||
await SerializeToStreamAsync(stream, context, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken)
|
||||
{
|
||||
PrepareContent();
|
||||
|
||||
byte[] buffer = new byte[_bufferSize];
|
||||
long size = _streamToWrite.Length;
|
||||
int uploaded = 0;
|
||||
|
||||
await using (_streamToWrite)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
int length = await _streamToWrite.ReadAsync(buffer, cancellationToken);
|
||||
if (length <= 0)
|
||||
break;
|
||||
|
||||
uploaded += length;
|
||||
_progress.Report(new StreamProgress(uploaded, size));
|
||||
await stream.WriteAsync(buffer, 0, length, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool TryComputeLength(out long length)
|
||||
{
|
||||
length = _streamToWrite.Length;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepares the content.
|
||||
/// </summary>
|
||||
/// <exception cref="System.InvalidOperationException">The stream has already been read.</exception>
|
||||
private void PrepareContent()
|
||||
{
|
||||
if (_contentConsumed)
|
||||
{
|
||||
// If the content needs to be written to a target stream a 2nd time, then the stream must support
|
||||
// seeking (e.g. a FileStream), otherwise the stream can't be copied a second time to a target
|
||||
// stream (e.g. a NetworkStream).
|
||||
if (_streamToWrite.CanSeek)
|
||||
_streamToWrite.Position = 0;
|
||||
else
|
||||
throw new InvalidOperationException("The stream has already been read.");
|
||||
}
|
||||
|
||||
_contentConsumed = true;
|
||||
}
|
||||
}
|
||||
57
src/Artemis.UI.Shared/Utilities/StreamProgress.cs
Normal file
57
src/Artemis.UI.Shared/Utilities/StreamProgress.cs
Normal file
@ -0,0 +1,57 @@
|
||||
// Heavily based on:
|
||||
// SkyClip
|
||||
// - UploadProgress.cs
|
||||
// --------------------------------------------------------------------
|
||||
// Author: Jeff Hansen <jeff@jeffijoe.com>
|
||||
// Copyright (C) Jeff Hansen 2015. All rights reserved.
|
||||
|
||||
namespace Artemis.UI.Shared.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// The upload progress.
|
||||
/// </summary>
|
||||
public class StreamProgress
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StreamProgress" /> class.
|
||||
/// </summary>
|
||||
/// <param name="bytesTransfered">
|
||||
/// The bytes transfered.
|
||||
/// </param>
|
||||
/// <param name="totalBytes">
|
||||
/// The total bytes.
|
||||
/// </param>
|
||||
public StreamProgress(long bytesTransfered, long? totalBytes)
|
||||
{
|
||||
BytesTransfered = bytesTransfered;
|
||||
TotalBytes = totalBytes;
|
||||
if (totalBytes.HasValue)
|
||||
ProgressPercentage = (int) ((float) bytesTransfered / totalBytes.Value * 100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a <see cref="System.String" /> that represents this instance.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// A <see cref="System.String" /> that represents this instance.
|
||||
/// </returns>
|
||||
public override string ToString()
|
||||
{
|
||||
return string.Format("{0}% ({1} / {2})", ProgressPercentage, BytesTransfered, TotalBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bytes transfered.
|
||||
/// </summary>
|
||||
public long BytesTransfered { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the progress percentage.
|
||||
/// </summary>
|
||||
public int ProgressPercentage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total bytes.
|
||||
/// </summary>
|
||||
public long? TotalBytes { get; }
|
||||
}
|
||||
@ -18,7 +18,7 @@
|
||||
<TrayIcon.Menu>
|
||||
<NativeMenu>
|
||||
<NativeMenuItem Header="Home" Command="{CompiledBinding OpenScreen}" CommandParameter="home" />
|
||||
<!-- <NativeMenuItem Header="Workshop" Command="{CompiledBinding OpenScreen}" CommandParameter="workshop" /> -->
|
||||
<NativeMenuItem Header="Workshop" Command="{CompiledBinding OpenScreen}" CommandParameter="workshop" />
|
||||
<NativeMenuItem Header="Surface Editor" Command="{CompiledBinding OpenScreen}" CommandParameter="surface-editor" />
|
||||
<NativeMenuItem Header="Settings" Command="{CompiledBinding OpenScreen}" CommandParameter="settings" />
|
||||
<NativeMenuItemSeparator />
|
||||
|
||||
@ -18,18 +18,24 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.2.0" />
|
||||
<PackageReference Include="Avalonia" Version="$(AvaloniaVersion)" />
|
||||
<PackageReference Include="Avalonia.AvaloniaEdit" Version="11.0.1" />
|
||||
<PackageReference Include="Avalonia.Controls.PanAndZoom" Version="11.0.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="$(AvaloniaVersion)" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="$(AvaloniaVersion)" />
|
||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="$(AvaloniaVersion)" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="$(AvaloniaVersion)" />
|
||||
<PackageReference Include="Avalonia.Xaml.Behaviors" Version="$(AvaloniaBehavioursVersion)" />
|
||||
<PackageReference Include="Avalonia.Skia.Lottie" Version="11.0.0" />
|
||||
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.0.1" />
|
||||
<PackageReference Include="DryIoc.dll" Version="5.4.0" />
|
||||
<PackageReference Include="DynamicData" Version="7.13.1" />
|
||||
<PackageReference Include="FluentAvaloniaUI" Version="$(FluentAvaloniaVersion)" />
|
||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||
<PackageReference Include="Markdown.Avalonia.Tight" Version="11.0.0" />
|
||||
<PackageReference Include="Markdown.Avalonia.Svg" Version="11.0.1" />
|
||||
<PackageReference Include="Markdown.Avalonia.Tight" Version="11.0.1" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.0.1" />
|
||||
<PackageReference Include="Octopus.Octodiff" Version="2.0.261" />
|
||||
<PackageReference Include="ReactiveUI" Version="18.4.26" />
|
||||
@ -38,9 +44,30 @@
|
||||
<PackageReference Include="RGB.NET.Layout" Version="$(RGBDotNetVersion)" />
|
||||
<PackageReference Include="SkiaSharp" Version="$(SkiaSharpVersion)" />
|
||||
<PackageReference Include="Splat.DryIoc" Version="14.6.8" />
|
||||
<PackageReference Include="System.Reactive" Version="5.0.0" />
|
||||
<PackageReference Include="TextMateSharp.Grammars" Version="1.0.55" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Screens\Workshop\Entries\Tabs\ProfileListView.axaml.cs">
|
||||
<DependentUpon>ProfileListView.axaml</DependentUpon>
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
<Compile Update="Screens\Workshop\Entries\Tabs\LayoutListView.axaml.cs">
|
||||
<DependentUpon>LayoutListView.axaml</DependentUpon>
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
<Compile Update="Screens\Workshop\Library\SubmissionDetailView.axaml.cs">
|
||||
<DependentUpon>SubmissionsDetailView.axaml</DependentUpon>
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<UpToDateCheckInput Remove="Screens\Workshop\Entries\Windows\MarkdownPreviewView.axaml" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
1
src/Artemis.UI/Assets/Animations/email.json
Normal file
1
src/Artemis.UI/Assets/Animations/email.json
Normal file
File diff suppressed because one or more lines are too long
1
src/Artemis.UI/Assets/Animations/empty.json
Normal file
1
src/Artemis.UI/Assets/Animations/empty.json
Normal file
File diff suppressed because one or more lines are too long
1
src/Artemis.UI/Assets/Animations/login-pending.json
Normal file
1
src/Artemis.UI/Assets/Animations/login-pending.json
Normal file
File diff suppressed because one or more lines are too long
1
src/Artemis.UI/Assets/Animations/password.json
Normal file
1
src/Artemis.UI/Assets/Animations/password.json
Normal file
File diff suppressed because one or more lines are too long
1
src/Artemis.UI/Assets/Animations/success.json
Normal file
1
src/Artemis.UI/Assets/Animations/success.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
src/Artemis.UI/Assets/Animations/workshop-wizard.json
Normal file
1
src/Artemis.UI/Assets/Animations/workshop-wizard.json
Normal file
File diff suppressed because one or more lines are too long
BIN
src/Artemis.UI/Assets/Fonts/RobotoMono-Bold.ttf
Normal file
BIN
src/Artemis.UI/Assets/Fonts/RobotoMono-Bold.ttf
Normal file
Binary file not shown.
BIN
src/Artemis.UI/Assets/Fonts/RobotoMono-BoldItalic.ttf
Normal file
BIN
src/Artemis.UI/Assets/Fonts/RobotoMono-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/Artemis.UI/Assets/Fonts/RobotoMono-Italic.ttf
Normal file
BIN
src/Artemis.UI/Assets/Fonts/RobotoMono-Italic.ttf
Normal file
Binary file not shown.
BIN
src/Artemis.UI/Assets/Fonts/RobotoMono-Regular.ttf
Normal file
BIN
src/Artemis.UI/Assets/Fonts/RobotoMono-Regular.ttf
Normal file
Binary file not shown.
BIN
src/Artemis.UI/Assets/Fonts/RobotoMono-SemiBold.ttf
Normal file
BIN
src/Artemis.UI/Assets/Fonts/RobotoMono-SemiBold.ttf
Normal file
Binary file not shown.
BIN
src/Artemis.UI/Assets/Fonts/RobotoMono-SemiBoldItalic.ttf
Normal file
BIN
src/Artemis.UI/Assets/Fonts/RobotoMono-SemiBoldItalic.ttf
Normal file
Binary file not shown.
34
src/Artemis.UI/Converters/DateTimeConverter.cs
Normal file
34
src/Artemis.UI/Converters/DateTimeConverter.cs
Normal file
@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Artemis.WebClient.Workshop;
|
||||
using Avalonia.Data.Converters;
|
||||
using Humanizer;
|
||||
|
||||
namespace Artemis.UI.Converters;
|
||||
|
||||
public class DateTimeConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is DateTimeOffset dateTimeOffset)
|
||||
{
|
||||
return parameter?.ToString() == "humanize"
|
||||
? dateTimeOffset.ToLocalTime().Humanize()
|
||||
: dateTimeOffset.ToLocalTime().ToString(parameter?.ToString() ?? "g");
|
||||
}
|
||||
|
||||
if (value is DateTime dateTime)
|
||||
{
|
||||
return parameter?.ToString() == "humanize"
|
||||
? dateTime.ToLocalTime().Humanize()
|
||||
: dateTime.ToLocalTime().ToString(parameter?.ToString() ?? "g");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
19
src/Artemis.UI/Converters/EntryIconUriConverter.cs
Normal file
19
src/Artemis.UI/Converters/EntryIconUriConverter.cs
Normal file
@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Artemis.WebClient.Workshop;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace Artemis.UI.Converters;
|
||||
|
||||
public class EntryIconUriConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
return $"{WorkshopConstants.WORKSHOP_URL}/entries/{value}/icon";
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
48
src/Artemis.UI/Extensions/Bitmap.cs
Normal file
48
src/Artemis.UI/Extensions/Bitmap.cs
Normal file
@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media.Imaging;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Artemis.UI.Extensions;
|
||||
|
||||
public class BitmapExtensions
|
||||
{
|
||||
public static Bitmap LoadAndResize(string file, int size)
|
||||
{
|
||||
using SKBitmap source = SKBitmap.Decode(file);
|
||||
return Resize(source, size);
|
||||
}
|
||||
|
||||
public static Bitmap LoadAndResize(Stream stream, int size)
|
||||
{
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
using MemoryStream copy = new();
|
||||
stream.CopyTo(copy);
|
||||
copy.Seek(0, SeekOrigin.Begin);
|
||||
using SKBitmap source = SKBitmap.Decode(copy);
|
||||
return Resize(source, size);
|
||||
}
|
||||
|
||||
private static Bitmap Resize(SKBitmap source, int size)
|
||||
{
|
||||
// Get smaller dimension.
|
||||
int minDim = Math.Min(source.Width, source.Height);
|
||||
|
||||
// Calculate crop rectangle position for center crop.
|
||||
int deltaX = (source.Width - minDim) / 2;
|
||||
int deltaY = (source.Height - minDim) / 2;
|
||||
|
||||
// Create crop rectangle.
|
||||
SKRectI rect = new(deltaX, deltaY, deltaX + minDim, deltaY + minDim);
|
||||
|
||||
// Do the actual cropping of the bitmap.
|
||||
using SKBitmap croppedBitmap = new(minDim, minDim);
|
||||
source.ExtractSubset(croppedBitmap, rect);
|
||||
|
||||
// Resize to the desired size after cropping.
|
||||
using SKBitmap resizedBitmap = croppedBitmap.Resize(new SKImageInfo(size, size), SKFilterQuality.High);
|
||||
|
||||
return new Bitmap(resizedBitmap.Encode(SKEncodedImageFormat.Png, 100).AsStream());
|
||||
}
|
||||
}
|
||||
15
src/Artemis.UI/Extensions/IActivatableViewModelExtensions.cs
Normal file
15
src/Artemis.UI/Extensions/IActivatableViewModelExtensions.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using System.Reactive.Disposables;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace Artemis.UI.Extensions;
|
||||
|
||||
public static class ActivatableViewModelExtensions
|
||||
{
|
||||
public static void WhenActivatedAsync(this IActivatableViewModel item, Func<CompositeDisposable, Task> block)
|
||||
{
|
||||
item.WhenActivated(d => Dispatcher.UIThread.InvokeAsync(async () => await block(d)));
|
||||
}
|
||||
}
|
||||
35
src/Artemis.UI/Extensions/MaterialIconKindExtensions.cs
Normal file
35
src/Artemis.UI/Extensions/MaterialIconKindExtensions.cs
Normal file
@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Material.Icons;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Artemis.UI.Extensions;
|
||||
|
||||
public static class MaterialIconKindExtensions
|
||||
{
|
||||
public static Stream EncodeToBitmap(this MaterialIconKind icon, int size, int margin, SKColor color)
|
||||
{
|
||||
string geometrySource = MaterialIconDataProvider.GetData(icon);
|
||||
|
||||
SKBitmap bitmap = new(size, size);
|
||||
using (SKCanvas canvas = new(bitmap))
|
||||
{
|
||||
canvas.Clear(SKColors.Transparent);
|
||||
|
||||
// Parse and render the geometry data using SkiaSharp's SKPath
|
||||
using SKPath path = SKPath.ParseSvgPathData(geometrySource);
|
||||
using SKPaint paint = new() {Color = color, IsAntialias = true,};
|
||||
|
||||
// Calculate scaling and translation to fit the icon in the 100x100 area with 14 pixels margin
|
||||
float scale = Math.Min(size / path.Bounds.Width, size / path.Bounds.Height);
|
||||
path.Transform(SKMatrix.CreateTranslation(path.Bounds.Left * -1, path.Bounds.Top * -1));
|
||||
path.Transform(SKMatrix.CreateScale(scale, scale));
|
||||
canvas.Scale((size - margin * 2) / (float) size, (size - margin * 2) / (float) size, size / 2f, size / 2f);
|
||||
canvas.DrawPath(path, paint);
|
||||
}
|
||||
|
||||
MemoryStream stream = new();
|
||||
bitmap.Encode(stream, SKEncodedImageFormat.Png, 100);
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Threading;
|
||||
using Artemis.UI.Shared.Utilities;
|
||||
|
||||
namespace Artemis.UI.Extensions;
|
||||
|
||||
@ -16,7 +17,7 @@ public static class ZipArchiveExtensions
|
||||
/// <param name="overwriteFiles">A boolean indicating whether to override existing files</param>
|
||||
/// <param name="progress">The progress to report to.</param>
|
||||
/// <param name="cancellationToken">A cancellation token</param>
|
||||
public static void ExtractToDirectory(this ZipArchive source, string destinationDirectoryName, bool overwriteFiles, IProgress<float> progress, CancellationToken cancellationToken)
|
||||
public static void ExtractToDirectory(this ZipArchive source, string destinationDirectoryName, bool overwriteFiles, IProgress<StreamProgress> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
if (source == null)
|
||||
throw new ArgumentNullException(nameof(source));
|
||||
@ -28,7 +29,7 @@ public static class ZipArchiveExtensions
|
||||
{
|
||||
ZipArchiveEntry entry = source.Entries[index];
|
||||
entry.ExtractRelativeToDirectory(destinationDirectoryName, overwriteFiles);
|
||||
progress.Report((index + 1f) / source.Entries.Count * 100f);
|
||||
progress.Report(new StreamProgress(index + 1, source.Entries.Count));
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,9 @@
|
||||
MinWidth="600"
|
||||
MinHeight="400"
|
||||
PointerReleased="InputElement_OnPointerReleased">
|
||||
<windowing:AppWindow.Resources>
|
||||
|
||||
</windowing:AppWindow.Resources>
|
||||
<windowing:AppWindow.Styles>
|
||||
<Styles>
|
||||
<Style Selector="Border#TitleBarContainer">
|
||||
@ -36,6 +39,5 @@
|
||||
</Border>
|
||||
<ContentControl Content="{CompiledBinding}" />
|
||||
</DockPanel>
|
||||
<StackPanel Classes="notification-container" Name="NotificationContainer" VerticalAlignment="Bottom" HorizontalAlignment="Right"/>
|
||||
</Panel>
|
||||
</windowing:AppWindow>
|
||||
28
src/Artemis.UI/Routing/RouteViewModel.cs
Normal file
28
src/Artemis.UI/Routing/RouteViewModel.cs
Normal file
@ -0,0 +1,28 @@
|
||||
using System;
|
||||
|
||||
namespace Artemis.UI.Routing;
|
||||
|
||||
public class RouteViewModel
|
||||
{
|
||||
public RouteViewModel(string name, string path, string? matchPath = null)
|
||||
{
|
||||
Path = path;
|
||||
Name = name;
|
||||
MatchPath = matchPath;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
public string Name { get; }
|
||||
public string? MatchPath { get; }
|
||||
|
||||
public bool Matches(string path)
|
||||
{
|
||||
return path.StartsWith(MatchPath ?? Path, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
{
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,12 @@ using Artemis.UI.Screens.Settings;
|
||||
using Artemis.UI.Screens.Settings.Updating;
|
||||
using Artemis.UI.Screens.SurfaceEditor;
|
||||
using Artemis.UI.Screens.Workshop;
|
||||
using Artemis.UI.Screens.Workshop.Entries;
|
||||
using Artemis.UI.Screens.Workshop.Entries.Tabs;
|
||||
using Artemis.UI.Screens.Workshop.Home;
|
||||
using Artemis.UI.Screens.Workshop.Layout;
|
||||
using Artemis.UI.Screens.Workshop.Library;
|
||||
using Artemis.UI.Screens.Workshop.Library.Tabs;
|
||||
using Artemis.UI.Screens.Workshop.Profile;
|
||||
using Artemis.UI.Shared.Routing;
|
||||
|
||||
@ -18,18 +23,34 @@ public static class Routes
|
||||
{
|
||||
new RouteRegistration<BlankViewModel>("blank"),
|
||||
new RouteRegistration<HomeViewModel>("home"),
|
||||
#if DEBUG
|
||||
new RouteRegistration<WorkshopViewModel>("workshop")
|
||||
{
|
||||
Children = new List<IRouterRegistration>()
|
||||
Children = new List<IRouterRegistration>
|
||||
{
|
||||
new RouteRegistration<ProfileListViewModel>("profiles/{page:int}"),
|
||||
new RouteRegistration<ProfileDetailsViewModel>("profiles/{entryId:guid}"),
|
||||
new RouteRegistration<LayoutListViewModel>("layouts/{page:int}"),
|
||||
new RouteRegistration<LayoutDetailsViewModel>("layouts/{entryId:guid}")
|
||||
new RouteRegistration<WorkshopOfflineViewModel>("offline/{message:string}"),
|
||||
new RouteRegistration<EntriesViewModel>("entries")
|
||||
{
|
||||
Children = new List<IRouterRegistration>
|
||||
{
|
||||
new RouteRegistration<ProfileListViewModel>("profiles/{page:int}"),
|
||||
new RouteRegistration<ProfileDetailsViewModel>("profiles/details/{entryId:long}"),
|
||||
#if DEBUG
|
||||
new RouteRegistration<LayoutListViewModel>("layouts/{page:int}"),
|
||||
new RouteRegistration<LayoutDetailsViewModel>("layouts/details/{entryId:long}"),
|
||||
#endif
|
||||
}
|
||||
},
|
||||
new RouteRegistration<WorkshopLibraryViewModel>("library")
|
||||
{
|
||||
Children = new List<IRouterRegistration>
|
||||
{
|
||||
new RouteRegistration<InstalledTabViewModel>("installed"),
|
||||
new RouteRegistration<SubmissionsTabViewModel>("submissions"),
|
||||
new RouteRegistration<SubmissionDetailViewModel>("submissions/{entryId:long}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
#endif
|
||||
new RouteRegistration<SurfaceEditorViewModel>("surface-editor"),
|
||||
new RouteRegistration<SettingsViewModel>("settings")
|
||||
{
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
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"
|
||||
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:debugger="clr-namespace:Artemis.UI.Screens.Debugger"
|
||||
xmlns:shared="clr-namespace:Artemis.UI.Shared;assembly=Artemis.UI.Shared"
|
||||
@ -35,7 +34,5 @@
|
||||
<Border Classes="router-container" Grid.Column="1">
|
||||
<ContentControl Content="{CompiledBinding SelectedItem}" Margin="15"></ContentControl>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="0" Grid.ColumnSpan="2" Classes="notification-container" Name="NotificationContainer" VerticalAlignment="Bottom" HorizontalAlignment="Right" />
|
||||
</Grid>
|
||||
</windowing:AppWindow>
|
||||
@ -23,7 +23,7 @@
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<TreeView Grid.Row="1" ItemsSource="{CompiledBinding MainDataModel.Children}">
|
||||
<TreeView Grid.Row="1" ItemsSource="{CompiledBinding MainDataModel.Children}" Padding="0 0 15 0">
|
||||
<TreeView.Styles>
|
||||
<Style Selector="TreeViewItem">
|
||||
<Setter Property="IsExpanded" Value="{CompiledBinding IsVisualizationExpanded, Mode=TwoWay,DataType=dataModel:DataModelVisualizationViewModel}" />
|
||||
@ -42,7 +42,7 @@
|
||||
</TreeView.Styles>
|
||||
<TreeView.DataTemplates>
|
||||
<TreeDataTemplate DataType="{x:Type dataModel:DataModelPropertiesViewModel}" ItemsSource="{CompiledBinding Children}">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*" Margin="0 0 8 0">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Margin="0 0 5 0">
|
||||
<TextBlock FontWeight="Bold">[</TextBlock>
|
||||
<TextBlock FontWeight="Bold" Text="{CompiledBinding DisplayValueType, Converter={StaticResource TypeToStringConverter}, Mode=OneWay}" />
|
||||
@ -52,13 +52,14 @@
|
||||
<TextBlock Grid.Column="1" Text="{CompiledBinding PropertyDescription.Name}" ToolTip.Tip="{CompiledBinding PropertyDescription.Description}" />
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="{CompiledBinding DisplayValue}"
|
||||
FontFamily="Consolas"
|
||||
FontFamily="{StaticResource RobotoMono}"
|
||||
FontSize="13"
|
||||
HorizontalAlignment="Right" />
|
||||
</Grid>
|
||||
|
||||
</TreeDataTemplate>
|
||||
<TreeDataTemplate DataType="{x:Type dataModel:DataModelListViewModel}" ItemsSource="{CompiledBinding ListChildren}">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*" Margin="0 0 8 0">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Margin="0 0 5 0">
|
||||
<TextBlock FontWeight="Bold">[</TextBlock>
|
||||
<TextBlock FontWeight="Bold" Text="{CompiledBinding DisplayValueType, Converter={StaticResource TypeToStringConverter}, Mode=OneWay}" />
|
||||
@ -67,12 +68,13 @@
|
||||
<TextBlock Grid.Column="1" Text="{CompiledBinding PropertyDescription.Name}" ToolTip.Tip="{CompiledBinding PropertyDescription.Description}" />
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="{CompiledBinding CountDisplay, Mode=OneWay}"
|
||||
FontFamily="Consolas"
|
||||
FontSize="13"
|
||||
FontFamily="{StaticResource RobotoMono}"
|
||||
HorizontalAlignment="Right" />
|
||||
</Grid>
|
||||
</TreeDataTemplate>
|
||||
<TreeDataTemplate DataType="{x:Type dataModel:DataModelPropertyViewModel}">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*" Margin="0 0 8 0">
|
||||
<!-- Value description -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Margin="0 0 5 0">
|
||||
<TextBlock FontWeight="Bold">[</TextBlock>
|
||||
@ -82,11 +84,11 @@
|
||||
<TextBlock Grid.Column="1" Text="{CompiledBinding PropertyDescription.Name}" ToolTip.Tip="{CompiledBinding PropertyDescription.Description}" />
|
||||
|
||||
<!-- Value display -->
|
||||
<ContentControl Grid.Column="2" Content="{CompiledBinding DisplayViewModel}" FontFamily="Consolas" />
|
||||
<ContentControl Grid.Column="2" Content="{CompiledBinding DisplayViewModel}" FontSize="13" FontFamily="{StaticResource RobotoMono}" />
|
||||
</Grid>
|
||||
</TreeDataTemplate>
|
||||
<TreeDataTemplate DataType="{x:Type dataModel:DataModelListItemViewModel}">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*" Margin="0 0 8 0">
|
||||
<!-- Value description -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Margin="0 0 5 0">
|
||||
<TextBlock FontWeight="Bold">[</TextBlock>
|
||||
@ -99,12 +101,12 @@
|
||||
</StackPanel>
|
||||
|
||||
<!-- Value display -->
|
||||
<ContentControl Grid.Column="2" Content="{CompiledBinding DisplayViewModel}" FontFamily="Consolas" />
|
||||
<ContentControl Grid.Column="2" Content="{CompiledBinding DisplayViewModel}" FontSize="13" FontFamily="{StaticResource RobotoMono}" />
|
||||
</Grid>
|
||||
</TreeDataTemplate>
|
||||
|
||||
<TreeDataTemplate DataType="{x:Type dataModel:DataModelEventViewModel}" ItemsSource="{CompiledBinding Children}">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*" Margin="0 0 8 0">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Margin="0 0 5 0">
|
||||
<TextBlock FontWeight="Bold">[</TextBlock>
|
||||
<TextBlock FontWeight="Bold" Text="{CompiledBinding DisplayValueType, Converter={StaticResource TypeToStringConverter}, Mode=OneWay}" />
|
||||
@ -113,7 +115,8 @@
|
||||
<TextBlock Grid.Column="1" Text="{CompiledBinding PropertyDescription.Name}" ToolTip.Tip="{CompiledBinding PropertyDescription.Description}" />
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="{CompiledBinding Path=CountDisplay, DataType=dataModel:DataModelListViewModel ,Mode=OneWay}"
|
||||
FontFamily="Consolas"
|
||||
FontSize="13"
|
||||
FontFamily="{StaticResource RobotoMono}"
|
||||
HorizontalAlignment="Right" />
|
||||
</Grid>
|
||||
</TreeDataTemplate>
|
||||
|
||||
@ -9,9 +9,9 @@
|
||||
<ScrollViewer Name="LogsScrollViewer" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto">
|
||||
<SelectableTextBlock
|
||||
Inlines="{CompiledBinding Lines}"
|
||||
FontFamily="Consolas"
|
||||
FontFamily="{StaticResource RobotoMono}"
|
||||
FontSize="12"
|
||||
SizeChanged="Control_OnSizeChanged"
|
||||
SelectionBrush="{StaticResource TextControlSelectionHighlightColor}"
|
||||
/>
|
||||
SelectionBrush="{StaticResource TextControlSelectionHighlightColor}"/>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@ -19,7 +19,8 @@
|
||||
<ScrollViewer Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2" Name="LogsScrollViewer" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto">
|
||||
<SelectableTextBlock
|
||||
Inlines="{CompiledBinding Lines}"
|
||||
FontFamily="Consolas"
|
||||
FontFamily="{StaticResource RobotoMono}"
|
||||
FontSize="12"
|
||||
SizeChanged="Control_OnSizeChanged"
|
||||
SelectionBrush="{StaticResource TextControlSelectionHighlightColor}" />
|
||||
</ScrollViewer>
|
||||
|
||||
@ -69,7 +69,5 @@
|
||||
</Panel>
|
||||
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="0" Grid.ColumnSpan="3" Classes="notification-container" Name="NotificationContainer" VerticalAlignment="Top" HorizontalAlignment="Right" />
|
||||
</Grid>
|
||||
</windowing:AppWindow>
|
||||
@ -1,12 +1,13 @@
|
||||
using Artemis.Core.Services;
|
||||
using Artemis.UI.Screens.StartupWizard;
|
||||
using Artemis.UI.Shared;
|
||||
using Artemis.UI.Shared.Routing;
|
||||
using Artemis.UI.Shared.Services;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace Artemis.UI.Screens.Home;
|
||||
|
||||
public class HomeViewModel : ViewModelBase, IMainScreenViewModel
|
||||
public class HomeViewModel : RoutableScreen, IMainScreenViewModel
|
||||
{
|
||||
public HomeViewModel(ISettingsService settingsService, IWindowService windowService)
|
||||
{
|
||||
|
||||
@ -70,7 +70,5 @@
|
||||
<ContentControl Margin="0 0 5 0" Content="{CompiledBinding ConfigurationViewModel}"></ContentControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Name="NotificationContainer" Classes="notification-container" VerticalAlignment="Bottom" HorizontalAlignment="Right" />
|
||||
</Panel>
|
||||
</windowing:AppWindow>
|
||||
@ -16,7 +16,6 @@ using Artemis.UI.Shared.Services;
|
||||
using Artemis.UI.Shared.Services.ProfileEditor;
|
||||
using Newtonsoft.Json;
|
||||
using ReactiveUI;
|
||||
using Serilog;
|
||||
|
||||
namespace Artemis.UI.Screens.ProfileEditor.MenuBar;
|
||||
|
||||
@ -182,7 +181,7 @@ public class MenuBarViewModel : ActivatableViewModelBase
|
||||
if (!await _windowService.ShowConfirmContentDialog("Delete profile", "Are you sure you want to permanently delete this profile?"))
|
||||
return;
|
||||
|
||||
if (ProfileConfiguration.IsBeingEdited)
|
||||
if (_profileService.FocusProfile == ProfileConfiguration)
|
||||
await _router.Navigate("home");
|
||||
_profileService.RemoveProfileConfiguration(ProfileConfiguration);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ 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;
|
||||
@ -237,7 +238,7 @@ public class PlaybackViewModel : ActivatableViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
_profileEditorService.ChangeTime(newTime);
|
||||
Dispatcher.UIThread.Invoke(() => _profileEditorService.ChangeTime(newTime));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reactive.Disposables;
|
||||
using System.Reactive.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.PanAndZoom;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.ReactiveUI;
|
||||
using Avalonia.Threading;
|
||||
@ -19,7 +19,7 @@ public partial class VisualEditorView : ReactiveUserControl<VisualEditorViewMode
|
||||
public VisualEditorView()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
|
||||
ZoomBorder.PropertyChanged += ZoomBorderOnPropertyChanged;
|
||||
ZoomBorder.PointerMoved += ZoomBorderOnPointerMoved;
|
||||
ZoomBorder.PointerWheelChanged += ZoomBorderOnPointerWheelChanged;
|
||||
@ -31,11 +31,7 @@ public partial class VisualEditorView : ReactiveUserControl<VisualEditorViewMode
|
||||
Disposable.Create(() => ViewModel.AutoFitRequested -= ViewModelOnAutoFitRequested).DisposeWith(d);
|
||||
});
|
||||
|
||||
this.WhenAnyValue(v => v.Bounds).Subscribe(_ =>
|
||||
{
|
||||
if (!_movedByUser)
|
||||
AutoFit(true);
|
||||
});
|
||||
this.WhenAnyValue(v => v.Bounds).Where(_ => !_movedByUser).Subscribe(_ => AutoFit(true));
|
||||
}
|
||||
|
||||
private void ZoomBorderOnPointerWheelChanged(object? sender, PointerWheelEventArgs e)
|
||||
|
||||
@ -81,7 +81,7 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
<ContentControl Grid.Column="1" Content="{CompiledBinding VisualEditorViewModel}" Classes="fade-in"
|
||||
<ContentControl Grid.Column="1" Content="{CompiledBinding VisualEditorViewModel}"
|
||||
IsVisible="{CompiledBinding ProfileConfiguration, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@ -17,14 +17,13 @@ using Artemis.UI.Shared;
|
||||
using Artemis.UI.Shared.Routing;
|
||||
using Artemis.UI.Shared.Services.MainWindow;
|
||||
using Artemis.UI.Shared.Services.ProfileEditor;
|
||||
using Avalonia.Threading;
|
||||
using DynamicData;
|
||||
using DynamicData.Binding;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace Artemis.UI.Screens.ProfileEditor;
|
||||
|
||||
public class ProfileEditorViewModel : RoutableScreen<object, ProfileEditorViewModelParameters>, IMainScreenViewModel
|
||||
public class ProfileEditorViewModel : RoutableScreen<ProfileEditorViewModelParameters>, IMainScreenViewModel
|
||||
{
|
||||
private readonly IProfileEditorService _profileEditorService;
|
||||
private readonly IProfileService _profileService;
|
||||
@ -161,10 +160,10 @@ public class ProfileEditorViewModel : RoutableScreen<object, ProfileEditorViewMo
|
||||
{
|
||||
ProfileConfiguration? profileConfiguration = _profileService.ProfileConfigurations.FirstOrDefault(c => c.ProfileId == parameters.ProfileId);
|
||||
|
||||
// If the profile doesn't exist, navigate home for lack of some kind of 404 :p
|
||||
// If the profile doesn't exist, cancel navigation
|
||||
if (profileConfiguration == null)
|
||||
{
|
||||
await args.Router.Navigate("home");
|
||||
args.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
using Artemis.UI.Shared;
|
||||
using Artemis.UI.Shared.Routing;
|
||||
|
||||
namespace Artemis.UI.Screens.Root;
|
||||
|
||||
public class BlankViewModel : ViewModelBase, IMainScreenViewModel
|
||||
public class BlankViewModel : RoutableScreen, IMainScreenViewModel
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ViewModelBase? TitleBarViewModel => null;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Reactive.Disposables;
|
||||
using Artemis.UI.Shared.Routing;
|
||||
using Avalonia.ReactiveUI;
|
||||
using Avalonia.Threading;
|
||||
using ReactiveUI;
|
||||
@ -14,7 +15,7 @@ public partial class RootView : ReactiveUserControl<RootViewModel>
|
||||
this.WhenActivated(d => ViewModel.WhenAnyValue(vm => vm.Screen).Subscribe(Navigate).DisposeWith(d));
|
||||
}
|
||||
|
||||
private void Navigate(IMainScreenViewModel viewModel)
|
||||
private void Navigate(RoutableScreen viewModel)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@ -18,7 +18,7 @@ using ReactiveUI;
|
||||
|
||||
namespace Artemis.UI.Screens.Root;
|
||||
|
||||
public class RootViewModel : RoutableScreen<IMainScreenViewModel>, IMainWindowProvider
|
||||
public class RootViewModel : RoutableHostScreen<RoutableScreen>, IMainWindowProvider
|
||||
{
|
||||
private readonly ICoreService _coreService;
|
||||
private readonly IDebugService _debugService;
|
||||
@ -100,12 +100,10 @@ public class RootViewModel : RoutableScreen<IMainScreenViewModel>, IMainWindowPr
|
||||
_router.GoForward();
|
||||
}
|
||||
|
||||
private void UpdateTitleBarViewModel(IMainScreenViewModel? viewModel)
|
||||
private void UpdateTitleBarViewModel(RoutableScreen? viewModel)
|
||||
{
|
||||
if (viewModel?.TitleBarViewModel != null)
|
||||
TitleBarViewModel = viewModel.TitleBarViewModel;
|
||||
else
|
||||
TitleBarViewModel = _defaultTitleBarViewModel;
|
||||
IMainScreenViewModel? mainScreenViewModel = viewModel as IMainScreenViewModel;
|
||||
TitleBarViewModel = mainScreenViewModel?.TitleBarViewModel ?? _defaultTitleBarViewModel;
|
||||
}
|
||||
|
||||
private void CurrentMainWindowOnClosing(object? sender, EventArgs e)
|
||||
@ -130,7 +128,7 @@ public class RootViewModel : RoutableScreen<IMainScreenViewModel>, IMainWindowPr
|
||||
|
||||
private void ShowSplashScreen()
|
||||
{
|
||||
_windowService.ShowWindow<SplashViewModel>();
|
||||
_windowService.ShowWindow(out SplashViewModel _);
|
||||
}
|
||||
|
||||
#region Tray commands
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Artemis.UI.Screens.Settings;
|
||||
|
||||
public class SettingsTab
|
||||
{
|
||||
public SettingsTab(string path, string name)
|
||||
{
|
||||
Path = path;
|
||||
Name = name;
|
||||
}
|
||||
|
||||
public string Path { get; set; }
|
||||
public string Name { get; set; }
|
||||
|
||||
public bool Matches(string path)
|
||||
{
|
||||
return path.StartsWith($"settings/{Path}", StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
}
|
||||
@ -8,20 +8,25 @@
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="Artemis.UI.Screens.Settings.SettingsView"
|
||||
x:DataType="settings:SettingsViewModel">
|
||||
<Border Classes="router-container">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<TabStrip Grid.Row="0" Margin="12" ItemsSource="{CompiledBinding SettingTabs}" SelectedItem="{CompiledBinding SelectedTab}">
|
||||
<TabStrip.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{CompiledBinding Name}" />
|
||||
</DataTemplate>
|
||||
</TabStrip.ItemTemplate>
|
||||
</TabStrip>
|
||||
<controls:Frame Grid.Row="1" Name="TabFrame" IsNavigationStackEnabled="False" CacheSize="0">
|
||||
<controls:Frame.NavigationPageFactory>
|
||||
<ui:PageFactory/>
|
||||
</controls:Frame.NavigationPageFactory>
|
||||
</controls:Frame>
|
||||
</Grid>
|
||||
</Border>
|
||||
<controls:NavigationView PaneDisplayMode="Top"
|
||||
MenuItemsSource="{CompiledBinding SettingTabs}"
|
||||
SelectedItem="{CompiledBinding SelectedTab}"
|
||||
IsBackEnabled="True"
|
||||
IsBackButtonVisible="True"
|
||||
IsSettingsVisible="False"
|
||||
BackRequested="NavigationView_OnBackRequested">
|
||||
<controls:NavigationView.Styles>
|
||||
<Styles>
|
||||
<Style Selector="controls|NavigationView:topnavminimal /template/ SplitView Border#ContentGridBorder">
|
||||
<Setter Property="CornerRadius" Value="8 0 0 0" />
|
||||
</Style>
|
||||
</Styles>
|
||||
</controls:NavigationView.Styles>
|
||||
|
||||
<controls:Frame Name="TabFrame" IsNavigationStackEnabled="False" CacheSize="0" Padding="20">
|
||||
<controls:Frame.NavigationPageFactory>
|
||||
<ui:PageFactory/>
|
||||
</controls:Frame.NavigationPageFactory>
|
||||
</controls:Frame>
|
||||
</controls:NavigationView>
|
||||
</UserControl>
|
||||
@ -3,8 +3,7 @@ using System.Reactive.Disposables;
|
||||
using Artemis.UI.Shared;
|
||||
using Avalonia.ReactiveUI;
|
||||
using Avalonia.Threading;
|
||||
using FluentAvalonia.UI.Media.Animation;
|
||||
using FluentAvalonia.UI.Navigation;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace Artemis.UI.Screens.Settings;
|
||||
@ -19,6 +18,11 @@ public partial class SettingsView : ReactiveUserControl<SettingsViewModel>
|
||||
|
||||
private void Navigate(ViewModelBase viewModel)
|
||||
{
|
||||
Dispatcher.UIThread.Invoke(() => TabFrame.NavigateFromObject(viewModel, new FrameNavigationOptions {TransitionInfoOverride = new SlideNavigationTransitionInfo()}));
|
||||
Dispatcher.UIThread.Invoke(() => TabFrame.NavigateFromObject(viewModel));
|
||||
}
|
||||
|
||||
private void NavigationView_OnBackRequested(object? sender, NavigationViewBackRequestedEventArgs e)
|
||||
{
|
||||
ViewModel?.GoBack();
|
||||
}
|
||||
}
|
||||
@ -4,39 +4,40 @@ using System.Linq;
|
||||
using System.Reactive.Disposables;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Artemis.UI.Routing;
|
||||
using Artemis.UI.Shared;
|
||||
using Artemis.UI.Shared.Routing;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace Artemis.UI.Screens.Settings;
|
||||
|
||||
public class SettingsViewModel : RoutableScreen<ActivatableViewModelBase>, IMainScreenViewModel
|
||||
public class SettingsViewModel : RoutableHostScreen<RoutableScreen>, IMainScreenViewModel
|
||||
{
|
||||
private readonly IRouter _router;
|
||||
private SettingsTab? _selectedTab;
|
||||
private RouteViewModel? _selectedTab;
|
||||
|
||||
public SettingsViewModel(IRouter router)
|
||||
{
|
||||
_router = router;
|
||||
SettingTabs = new ObservableCollection<SettingsTab>
|
||||
SettingTabs = new ObservableCollection<RouteViewModel>
|
||||
{
|
||||
new("general", "General"),
|
||||
new("plugins", "Plugins"),
|
||||
new("devices", "Devices"),
|
||||
new("releases", "Releases"),
|
||||
new("about", "About"),
|
||||
new("General", "settings/general"),
|
||||
new("Plugins", "settings/plugins"),
|
||||
new("Devices", "settings/devices"),
|
||||
new("Releases", "settings/releases"),
|
||||
new("About", "settings/about"),
|
||||
};
|
||||
|
||||
// Navigate on tab change
|
||||
this.WhenActivated(d => this.WhenAnyValue(vm => vm.SelectedTab)
|
||||
.WhereNotNull()
|
||||
.Subscribe(s => _router.Navigate($"settings/{s.Path}", new RouterNavigationOptions {IgnoreOnPartialMatch = true}))
|
||||
.Subscribe(s => _router.Navigate(s.Path, new RouterNavigationOptions {IgnoreOnPartialMatch = true}))
|
||||
.DisposeWith(d));
|
||||
}
|
||||
|
||||
public ObservableCollection<SettingsTab> SettingTabs { get; }
|
||||
public ObservableCollection<RouteViewModel> SettingTabs { get; }
|
||||
|
||||
public SettingsTab? SelectedTab
|
||||
public RouteViewModel? SelectedTab
|
||||
{
|
||||
get => _selectedTab;
|
||||
set => RaiseAndSetIfChanged(ref _selectedTab, value);
|
||||
@ -52,6 +53,11 @@ public class SettingsViewModel : RoutableScreen<ActivatableViewModelBase>, IMain
|
||||
|
||||
// Always show a tab, if there is none forward to the first
|
||||
if (SelectedTab == null)
|
||||
await _router.Navigate($"settings/{SettingTabs.First().Path}");
|
||||
await _router.Navigate(SettingTabs.First().Path);
|
||||
}
|
||||
|
||||
public void GoBack()
|
||||
{
|
||||
_router.Navigate("workshop");
|
||||
}
|
||||
}
|
||||
@ -5,20 +5,21 @@
|
||||
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
|
||||
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:vm="clr-namespace:Artemis.UI.Screens.Settings;assembly=Artemis.UI"
|
||||
xmlns:il="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
|
||||
x:DataType="vm:AboutTabViewModel"
|
||||
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="1400"
|
||||
x:Class="Artemis.UI.Screens.Settings.AboutTabView">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="15" MaxWidth="800">
|
||||
<Grid RowDefinitions="*,*" ColumnDefinitions="Auto,*,Auto">
|
||||
<Image Grid.Column="0"
|
||||
Grid.RowSpan="2"
|
||||
<Image Grid.Column="0"
|
||||
Grid.RowSpan="2"
|
||||
Width="65"
|
||||
Height="65"
|
||||
VerticalAlignment="Center"
|
||||
Source="/Assets/Images/Logo/bow.png"
|
||||
Margin="0 0 20 0"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality"/>
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" FontSize="36" VerticalAlignment="Bottom">
|
||||
Artemis 2
|
||||
</TextBlock>
|
||||
@ -52,17 +53,9 @@
|
||||
<Border Classes="card" Margin="0 20 0 10">
|
||||
<StackPanel>
|
||||
<Grid RowDefinitions="*,*,*" ColumnDefinitions="Auto,*">
|
||||
<Ellipse Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Grid.RowSpan="3"
|
||||
VerticalAlignment="Top"
|
||||
Height="40"
|
||||
Width="40"
|
||||
Margin="0 0 15 0"
|
||||
IsVisible="{CompiledBinding RobertProfileImage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality">
|
||||
<Ellipse Grid.Row="0" Grid.Column="0" Grid.RowSpan="3" VerticalAlignment="Top" Height="75" Width="75" Margin="0 0 15 0">
|
||||
<Ellipse.Fill>
|
||||
<ImageBrush Source="{CompiledBinding RobertProfileImage}" />
|
||||
<ImageBrush il:ImageBrushLoader.Source="https://avatars.githubusercontent.com/u/8858506" />
|
||||
</Ellipse.Fill>
|
||||
</Ellipse>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Padding="0">
|
||||
@ -81,17 +74,9 @@
|
||||
<Border Classes="card-separator" />
|
||||
|
||||
<Grid RowDefinitions="*,*,*" ColumnDefinitions="Auto,*">
|
||||
<Ellipse Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Grid.RowSpan="3"
|
||||
VerticalAlignment="Top"
|
||||
Height="75"
|
||||
Width="75"
|
||||
Margin="0 0 15 0"
|
||||
IsVisible="{CompiledBinding DarthAffeProfileImage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality">
|
||||
<Ellipse Grid.Row="0" Grid.Column="0" Grid.RowSpan="3" VerticalAlignment="Top" Height="75" Width="75" Margin="0 0 15 0">
|
||||
<Ellipse.Fill>
|
||||
<ImageBrush Source="{CompiledBinding DarthAffeProfileImage}" />
|
||||
<ImageBrush il:ImageBrushLoader.Source="https://avatars.githubusercontent.com/u/1094841" />
|
||||
</Ellipse.Fill>
|
||||
</Ellipse>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Padding="0">
|
||||
@ -110,17 +95,9 @@
|
||||
<Border Classes="card-separator" />
|
||||
|
||||
<Grid RowDefinitions="*,*,*" ColumnDefinitions="Auto,*">
|
||||
<Ellipse Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Grid.RowSpan="3"
|
||||
VerticalAlignment="Top"
|
||||
Height="75"
|
||||
Width="75"
|
||||
Margin="0 0 15 0"
|
||||
IsVisible="{CompiledBinding DrMeteorProfileImage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality">
|
||||
<Ellipse Grid.Row="0" Grid.Column="0" Grid.RowSpan="3" VerticalAlignment="Top" Height="75" Width="75" Margin="0 0 15 0">
|
||||
<Ellipse.Fill>
|
||||
<ImageBrush Source="{CompiledBinding DrMeteorProfileImage}" />
|
||||
<ImageBrush il:ImageBrushLoader.Source="https://avatars.githubusercontent.com/u/29486064" />
|
||||
</Ellipse.Fill>
|
||||
</Ellipse>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Padding="0">
|
||||
@ -139,17 +116,9 @@
|
||||
<Border Classes="card-separator" />
|
||||
|
||||
<Grid RowDefinitions="*,*,*" ColumnDefinitions="Auto,*">
|
||||
<Ellipse Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Grid.RowSpan="3"
|
||||
VerticalAlignment="Top"
|
||||
Height="75"
|
||||
Width="75"
|
||||
Margin="0 0 15 0"
|
||||
IsVisible="{CompiledBinding KaiProfileImage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality">
|
||||
<Ellipse Grid.Row="0" Grid.Column="0" Grid.RowSpan="3" VerticalAlignment="Top" Height="75" Width="75" Margin="0 0 15 0">
|
||||
<Ellipse.Fill>
|
||||
<ImageBrush Source="{CompiledBinding KaiProfileImage}" />
|
||||
<ImageBrush il:ImageBrushLoader.Source="https://i.imgur.com/8mPWY1j.png" />
|
||||
</Ellipse.Fill>
|
||||
</Ellipse>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Padding="0">
|
||||
|
||||
@ -1,75 +1,15 @@
|
||||
using System;
|
||||
using System.Reactive.Disposables;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Artemis.Core;
|
||||
using Artemis.UI.Shared;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Flurl.Http;
|
||||
using ReactiveUI;
|
||||
using Artemis.Core;
|
||||
using Artemis.UI.Shared.Routing;
|
||||
|
||||
namespace Artemis.UI.Screens.Settings;
|
||||
|
||||
public class AboutTabViewModel : ActivatableViewModelBase
|
||||
public class AboutTabViewModel : RoutableScreen
|
||||
{
|
||||
private Bitmap? _darthAffeProfileImage;
|
||||
private Bitmap? _drMeteorProfileImage;
|
||||
private Bitmap? _kaiProfileImage;
|
||||
private Bitmap? _robertProfileImage;
|
||||
private string? _version;
|
||||
|
||||
public AboutTabViewModel()
|
||||
{
|
||||
DisplayName = "About";
|
||||
this.WhenActivated((CompositeDisposable _) => Task.Run(Activate));
|
||||
}
|
||||
|
||||
public string? Version
|
||||
{
|
||||
get => _version;
|
||||
set => RaiseAndSetIfChanged(ref _version, value);
|
||||
}
|
||||
|
||||
public Bitmap? RobertProfileImage
|
||||
{
|
||||
get => _robertProfileImage;
|
||||
set => RaiseAndSetIfChanged(ref _robertProfileImage, value);
|
||||
}
|
||||
|
||||
public Bitmap? DarthAffeProfileImage
|
||||
{
|
||||
get => _darthAffeProfileImage;
|
||||
set => RaiseAndSetIfChanged(ref _darthAffeProfileImage, value);
|
||||
}
|
||||
|
||||
public Bitmap? DrMeteorProfileImage
|
||||
{
|
||||
get => _drMeteorProfileImage;
|
||||
set => RaiseAndSetIfChanged(ref _drMeteorProfileImage, value);
|
||||
}
|
||||
|
||||
public Bitmap? KaiProfileImage
|
||||
{
|
||||
get => _kaiProfileImage;
|
||||
set => RaiseAndSetIfChanged(ref _kaiProfileImage, value);
|
||||
}
|
||||
|
||||
private async Task Activate()
|
||||
{
|
||||
AssemblyInformationalVersionAttribute? versionAttribute = typeof(AboutTabViewModel).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
||||
Version = $"Version {Constants.CurrentVersion}";
|
||||
|
||||
try
|
||||
{
|
||||
RobertProfileImage = new Bitmap(await "https://avatars.githubusercontent.com/u/8858506".GetStreamAsync());
|
||||
RobertProfileImage = new Bitmap(await "https://avatars.githubusercontent.com/u/8858506".GetStreamAsync());
|
||||
DarthAffeProfileImage = new Bitmap(await "https://avatars.githubusercontent.com/u/1094841".GetStreamAsync());
|
||||
DrMeteorProfileImage = new Bitmap(await "https://avatars.githubusercontent.com/u/29486064".GetStreamAsync());
|
||||
KaiProfileImage = new Bitmap(await "https://i.imgur.com/8mPWY1j.png".GetStreamAsync());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// ignored, unluckyyyy
|
||||
}
|
||||
}
|
||||
|
||||
public string Version { get; }
|
||||
}
|
||||
@ -8,7 +8,7 @@ using Artemis.Core;
|
||||
using Artemis.Core.Services;
|
||||
using Artemis.UI.DryIoc.Factories;
|
||||
using Artemis.UI.Screens.Device;
|
||||
using Artemis.UI.Shared;
|
||||
using Artemis.UI.Shared.Routing;
|
||||
using Artemis.UI.Shared.Services;
|
||||
using Avalonia.Threading;
|
||||
using DynamicData;
|
||||
@ -16,7 +16,7 @@ using ReactiveUI;
|
||||
|
||||
namespace Artemis.UI.Screens.Settings;
|
||||
|
||||
public class DevicesTabViewModel : ActivatableViewModelBase
|
||||
public class DevicesTabViewModel : RoutableScreen
|
||||
{
|
||||
private readonly IDeviceVmFactory _deviceVmFactory;
|
||||
private readonly IRgbService _rgbService;
|
||||
|
||||
@ -13,8 +13,8 @@ using Artemis.Core.Services;
|
||||
using Artemis.UI.Screens.StartupWizard;
|
||||
using Artemis.UI.Services.Interfaces;
|
||||
using Artemis.UI.Services.Updating;
|
||||
using Artemis.UI.Shared;
|
||||
using Artemis.UI.Shared.Providers;
|
||||
using Artemis.UI.Shared.Routing;
|
||||
using Artemis.UI.Shared.Services;
|
||||
using Artemis.UI.Shared.Services.Builders;
|
||||
using Avalonia.Threading;
|
||||
@ -26,7 +26,7 @@ using Serilog.Events;
|
||||
|
||||
namespace Artemis.UI.Screens.Settings;
|
||||
|
||||
public class GeneralTabViewModel : ActivatableViewModelBase
|
||||
public class GeneralTabViewModel : RoutableScreen
|
||||
{
|
||||
private readonly IAutoRunProvider? _autoRunProvider;
|
||||
private readonly IDebugService _debugService;
|
||||
|
||||
@ -25,14 +25,19 @@
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Row="1" Grid.Column="0" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto" VerticalAlignment="Top">
|
||||
<ItemsRepeater ItemsSource="{CompiledBinding Plugins}" MaxWidth="1000" VerticalAlignment="Center">
|
||||
<ItemsRepeater.ItemTemplate>
|
||||
<ItemsControl ItemsSource="{CompiledBinding Plugins}" MaxWidth="1000" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<VirtualizingStackPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="plugins:PluginSettingsViewModel">
|
||||
<ContentControl Content="{CompiledBinding}" Height="200"/>
|
||||
<ContentControl Content="{CompiledBinding}" Height="200" />
|
||||
</DataTemplate>
|
||||
</ItemsRepeater.ItemTemplate>
|
||||
</ItemsRepeater>
|
||||
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@ -9,18 +9,17 @@ using Artemis.Core;
|
||||
using Artemis.Core.Services;
|
||||
using Artemis.UI.DryIoc.Factories;
|
||||
using Artemis.UI.Screens.Plugins;
|
||||
using Artemis.UI.Shared;
|
||||
using Artemis.UI.Shared.Routing;
|
||||
using Artemis.UI.Shared.Services;
|
||||
using Artemis.UI.Shared.Services.Builders;
|
||||
using Avalonia.ReactiveUI;
|
||||
using Avalonia.Threading;
|
||||
using DynamicData;
|
||||
using DynamicData.Binding;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace Artemis.UI.Screens.Settings;
|
||||
|
||||
public class PluginsTabViewModel : ActivatableViewModelBase
|
||||
public class PluginsTabViewModel : RoutableScreen
|
||||
{
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly IPluginManagementService _pluginManagementService;
|
||||
|
||||
@ -22,7 +22,7 @@ using StrawberryShake;
|
||||
|
||||
namespace Artemis.UI.Screens.Settings;
|
||||
|
||||
public class ReleasesTabViewModel : RoutableScreen<ReleaseDetailsViewModel>
|
||||
public class ReleasesTabViewModel : RoutableHostScreen<ReleaseDetailsViewModel>
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly IUpdateService _updateService;
|
||||
|
||||
@ -3,22 +3,23 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:updating="clr-namespace:Artemis.UI.Screens.Settings.Updating"
|
||||
xmlns:avalonia="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia"
|
||||
xmlns:avalonia1="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:converters="clr-namespace:Artemis.UI.Shared.Converters;assembly=Artemis.UI.Shared"
|
||||
xmlns:converters1="clr-namespace:Artemis.UI.Converters"
|
||||
xmlns:shared="clr-namespace:Artemis.UI.Shared.Converters;assembly=Artemis.UI.Shared"
|
||||
xmlns:converters="clr-namespace:Artemis.UI.Converters"
|
||||
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight"
|
||||
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="1400"
|
||||
x:Class="Artemis.UI.Screens.Settings.Updating.ReleaseDetailsView"
|
||||
x:DataType="updating:ReleaseDetailsViewModel">
|
||||
<UserControl.Resources>
|
||||
<converters:BytesToStringConverter x:Key="BytesToStringConverter" />
|
||||
<converters1:SubstringConverter x:Key="SubstringConverter" />
|
||||
<shared:BytesToStringConverter x:Key="BytesToStringConverter" />
|
||||
<converters:SubstringConverter x:Key="SubstringConverter" />
|
||||
<converters:DateTimeConverter x:Key="DateTimeConverter" />
|
||||
</UserControl.Resources>
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Grid.info-container">
|
||||
<Setter Property="Margin" Value="10" />
|
||||
</Style>
|
||||
<Style Selector="avalonia1|MaterialIcon.info-icon">
|
||||
<Style Selector="avalonia|MaterialIcon.info-icon">
|
||||
<Setter Property="VerticalAlignment" Value="Top" />
|
||||
<Setter Property="Margin" Value="0 3 10 0" />
|
||||
</Style>
|
||||
@ -103,16 +104,16 @@
|
||||
<Border Classes="card-separator" />
|
||||
<Grid Margin="-5 -10" ColumnDefinitions="*,*,*">
|
||||
<Grid Grid.Column="0" ColumnDefinitions="*,*" RowDefinitions="*,*,*" Classes="info-container" HorizontalAlignment="Left">
|
||||
<avalonia1:MaterialIcon Kind="Calendar" Grid.Column="0" Grid.RowSpan="2" Classes="info-icon" />
|
||||
<avalonia:MaterialIcon Kind="Calendar" Grid.Column="0" Grid.RowSpan="2" Classes="info-icon" />
|
||||
<TextBlock Grid.Column="1" Grid.Row="0" Classes="info-title">Release date</TextBlock>
|
||||
<TextBlock Grid.Column="1"
|
||||
Grid.Row="1"
|
||||
Classes="info-body"
|
||||
Text="{CompiledBinding Release.CreatedAt, StringFormat={}{0:g}, FallbackValue=Loading...}" />
|
||||
Text="{CompiledBinding Release.CreatedAt, Converter={StaticResource DateTimeConverter}, FallbackValue=Loading...}" />
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Column="1" ColumnDefinitions="*,*" RowDefinitions="*,*" Classes="info-container" HorizontalAlignment="Center">
|
||||
<avalonia1:MaterialIcon Kind="Git" Grid.Column="0" Grid.RowSpan="2" Classes="info-icon" />
|
||||
<avalonia:MaterialIcon Kind="Git" Grid.Column="0" Grid.RowSpan="2" Classes="info-icon" />
|
||||
<TextBlock Grid.Column="1" Grid.Row="0" Classes="info-title">Source</TextBlock>
|
||||
<TextBlock Grid.Column="1"
|
||||
Grid.Row="1"
|
||||
@ -123,7 +124,7 @@
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Column="2" ColumnDefinitions="*,*" RowDefinitions="*,*" Classes="info-container" HorizontalAlignment="Right">
|
||||
<avalonia1:MaterialIcon Kind="BoxOutline" Grid.Column="0" Grid.RowSpan="2" Classes="info-icon" />
|
||||
<avalonia:MaterialIcon Kind="BoxOutline" Grid.Column="0" Grid.RowSpan="2" Classes="info-icon" />
|
||||
<TextBlock Grid.Column="1" Grid.Row="0" Classes="info-title">File size</TextBlock>
|
||||
<TextBlock Grid.Column="1"
|
||||
Grid.Row="1"
|
||||
@ -140,11 +141,11 @@
|
||||
<TextBlock Grid.Row="0" Classes="h5 no-margin">Release notes</TextBlock>
|
||||
<Border Grid.Row="1" Classes="card-separator" />
|
||||
|
||||
<avalonia:MarkdownScrollViewer Grid.Row="2" Markdown="{CompiledBinding Release.Changelog}" MarkdownStyleName="FluentAvalonia">
|
||||
<avalonia:MarkdownScrollViewer.Styles>
|
||||
<mdxaml:MarkdownScrollViewer Grid.Row="2" Markdown="{CompiledBinding Release.Changelog}" MarkdownStyleName="FluentAvalonia">
|
||||
<mdxaml:MarkdownScrollViewer.Styles>
|
||||
<StyleInclude Source="/Styles/Markdown.axaml"/>
|
||||
</avalonia:MarkdownScrollViewer.Styles>
|
||||
</avalonia:MarkdownScrollViewer>
|
||||
</mdxaml:MarkdownScrollViewer.Styles>
|
||||
</mdxaml:MarkdownScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
@ -18,7 +18,7 @@ using StrawberryShake;
|
||||
|
||||
namespace Artemis.UI.Screens.Settings.Updating;
|
||||
|
||||
public class ReleaseDetailsViewModel : RoutableScreen<ViewModelBase, ReleaseDetailsViewModelParameters>
|
||||
public class ReleaseDetailsViewModel : RoutableScreen<ReleaseDetailsViewModelParameters>
|
||||
{
|
||||
private readonly ObservableAsPropertyHelper<long> _fileSize;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user