1
0
mirror of https://github.com/Artemis-RGB/Artemis synced 2025-12-12 13:28:33 +00:00

Merge pull request #814 from Artemis-RGB/feature/workshop

Workshop - Initial implementation
This commit is contained in:
RobertBeekman 2023-09-09 20:47:29 +02:00 committed by GitHub
commit ca9ce948c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
262 changed files with 7395 additions and 1968 deletions

View File

@ -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();

View File

@ -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;

View File

@ -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;

View File

@ -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)
{

View File

@ -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;
}

View File

@ -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;

View File

@ -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; }

View 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; }
}

View 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);
}
}

View File

@ -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);
}

View File

@ -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 />

View File

@ -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 />

View File

@ -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" />

View 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();
}
}

View 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();
}

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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));
}
}
}

View File

@ -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);
}

View 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);
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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);
}
}

View File

@ -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.

View File

@ -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");

View File

@ -79,6 +79,7 @@ public partial class RouteSegment
return parameterType switch
{
"guid" => new GuidParameterParser(),
"long" => new LongParameterParser(),
"int" => new IntParameterParser(),
_ => new StringParameterParser()
};

View File

@ -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.

View File

@ -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)

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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

View File

@ -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 />

View File

@ -32,7 +32,7 @@
<TextBox Text="{CompiledBinding Exception, Mode=OneTime}"
AcceptsReturn="True"
IsReadOnly="True"
FontFamily="Consolas"
FontFamily="{StaticResource RobotoMono}"
FontSize="12"
BorderThickness="0" />
</ScrollViewer>

View File

@ -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)

View File

@ -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" />

View File

@ -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">

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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"/>

View File

@ -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">

View File

@ -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>

View 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;
}
}

View 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; }
}

View File

@ -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 />

View File

@ -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>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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;
}
}

View 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;
}
}

View 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());
}
}

View 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)));
}
}

View 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;
}
}

View File

@ -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();
}
}

View File

@ -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>

View 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;
}
}

View File

@ -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")
{

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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)
{

View File

@ -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>

View File

@ -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);
}

View File

@ -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
{

View File

@ -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)

View File

@ -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>

View File

@ -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;
}

View File

@ -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;

View File

@ -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
{

View File

@ -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

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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();
}
}

View File

@ -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");
}
}

View File

@ -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">

View File

@ -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; }
}

View File

@ -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;

View File

@ -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;

View File

@ -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>

View File

@ -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;

View File

@ -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;

View File

@ -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>

View File

@ -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