diff --git a/src/Artemis.UI.Avalonia.Shared/Services/Builders/ContentDialogBuilder.cs b/src/Artemis.UI.Avalonia.Shared/Services/Builders/ContentDialogBuilder.cs new file mode 100644 index 000000000..cdafb5371 --- /dev/null +++ b/src/Artemis.UI.Avalonia.Shared/Services/Builders/ContentDialogBuilder.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Input; +using Avalonia.Controls; +using FluentAvalonia.UI.Controls; +using Ninject; +using Ninject.Parameters; + +namespace Artemis.UI.Avalonia.Shared.Services.Builders +{ + public class ContentDialogBuilder + { + private readonly ContentDialog _contentDialog; + private readonly IKernel _kernel; + private readonly Window _parent; + + internal ContentDialogBuilder(IKernel kernel, Window parent) + { + _kernel = kernel; + _parent = parent; + _contentDialog = new ContentDialog + { + CloseButtonText = "CLose" + }; + } + + public ContentDialogBuilder WithTitle(string? title) + { + _contentDialog.Title = title; + return this; + } + + public ContentDialogBuilder WithContent(string? content) + { + _contentDialog.Content = content; + return this; + } + + public ContentDialogBuilder WithDefaultButton(ContentDialogButton defaultButton) + { + _contentDialog.DefaultButton = defaultButton; + return this; + } + + public ContentDialogBuilder HavingPrimaryButton(Action configure) + { + ContentDialogButtonBuilder builder = new(); + configure(builder); + + _contentDialog.IsPrimaryButtonEnabled = true; + _contentDialog.PrimaryButtonText = builder.Text; + _contentDialog.PrimaryButtonCommand = builder.Command; + _contentDialog.PrimaryButtonCommandParameter = builder.CommandParameter; + + return this; + } + + public ContentDialogBuilder HavingSecondaryButton(Action configure) + { + ContentDialogButtonBuilder builder = new(); + configure(builder); + + _contentDialog.IsSecondaryButtonEnabled = true; + _contentDialog.SecondaryButtonText = builder.Text; + _contentDialog.SecondaryButtonCommand = builder.Command; + _contentDialog.SecondaryButtonCommandParameter = builder.CommandParameter; + + return this; + } + + public ContentDialogBuilder WithCloseButtonText(string? text) + { + _contentDialog.CloseButtonText = text; + return this; + } + + public ContentDialogBuilder WithViewModel(params (string name, object value)[] parameters) + { + if (parameters.Length != 0) + { + IParameter[] paramsArray = parameters.Select(kv => new ConstructorArgument(kv.name, kv.value)).Cast().ToArray(); + _contentDialog.Content = _kernel.Get(paramsArray); + } + else + _contentDialog.Content = _kernel.Get(); + + return this; + } + + public async Task ShowAsync() + { + if (_parent.Content is not Panel panel) + return ContentDialogResult.None; + + try + { + panel.Children.Add(_contentDialog); + return await _contentDialog.ShowAsync(); + } + finally + { + panel.Children.Remove(_contentDialog); + } + } + } + + public class ContentDialogButtonBuilder + { + internal ContentDialogButtonBuilder() + { + } + + internal string? Text { get; set; } + internal ICommand? Command { get; set; } + internal object? CommandParameter { get; set; } + + public ContentDialogButtonBuilder WithText(string? text) + { + Text = text; + return this; + } + + public ContentDialogButtonBuilder WithCommand(ICommand? command) + { + Command = command; + return this; + } + + public ContentDialogButtonBuilder WithCommandParameter(object? commandParameter) + { + CommandParameter = commandParameter; + return this; + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia.Shared/Services/Builders/FileDialogFilterBuilder.cs b/src/Artemis.UI.Avalonia.Shared/Services/Builders/FileDialogFilterBuilder.cs new file mode 100644 index 000000000..03cd4c7bf --- /dev/null +++ b/src/Artemis.UI.Avalonia.Shared/Services/Builders/FileDialogFilterBuilder.cs @@ -0,0 +1,40 @@ +using Avalonia.Controls; + +namespace Artemis.UI.Avalonia.Shared.Services.Builders +{ + /// + /// Represents a builder that can create a . + /// + public class FileDialogFilterBuilder + { + private readonly FileDialogFilter _filter; + + internal FileDialogFilterBuilder() + { + _filter = new FileDialogFilter(); + } + + /// + /// Sets the name of the filter + /// + public FileDialogFilterBuilder WithName(string name) + { + _filter.Name = name; + return this; + } + + /// + /// Adds the provided extension to the filter + /// + public FileDialogFilterBuilder WithExtension(string extension) + { + _filter.Extensions.Add(extension); + return this; + } + + internal FileDialogFilter Build() + { + return _filter; + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia.Shared/Services/Builders/NotificationBuilder.cs b/src/Artemis.UI.Avalonia.Shared/Services/Builders/NotificationBuilder.cs new file mode 100644 index 000000000..5b3379a9c --- /dev/null +++ b/src/Artemis.UI.Avalonia.Shared/Services/Builders/NotificationBuilder.cs @@ -0,0 +1,81 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Threading; +using FluentAvalonia.UI.Controls; + +namespace Artemis.UI.Avalonia.Shared.Services.Builders +{ + public class NotificationBuilder + { + private readonly InfoBar _infoBar; + private readonly Window _parent; + private TimeSpan _timeout = TimeSpan.FromSeconds(3); + + public NotificationBuilder(Window parent) + { + _parent = parent; + _infoBar = new InfoBar {Classes = Classes.Parse("notification-info-bar")}; + } + + public NotificationBuilder WithTitle(string? title) + { + _infoBar.Title = title; + return this; + } + + public NotificationBuilder WithMessage(string? content) + { + _infoBar.Message = content; + return this; + } + + public NotificationBuilder WithTimeout(TimeSpan timeout) + { + _timeout = timeout; + return this; + } + + public NotificationBuilder WithSeverity(NotificationSeverity severity) + { + _infoBar.Severity = (InfoBarSeverity) severity; + return this; + } + + public void Show() + { + if (_parent.Content is not Panel panel) + return; + + Dispatcher.UIThread.Post(() => + { + panel.Children.Add(_infoBar); + _infoBar.Closed += InfoBarOnClosed; + _infoBar.IsOpen = true; + }); + + Task.Run(async () => + { + await Task.Delay(_timeout); + 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); + } + } + + public enum NotificationSeverity + { + Informational, + Success, + Warning, + Error + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia.Shared/Services/Builders/OpenFileDialogBuilder.cs b/src/Artemis.UI.Avalonia.Shared/Services/Builders/OpenFileDialogBuilder.cs new file mode 100644 index 000000000..f53729300 --- /dev/null +++ b/src/Artemis.UI.Avalonia.Shared/Services/Builders/OpenFileDialogBuilder.cs @@ -0,0 +1,81 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Controls; + +namespace Artemis.UI.Avalonia.Shared.Services.Builders +{ + /// + /// Represents a builder that can create a . + /// + public class OpenFileDialogBuilder + { + private readonly OpenFileDialog _openFileDialog; + private readonly Window _parent; + + public OpenFileDialogBuilder(Window parent) + { + _parent = parent; + _openFileDialog = new OpenFileDialog(); + } + + /// + /// Indicate that the user can select multiple files. + /// + public OpenFileDialogBuilder WithAllowMultiple() + { + _openFileDialog.AllowMultiple = true; + return this; + } + + /// + /// Set the title of the dialog + /// + public OpenFileDialogBuilder WithTitle(string? title) + { + _openFileDialog.Title = title; + return this; + } + + /// + /// Set the initial directory of the dialog + /// + public OpenFileDialogBuilder WithDirectory(string? directory) + { + _openFileDialog.Directory = directory; + return this; + } + + /// + /// Set the initial file name of the dialog + /// + public OpenFileDialogBuilder WithInitialFileName(string? initialFileName) + { + _openFileDialog.InitialFileName = initialFileName; + return this; + } + + /// + /// Add a filter to the dialog + /// + public OpenFileDialogBuilder HavingFilter(Action configure) + { + FileDialogFilterBuilder builder = new(); + configure(builder); + _openFileDialog.Filters.Add(builder.Build()); + + return this; + } + + /// + /// Shows the file dialog + /// + /// + /// A task that on completion returns an array containing the full path to the selected + /// files, or null if the dialog was canceled. + /// + public async Task ShowAsync() + { + return await _openFileDialog.ShowAsync(_parent); + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia.Shared/Services/Builders/SaveFileDialogBuilder.cs b/src/Artemis.UI.Avalonia.Shared/Services/Builders/SaveFileDialogBuilder.cs new file mode 100644 index 000000000..c8547b9cf --- /dev/null +++ b/src/Artemis.UI.Avalonia.Shared/Services/Builders/SaveFileDialogBuilder.cs @@ -0,0 +1,81 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Controls; + +namespace Artemis.UI.Avalonia.Shared.Services.Builders +{ + /// + /// Represents a builder that can create a . + /// + public class SaveFileDialogBuilder + { + private readonly Window _parent; + private readonly SaveFileDialog _saveFileDialog; + + public SaveFileDialogBuilder(Window parent) + { + _parent = parent; + _saveFileDialog = new SaveFileDialog(); + } + + /// + /// Set the title of the dialog + /// + public SaveFileDialogBuilder WithTitle(string? title) + { + _saveFileDialog.Title = title; + return this; + } + + /// + /// Set the initial directory of the dialog + /// + public SaveFileDialogBuilder WithDirectory(string? directory) + { + _saveFileDialog.Directory = directory; + return this; + } + + /// + /// Set the initial file name of the dialog + /// + public SaveFileDialogBuilder WithInitialFileName(string? initialFileName) + { + _saveFileDialog.InitialFileName = initialFileName; + return this; + } + + /// + /// Set the default extension of the dialog + /// + public SaveFileDialogBuilder WithDefaultExtension(string? defaultExtension) + { + _saveFileDialog.DefaultExtension = defaultExtension; + return this; + } + + /// + /// Add a filter to the dialog + /// + public SaveFileDialogBuilder HavingFilter(Action configure) + { + FileDialogFilterBuilder builder = new(); + configure(builder); + _saveFileDialog.Filters.Add(builder.Build()); + + return this; + } + + /// + /// Shows the save file dialog. + /// + /// + /// A task that on completion contains the full path of the save location, or null if the + /// dialog was canceled. + /// + public async Task ShowAsync() + { + return await _saveFileDialog.ShowAsync(_parent); + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia.Shared/Services/Interfaces/INotificationService.cs b/src/Artemis.UI.Avalonia.Shared/Services/Interfaces/INotificationService.cs new file mode 100644 index 000000000..6249079b8 --- /dev/null +++ b/src/Artemis.UI.Avalonia.Shared/Services/Interfaces/INotificationService.cs @@ -0,0 +1,9 @@ +using Artemis.UI.Avalonia.Shared.Services.Builders; + +namespace Artemis.UI.Avalonia.Shared.Services.Interfaces +{ + public interface INotificationService : IArtemisSharedUIService + { + NotificationBuilder CreateNotification(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia.Shared/Services/Interfaces/IWindowService.cs b/src/Artemis.UI.Avalonia.Shared/Services/Interfaces/IWindowService.cs index 99f2aaae6..e965ee66e 100644 --- a/src/Artemis.UI.Avalonia.Shared/Services/Interfaces/IWindowService.cs +++ b/src/Artemis.UI.Avalonia.Shared/Services/Interfaces/IWindowService.cs @@ -1,9 +1,16 @@ using System.Threading.Tasks; +using Artemis.UI.Avalonia.Shared.Services.Builders; +using Avalonia.Controls; namespace Artemis.UI.Avalonia.Shared.Services.Interfaces { public interface IWindowService : IArtemisSharedUIService { + /// + /// Creates a view model instance of type and shows its corresponding View as a window + /// + /// The type of view model to create + /// The created view model T ShowWindow(); /// @@ -13,11 +20,27 @@ namespace Artemis.UI.Avalonia.Shared.Services.Interfaces void ShowWindow(object viewModel); /// - /// Given a ViewModel, show its corresponding View as a Dialog + /// Given a ViewModel, show its corresponding View as a Dialog /// /// The return type /// ViewModel to show the View for - /// A task containing the return value of type - Task ShowDialog(object viewModel); + /// A task containing the return value of type + Task ShowDialogAsync(object viewModel); + + /// + /// Creates an open file dialog, use the fluent API to configure it + /// + /// The builder that can be used to configure the dialog + OpenFileDialogBuilder CreateOpenFileDialog(); + + /// + /// Creates a save file dialog, use the fluent API to configure it + /// + /// The builder that can be used to configure the dialog + SaveFileDialogBuilder CreateSaveFileDialog(); + + ContentDialogBuilder CreateContentDialog(); + + Window GetCurrentWindow(); } } \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia.Shared/Services/NotificationService.cs b/src/Artemis.UI.Avalonia.Shared/Services/NotificationService.cs new file mode 100644 index 000000000..16aeb45c4 --- /dev/null +++ b/src/Artemis.UI.Avalonia.Shared/Services/NotificationService.cs @@ -0,0 +1,20 @@ +using Artemis.UI.Avalonia.Shared.Services.Builders; +using Artemis.UI.Avalonia.Shared.Services.Interfaces; + +namespace Artemis.UI.Avalonia.Shared.Services +{ + public class NotificationService : INotificationService + { + private readonly IWindowService _windowService; + + public NotificationService(IWindowService windowService) + { + _windowService = windowService; + } + + public NotificationBuilder CreateNotification() + { + return new NotificationBuilder(_windowService.GetCurrentWindow()); + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia.Shared/Services/WindowService.cs b/src/Artemis.UI.Avalonia.Shared/Services/WindowService.cs index e0174a9e3..9ea522b6e 100644 --- a/src/Artemis.UI.Avalonia.Shared/Services/WindowService.cs +++ b/src/Artemis.UI.Avalonia.Shared/Services/WindowService.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using Artemis.UI.Avalonia.Shared.Exceptions; +using Artemis.UI.Avalonia.Shared.Services.Builders; using Artemis.UI.Avalonia.Shared.Services.Interfaces; using Avalonia; using Avalonia.Controls; @@ -19,9 +20,6 @@ namespace Artemis.UI.Avalonia.Shared.Services _kernel = kernel; } - #region Implementation of IWindowService - - /// public T ShowWindow() { T viewModel = _kernel.Get()!; @@ -29,7 +27,6 @@ namespace Artemis.UI.Avalonia.Shared.Services return viewModel; } - /// public void ShowWindow(object viewModel) { string name = viewModel.GetType().FullName!.Split('`')[0].Replace("ViewModel", "View"); @@ -45,8 +42,7 @@ namespace Artemis.UI.Avalonia.Shared.Services window.Show(); } - /// - public async Task ShowDialog(object viewModel) + public async Task ShowDialogAsync(object viewModel) { if (Application.Current.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime classic) throw new ArtemisSharedUIException($"Can't show a dialog when application lifetime is not IClassicDesktopStyleApplicationLifetime."); @@ -65,6 +61,28 @@ namespace Artemis.UI.Avalonia.Shared.Services return await window.ShowDialog(parent); } - #endregion + public ContentDialogBuilder CreateContentDialog() + { + return new ContentDialogBuilder(_kernel, GetCurrentWindow()); + } + + public OpenFileDialogBuilder CreateOpenFileDialog() + { + return new OpenFileDialogBuilder(GetCurrentWindow()); + } + + public SaveFileDialogBuilder CreateSaveFileDialog() + { + return new SaveFileDialogBuilder(GetCurrentWindow()); + } + + public Window GetCurrentWindow() + { + if (Application.Current.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime classic) + throw new ArtemisSharedUIException("Can't show a dialog when application lifetime is not IClassicDesktopStyleApplicationLifetime."); + + Window parent = classic.Windows.FirstOrDefault(w => w.IsActive) ?? classic.MainWindow; + return parent; + } } } \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia.Shared/Styles/InfoBar.axaml b/src/Artemis.UI.Avalonia.Shared/Styles/InfoBar.axaml new file mode 100644 index 000000000..10bb2febd --- /dev/null +++ b/src/Artemis.UI.Avalonia.Shared/Styles/InfoBar.axaml @@ -0,0 +1,19 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia/App.axaml b/src/Artemis.UI.Avalonia/App.axaml index bce72106b..db7e15f0f 100644 --- a/src/Artemis.UI.Avalonia/App.axaml +++ b/src/Artemis.UI.Avalonia/App.axaml @@ -22,5 +22,9 @@ + + + + \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia/Exceptions/ArtemisGraphicsContextException.cs b/src/Artemis.UI.Avalonia/Exceptions/ArtemisGraphicsContextException.cs new file mode 100644 index 000000000..dca0fd609 --- /dev/null +++ b/src/Artemis.UI.Avalonia/Exceptions/ArtemisGraphicsContextException.cs @@ -0,0 +1,22 @@ +using System; + +namespace Artemis.UI.Avalonia.Exceptions +{ + public class ArtemisGraphicsContextException : Exception + { + /// + public ArtemisGraphicsContextException() + { + } + + /// + public ArtemisGraphicsContextException(string message) : base(message) + { + } + + /// + public ArtemisGraphicsContextException(string message, Exception innerException) : base(message, innerException) + { + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia/Exceptions/ArtemisUIException.cs b/src/Artemis.UI.Avalonia/Exceptions/ArtemisUIException.cs new file mode 100644 index 000000000..df8b6464c --- /dev/null +++ b/src/Artemis.UI.Avalonia/Exceptions/ArtemisUIException.cs @@ -0,0 +1,19 @@ +using System; + +namespace Artemis.UI.Avalonia.Exceptions +{ + public class ArtemisUIException : Exception + { + public ArtemisUIException() + { + } + + public ArtemisUIException(string message) : base(message) + { + } + + public ArtemisUIException(string message, Exception inner) : base(message, inner) + { + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia/Screens/Device/ViewModels/DeviceInfoTabViewModel.cs b/src/Artemis.UI.Avalonia/Screens/Device/ViewModels/DeviceInfoTabViewModel.cs new file mode 100644 index 000000000..0e8ffadb6 --- /dev/null +++ b/src/Artemis.UI.Avalonia/Screens/Device/ViewModels/DeviceInfoTabViewModel.cs @@ -0,0 +1,29 @@ +using System.Threading.Tasks; +using Artemis.Core; +using Avalonia; +using RGB.NET.Core; + +namespace Artemis.UI.Avalonia.Screens.Device.ViewModels +{ + public class DeviceInfoTabViewModel : ActivatableViewModelBase + { + public DeviceInfoTabViewModel(ArtemisDevice device) + { + Device = device; + DisplayName = "INFO"; + + DefaultLayoutPath = Device.DeviceProvider.LoadLayout(Device).FilePath; + } + + public bool IsKeyboard => Device.DeviceType == RGBDeviceType.Keyboard; + public ArtemisDevice Device { get; } + + public string DefaultLayoutPath { get; } + + public async Task CopyToClipboard(string content) + { + await Application.Current.Clipboard.SetTextAsync(content); + // ((DeviceDialogViewModel) Parent).DeviceMessageQueue.Enqueue("Copied path to clipboard."); + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia/Screens/Device/ViewModels/DeviceLedsTabViewModel.cs b/src/Artemis.UI.Avalonia/Screens/Device/ViewModels/DeviceLedsTabViewModel.cs new file mode 100644 index 000000000..b2c44ea2f --- /dev/null +++ b/src/Artemis.UI.Avalonia/Screens/Device/ViewModels/DeviceLedsTabViewModel.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; +using System.Reactive.Disposables; +using Artemis.Core; +using DynamicData.Binding; +using ReactiveUI; + +namespace Artemis.UI.Avalonia.Screens.Device.ViewModels +{ + public class DeviceLedsTabViewModel : ActivatableViewModelBase + { + private readonly DevicePropertiesViewModel _devicePropertiesViewModel; + + public DeviceLedsTabViewModel(ArtemisDevice device, DevicePropertiesViewModel devicePropertiesViewModel) + { + _devicePropertiesViewModel = devicePropertiesViewModel; + + Device = device; + DisplayName = "LEDS"; + LedViewModels = new ObservableCollection(Device.Leds.Select(l => new DeviceLedsTabLedViewModel(l, _devicePropertiesViewModel.SelectedLeds))); + + this.WhenActivated(disposables => _devicePropertiesViewModel.SelectedLeds.ToObservableChangeSet().Subscribe(_ => UpdateSelectedLeds()).DisposeWith(disposables)); + } + + public ArtemisDevice Device { get; } + public ObservableCollection LedViewModels { get; } + + private void SelectedLedsOnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + UpdateSelectedLeds(); + } + + private void UpdateSelectedLeds() + { + foreach (DeviceLedsTabLedViewModel deviceLedsTabLedViewModel in LedViewModels) + deviceLedsTabLedViewModel.Update(); + } + } + + public class DeviceLedsTabLedViewModel : ViewModelBase + { + private readonly ObservableCollection _selectedLeds; + private bool _isSelected; + + public DeviceLedsTabLedViewModel(ArtemisLed artemisLed, ObservableCollection selectedLeds) + { + _selectedLeds = selectedLeds; + ArtemisLed = artemisLed; + + Update(); + } + + public ArtemisLed ArtemisLed { get; } + + public bool IsSelected + { + get => _isSelected; + set + { + if (!this.RaiseAndSetIfChanged(ref _isSelected, value)) + return; + Apply(); + } + } + + public void Update() + { + IsSelected = _selectedLeds.Contains(ArtemisLed); + } + + public void Apply() + { + if (IsSelected && !_selectedLeds.Contains(ArtemisLed)) + _selectedLeds.Add(ArtemisLed); + else if (!IsSelected && _selectedLeds.Contains(ArtemisLed)) + _selectedLeds.Remove(ArtemisLed); + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia/Screens/Device/ViewModels/DevicePropertiesTabViewModel.cs b/src/Artemis.UI.Avalonia/Screens/Device/ViewModels/DevicePropertiesTabViewModel.cs new file mode 100644 index 000000000..e3abe2e7d --- /dev/null +++ b/src/Artemis.UI.Avalonia/Screens/Device/ViewModels/DevicePropertiesTabViewModel.cs @@ -0,0 +1,270 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading.Tasks; +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.UI.Avalonia.Shared.Services.Builders; +using Artemis.UI.Avalonia.Shared.Services.Interfaces; +using FluentAvalonia.UI.Controls; +using ReactiveUI; +using SkiaSharp; + +namespace Artemis.UI.Avalonia.Screens.Device.ViewModels +{ + public class DevicePropertiesTabViewModel : ActivatableViewModelBase + { + private readonly List _categories; + private readonly ICoreService _coreService; + private readonly float _initialBlueScale; + private readonly float _initialGreenScale; + private readonly float _initialRedScale; + private readonly IRgbService _rgbService; + private readonly IWindowService _windowService; + private readonly INotificationService _notificationService; + private float _blueScale; + private SKColor _currentColor; + private bool _displayOnDevices; + private float _greenScale; + private float _redScale; + private int _rotation; + private float _scale; + private int _x; + private int _y; + + public DevicePropertiesTabViewModel(ArtemisDevice device, ICoreService coreService, IRgbService rgbService, IWindowService windowService, INotificationService notificationService) + { + _coreService = coreService; + _rgbService = rgbService; + _windowService = windowService; + _notificationService = notificationService; + _categories = new List(device.Categories); + + Device = device; + DisplayName = "PROPERTIES"; + + X = (int) Device.X; + Y = (int) Device.Y; + Scale = Device.Scale; + Rotation = (int) Device.Rotation; + RedScale = Device.RedScale * 100f; + GreenScale = Device.GreenScale * 100f; + BlueScale = Device.BlueScale * 100f; + CurrentColor = SKColors.White; + + // We need to store the initial values to be able to restore them when the user clicks "Cancel" + _initialRedScale = Device.RedScale; + _initialGreenScale = Device.GreenScale; + _initialBlueScale = Device.BlueScale; + } + + public ArtemisDevice Device { get; } + + public int X + { + get => _x; + set => this.RaiseAndSetIfChanged(ref _x, value); + } + + public int Y + { + get => _y; + set => this.RaiseAndSetIfChanged(ref _y, value); + } + + public float Scale + { + get => _scale; + set => this.RaiseAndSetIfChanged(ref _scale, value); + } + + public int Rotation + { + get => _rotation; + set => this.RaiseAndSetIfChanged(ref _rotation, value); + } + + public float RedScale + { + get => _redScale; + set => this.RaiseAndSetIfChanged(ref _redScale, value); + } + + public float GreenScale + { + get => _greenScale; + set => this.RaiseAndSetIfChanged(ref _greenScale, value); + } + + public float BlueScale + { + get => _blueScale; + set => this.RaiseAndSetIfChanged(ref _blueScale, value); + } + + public SKColor CurrentColor + { + get => _currentColor; + set => this.RaiseAndSetIfChanged(ref _currentColor, value); + } + + public bool DisplayOnDevices + { + get => _displayOnDevices; + set => this.RaiseAndSetIfChanged(ref _displayOnDevices, value); + } + + // This solution won't scale well but I don't expect there to be many more categories. + // If for some reason there will be, dynamically creating a view model per category may be more appropriate + public bool HasDeskCategory + { + get => GetCategory(DeviceCategory.Desk); + set => SetCategory(DeviceCategory.Desk, value); + } + + public bool HasMonitorCategory + { + get => GetCategory(DeviceCategory.Monitor); + set => SetCategory(DeviceCategory.Monitor, value); + } + + public bool HasCaseCategory + { + get => GetCategory(DeviceCategory.Case); + set => SetCategory(DeviceCategory.Case, value); + } + + public bool HasRoomCategory + { + get => GetCategory(DeviceCategory.Room); + set => SetCategory(DeviceCategory.Room, value); + } + + public bool HasPeripheralsCategory + { + get => GetCategory(DeviceCategory.Peripherals); + set => SetCategory(DeviceCategory.Peripherals, value); + } + + public void ApplyScaling() + { + Device.RedScale = RedScale / 100f; + Device.GreenScale = GreenScale / 100f; + Device.BlueScale = BlueScale / 100f; + + _rgbService.FlushLeds = true; + } + + public void ClearCustomLayout() + { + Device.CustomLayoutPath = null; + _notificationService.CreateNotification() + .WithMessage("Cleared imported layout.") + .WithSeverity(NotificationSeverity.Informational); + } + + public async Task BrowseCustomLayout() + { + string[]? files = await _windowService.CreateOpenFileDialog() + .WithTitle("Select device layout file") + .HavingFilter(f => f.WithName("Layout files").WithExtension("xml")) + .ShowAsync(); + + if (files?.Length > 0) + { + Device.CustomLayoutPath = files[0]; + _notificationService.CreateNotification() + .WithTitle("Imported layout") + .WithMessage($"File loaded from {files[0]}") + .WithSeverity(NotificationSeverity.Informational); + } + } + + public async Task SelectPhysicalLayout() + { + // await _windowService.CreateContentDialog() + // .WithTitle("Select layout") + // .WithViewModel(("device", Device)) + // .ShowAsync(); + } + + public async Task Apply() + { + // TODO: Validation + + _coreService.ProfileRenderingDisabled = true; + await Task.Delay(100); + + Device.X = X; + Device.Y = Y; + Device.Scale = Scale; + Device.Rotation = Rotation; + Device.RedScale = RedScale / 100f; + Device.GreenScale = GreenScale / 100f; + Device.BlueScale = BlueScale / 100f; + Device.Categories.Clear(); + foreach (DeviceCategory deviceCategory in _categories) + Device.Categories.Add(deviceCategory); + + _rgbService.SaveDevice(Device); + + _coreService.ProfileRenderingDisabled = false; + } + + public void Reset() + { + HasDeskCategory = Device.Categories.Contains(DeviceCategory.Desk); + HasMonitorCategory = Device.Categories.Contains(DeviceCategory.Monitor); + HasCaseCategory = Device.Categories.Contains(DeviceCategory.Case); + HasRoomCategory = Device.Categories.Contains(DeviceCategory.Room); + HasPeripheralsCategory = Device.Categories.Contains(DeviceCategory.Peripherals); + + Device.RedScale = _initialRedScale; + Device.GreenScale = _initialGreenScale; + Device.BlueScale = _initialBlueScale; + } + + protected void OnActivate() + { + _coreService.FrameRendering += OnFrameRendering; + Device.PropertyChanged += DeviceOnPropertyChanged; + } + + protected void OnDeactivate() + { + _coreService.FrameRendering -= OnFrameRendering; + Device.PropertyChanged -= DeviceOnPropertyChanged; + } + + private bool GetCategory(DeviceCategory category) + { + return _categories.Contains(category); + } + + private void SetCategory(DeviceCategory category, bool value) + { + if (value && !_categories.Contains(category)) + _categories.Add(category); + else + _categories.Remove(category); + + this.RaisePropertyChanged($"Has{category}Category"); + } + + private void DeviceOnPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Device.CustomLayoutPath) || e.PropertyName == nameof(Device.DisableDefaultLayout)) Task.Run(() => _rgbService.ApplyBestDeviceLayout(Device)); + } + + private void OnFrameRendering(object sender, FrameRenderingEventArgs e) + { + if (!_displayOnDevices) + return; + + using SKPaint overlayPaint = new() + { + Color = CurrentColor + }; + e.Canvas.DrawRect(0, 0, e.Canvas.LocalClipBounds.Width, e.Canvas.LocalClipBounds.Height, overlayPaint); + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia/Screens/Device/ViewModels/DevicePropertiesViewModel.cs b/src/Artemis.UI.Avalonia/Screens/Device/ViewModels/DevicePropertiesViewModel.cs index efb0d6c67..70c0aad7c 100644 --- a/src/Artemis.UI.Avalonia/Screens/Device/ViewModels/DevicePropertiesViewModel.cs +++ b/src/Artemis.UI.Avalonia/Screens/Device/ViewModels/DevicePropertiesViewModel.cs @@ -1,4 +1,5 @@ -using Artemis.Core; +using System.Collections.ObjectModel; +using Artemis.Core; namespace Artemis.UI.Avalonia.Screens.Device.ViewModels { @@ -10,5 +11,6 @@ namespace Artemis.UI.Avalonia.Screens.Device.ViewModels } public ArtemisDevice Device { get; } + public ObservableCollection SelectedLeds { get; } = new(); } } \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia/Screens/Device/ViewModels/InputMappingsTabViewModel.cs b/src/Artemis.UI.Avalonia/Screens/Device/ViewModels/InputMappingsTabViewModel.cs new file mode 100644 index 000000000..1226f17f7 --- /dev/null +++ b/src/Artemis.UI.Avalonia/Screens/Device/ViewModels/InputMappingsTabViewModel.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.UI.Avalonia.Exceptions; +using ReactiveUI; +using RGB.NET.Core; + +namespace Artemis.UI.Avalonia.Screens.Device.ViewModels +{ + public class InputMappingsTabViewModel : ActivatableViewModelBase + { + private readonly IRgbService _rgbService; + private readonly IInputService _inputService; + private ArtemisLed _selectedLed; + + public InputMappingsTabViewModel(ArtemisDevice device, IRgbService rgbService, IInputService inputService) + { + if (device.DeviceType != RGBDeviceType.Keyboard) + throw new ArtemisUIException("The input mappings tab only supports keyboards"); + _rgbService = rgbService; + _inputService = inputService; + + Device = device; + DisplayName = "INPUT MAPPINGS"; + InputMappings = new ObservableCollection<(ArtemisLed, ArtemisLed)>(); + } + + public ArtemisDevice Device { get; } + + public ArtemisLed SelectedLed + { + get => _selectedLed; + set => this.RaiseAndSetIfChanged(ref _selectedLed, value); + } + + public ObservableCollection<(ArtemisLed, ArtemisLed)> InputMappings { get; } + + public void DeleteMapping(Tuple inputMapping) + { + Device.InputMappings.Remove(inputMapping.Item1); + UpdateInputMappings(); + } + + private void InputServiceOnKeyboardKeyUp(object sender, ArtemisKeyboardKeyEventArgs e) + { + if (SelectedLed == null || e.Led == null) + return; + + // Locate the original LED the same way the InputService did it, but supply false to Device.GetLed + bool foundLedId = InputKeyUtilities.KeyboardKeyLedIdMap.TryGetValue(e.Key, out LedId ledId); + if (!foundLedId) + return; + ArtemisLed artemisLed = Device.GetLed(ledId, false); + if (artemisLed == null) + return; + + // Apply the new LED mapping + Device.InputMappings[SelectedLed] = artemisLed; + _rgbService.SaveDevice(Device); + // ((DeviceDialogViewModel) Parent).SelectedLeds.Clear(); + + UpdateInputMappings(); + } + + private void UpdateInputMappings() + { + if (InputMappings.Any()) + InputMappings.Clear(); + + // InputMappings.AddRange(Device.InputMappings.Select(m => new Tuple(m.Key, m.Value))); + } + + private void SelectedLedsOnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + // SelectedLed = ((DeviceDialogViewModel) Parent).SelectedLeds.FirstOrDefault(); + } + // + // #region Overrides of Screen + // + // /// + // protected override void OnActivate() + // { + // UpdateInputMappings(); + // _inputService.KeyboardKeyUp += InputServiceOnKeyboardKeyUp; + // ((DeviceDialogViewModel) Parent).SelectedLeds.CollectionChanged += SelectedLedsOnCollectionChanged; + // + // base.OnActivate(); + // } + // + // /// + // protected override void OnDeactivate() + // { + // InputMappings.Clear(); + // _inputService.KeyboardKeyUp -= InputServiceOnKeyboardKeyUp; + // ((DeviceDialogViewModel) Parent).SelectedLeds.CollectionChanged -= SelectedLedsOnCollectionChanged; + // base.OnDeactivate(); + // } + // + // #endregion + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Avalonia/Screens/SurfaceEditor/ViewModels/SurfaceDeviceViewModel.cs b/src/Artemis.UI.Avalonia/Screens/SurfaceEditor/ViewModels/SurfaceDeviceViewModel.cs index 1deb7414d..eb4e66260 100644 --- a/src/Artemis.UI.Avalonia/Screens/SurfaceEditor/ViewModels/SurfaceDeviceViewModel.cs +++ b/src/Artemis.UI.Avalonia/Screens/SurfaceEditor/ViewModels/SurfaceDeviceViewModel.cs @@ -105,7 +105,7 @@ namespace Artemis.UI.Avalonia.Screens.SurfaceEditor.ViewModels private async Task ExecuteViewProperties(ArtemisDevice device) { - await _windowService.ShowDialog(_deviceVmFactory.DevicePropertiesViewModel(device)); + await _windowService.ShowDialogAsync(_deviceVmFactory.DevicePropertiesViewModel(device)); } private bool Fits(float x, float y)