1
0
mirror of https://github.com/Artemis-RGB/Artemis synced 2025-12-13 05:48:35 +00:00

Memory improvements

Increased Corsair key position accuracy
Drastically reduced memory usage
This commit is contained in:
SpoinkyNL 2016-10-30 00:39:05 +02:00
parent a9194e6906
commit a982e28615
9 changed files with 219 additions and 218 deletions

View File

@ -23,40 +23,28 @@ namespace Artemis.DAL
private static readonly string ProfileFolder =
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) + @"\Artemis\profiles";
private static readonly List<ProfileModel> Profiles = new List<ProfileModel>();
private static bool _installedDefaults;
/// <summary>
/// Get all profiles
/// </summary>
/// <returns>All profiles</returns>
public static List<ProfileModel> GetAll()
static ProfileProvider()
{
lock (Profiles)
{
if (!Profiles.Any())
ReadProfiles();
// Return a new list, this'll make sure removing/updating the retrieved list doesn't
// affect the datastore
return Profiles.ToList();
}
CheckProfiles();
InstallDefaults();
}
/// <summary>
/// Get all profiles matching the provided game
/// </summary>
/// <param name="game">The game to match</param>
/// <param name="keyboard">The keyboard to match</param>
/// <returns>All profiles matching the provided game</returns>
public static List<ProfileModel> GetAll(EffectModel game, KeyboardProvider keyboard)
public static List<string> GetProfileNames(KeyboardProvider keyboard, EffectModel effect)
{
if (game == null)
throw new ArgumentNullException(nameof(game));
if (keyboard == null)
throw new ArgumentNullException(nameof(keyboard));
return ReadProfiles(keyboard.Slug + "/" + effect.Name).Select(p => p.Name).ToList();
}
return GetAll().Where(g => g.GameName.Equals(game.Name) && g.KeyboardSlug.Equals(keyboard.Slug)).ToList();
public static ProfileModel GetProfile(KeyboardProvider keyboard, EffectModel effect, string name)
{
return ReadProfiles(keyboard.Slug + "/" + effect.Name).FirstOrDefault(p => p.Name == name);
}
public static bool IsProfileUnique(ProfileModel profileModel)
{
var existing = ReadProfiles(profileModel.KeyboardSlug + "/" + profileModel.GameName);
return existing.Contains(profileModel);
}
/// <summary>
@ -69,12 +57,6 @@ namespace Artemis.DAL
if (prof == null)
throw new ArgumentNullException(nameof(prof));
lock (Profiles)
{
if (!Profiles.Contains(prof))
Profiles.Add(prof);
}
lock (prof)
{
// Store the file
@ -103,100 +85,30 @@ namespace Artemis.DAL
}
}
private static void ReadProfiles()
{
CheckProfiles();
InstallDefaults();
lock (Profiles)
{
Profiles.Clear();
// Create the directory structure
var profilePaths = Directory.GetFiles(ProfileFolder, "*.json", SearchOption.AllDirectories);
// Parse the JSON files into objects and add them if they are valid
foreach (var path in profilePaths)
{
try
{
var prof = LoadProfileIfValid(path);
if (prof == null)
continue;
// Only add unique profiles
if (Profiles.Any(p => p.GameName == prof.GameName && p.Name == prof.Name &&
p.KeyboardSlug == prof.KeyboardSlug))
{
Logger.Info("Didn't load duplicate profile: {0}", path);
}
else
{
Profiles.Add(prof);
}
}
catch (Exception e)
{
Logger.Error("Failed to load profile: {0} - {1}", path, e);
}
}
}
}
/// <summary>
/// Unpacks the default profiles into the profile directory
/// Renames the profile on the model and filesystem
/// </summary>
private static void InstallDefaults()
/// <param name="profile">The profile to rename</param>
/// <param name="name">The new name</param>
public static void RenameProfile(ProfileModel profile, string name)
{
// Only install the defaults once per session
if (_installedDefaults)
if (string.IsNullOrEmpty(name))
return;
_installedDefaults = true;
// Load the ZIP from resources
var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("Artemis.Resources.Keyboards.default-profiles.zip");
// Remove the old profile
DeleteProfile(profile);
// Extract it over the old defaults in case one was updated
if (stream == null)
return;
var archive = new ZipArchive(stream);
archive.ExtractToDirectory(ProfileFolder, true);
var demoProfiles = Profiles.Where(d => d.Name == "Demo (duplicate to keep changes)");
InsertGif(demoProfiles, "GIF", Resources.demo_gif, "demo-gif");
// Update the profile, creating a new file
profile.Name = name;
AddOrUpdate(profile);
}
public static void InsertGif(IEnumerable<ProfileModel> profileModels, string layerName, Bitmap gifFile,
string fileName)
public static void DeleteProfile(ProfileModel prof)
{
// Extract the GIF file
var gifDir = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) + @"\Artemis\gifs";
Directory.CreateDirectory(gifDir);
var gifPath = gifDir + $"\\{fileName}.gif";
gifFile.Save(gifPath);
foreach (var profile in profileModels)
{
var gifLayer = profile.GetLayers().FirstOrDefault(l => l.Name == layerName);
if (gifLayer == null)
continue;
((KeyboardPropertiesModel) gifLayer.Properties).GifFile = gifPath;
AddOrUpdate(profile);
}
}
/// <summary>
/// Makes sure the profile directory structure is in order and places default profiles
/// </summary>
private static void CheckProfiles()
{
// Create the directory structure
if (Directory.Exists(ProfileFolder))
return;
Directory.CreateDirectory(ProfileFolder);
// Remove the file
var path = ProfileFolder + $@"\{prof.KeyboardSlug}\{prof.GameName}\{prof.Name}.json";
if (File.Exists(path))
File.Delete(path);
}
/// <summary>
@ -222,7 +134,7 @@ namespace Artemis.DAL
}
/// <summary>
/// Exports the given profile to the provided path in XML
/// Exports the given profile to the provided path in JSON
/// </summary>
/// <param name="prof">The profile to export</param>
/// <param name="path">The path to save the profile to</param>
@ -232,41 +144,102 @@ namespace Artemis.DAL
File.WriteAllText(path, json);
}
/// <summary>
/// Renames the profile on the model and filesystem
/// </summary>
/// <param name="profile">The profile to rename</param>
/// <param name="name">The new name</param>
public static void RenameProfile(ProfileModel profile, string name)
public static void InsertGif(string effectName, string profileName, string layerName, Bitmap gifFile, string fileName)
{
if (string.IsNullOrEmpty(name))
return;
var directories = new DirectoryInfo(ProfileFolder).GetDirectories();
var profiles = new List<ProfileModel>();
foreach (var directoryInfo in directories)
profiles.AddRange(ReadProfiles(directoryInfo.Name + "/effectName").Where(d => d.Name == profileName));
// Extract the GIF file
var gifDir = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) + @"\Artemis\gifs";
Directory.CreateDirectory(gifDir);
var gifPath = gifDir + $"\\{fileName}.gif";
gifFile.Save(gifPath);
// Remove the old profile
DeleteProfile(profile);
foreach (var profile in profiles)
{
var gifLayer = profile.GetLayers().FirstOrDefault(l => l.Name == layerName);
if (gifLayer == null)
continue;
// Update the profile, creating a new file
profile.Name = name;
AddOrUpdate(profile);
((KeyboardPropertiesModel) gifLayer.Properties).GifFile = gifPath;
AddOrUpdate(profile);
}
}
public static void DeleteProfile(ProfileModel prof)
private static List<ProfileModel> ReadProfiles(string subDirectory)
{
// Remove from datastore
lock (Profiles)
{
// Get the profile from the datastore instead of just the provided value, to be certain it is removed
var dsProfile = Profiles.FirstOrDefault(p => p.GameName == prof.GameName &&
p.Name == prof.Name &&
p.KeyboardSlug == prof.KeyboardSlug);
if (dsProfile != null)
Profiles.Remove(dsProfile);
}
var profiles = new List<ProfileModel>();
var directory = ProfileFolder + "/" + subDirectory;
if (!Directory.Exists(directory))
return profiles;
// Remove the file
var path = ProfileFolder + $@"\{prof.KeyboardSlug}\{prof.GameName}\{prof.Name}.json";
if (File.Exists(path))
File.Delete(path);
// Create the directory structure
var profilePaths = Directory.GetFiles(directory, "*.json", SearchOption.AllDirectories);
// Parse the JSON files into objects and add them if they are valid
foreach (var path in profilePaths)
{
try
{
var prof = LoadProfileIfValid(path);
if (prof == null)
continue;
// Only add unique profiles
if (profiles.Any(p => p.GameName == prof.GameName && p.Name == prof.Name &&
p.KeyboardSlug == prof.KeyboardSlug))
{
Logger.Info("Didn't load duplicate profile: {0}", path);
}
else
{
profiles.Add(prof);
}
}
catch (Exception e)
{
Logger.Error("Failed to load profile: {0} - {1}", path, e);
}
}
return profiles;
}
/// <summary>
/// Unpacks the default profiles into the profile directory
/// </summary>
private static void InstallDefaults()
{
// Only install the defaults once per session
if (_installedDefaults)
return;
_installedDefaults = true;
// Load the ZIP from resources
var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("Artemis.Resources.Keyboards.default-profiles.zip");
// Extract it over the old defaults in case one was updated
if (stream == null)
return;
var archive = new ZipArchive(stream);
archive.ExtractToDirectory(ProfileFolder, true);
InsertGif("WindowsProfile", "Demo (duplicate to keep changes)", "GIF", Resources.demo_gif, "demo-gif");
}
/// <summary>
/// Makes sure the profile directory structure is in order and places default profiles
/// </summary>
private static void CheckProfiles()
{
// Create the directory structure
if (Directory.Exists(ProfileFolder))
return;
Directory.CreateDirectory(ProfileFolder);
}
}
}

View File

@ -11,6 +11,7 @@ using CUE.NET.Brushes;
using CUE.NET.Devices.Generic;
using CUE.NET.Devices.Generic.Enums;
using CUE.NET.Devices.Keyboard;
using CUE.NET.Helper;
using Ninject.Extensions.Logging;
using Point = System.Drawing.Point;
@ -103,15 +104,17 @@ namespace Artemis.DeviceProviders.Corsair
// For STRAFE, stretch the image on row 2.
if (_keyboard.DeviceInfo.Model == "STRAFE RGB")
{
var strafeBitmap = new Bitmap(22, 8);
using (var g = Graphics.FromImage(strafeBitmap))
using (var strafeBitmap = new Bitmap(22, 8))
{
g.DrawImage(image, new Point(0, 0));
g.DrawImage(image, new Rectangle(0, 3, 22, 7), new Rectangle(0, 2, 22, 7), GraphicsUnit.Pixel);
}
using (var g = Graphics.FromImage(strafeBitmap))
{
g.DrawImage(image, new Point(0, 0));
g.DrawImage(image, new Rectangle(0, 3, 22, 7), new Rectangle(0, 2, 22, 7), GraphicsUnit.Pixel);
}
image.Dispose();
image = strafeBitmap;
image.Dispose();
image = strafeBitmap;
}
}
_keyboardBrush.Image = image;
@ -136,11 +139,11 @@ namespace Artemis.DeviceProviders.Corsair
// ignored
}
if (cueLed != null)
return new KeyMatch(keyCode, (int) (cueLed.LedRectangle.X*widthMultiplier),
(int) (cueLed.LedRectangle.Y*heightMultiplier));
if (cueLed == null)
return null;
return null;
var center = cueLed.LedRectangle.GetCenter();
return new KeyMatch(keyCode, (int) (center.X*widthMultiplier),(int) (center.Y*heightMultiplier));
}
}
}

View File

@ -96,19 +96,21 @@ namespace Artemis.Modules.Games.UnrealTournament
}
// Load the ZIP from resources
var stream = new MemoryStream(Resources.ut_plugin);
var archive = new ZipArchive(stream);
try
using (var stream = new MemoryStream(Resources.ut_plugin))
{
Directory.CreateDirectory(path + @"\UnrealTournament\Plugins\Artemis");
archive.ExtractToDirectory(path + @"\UnrealTournament\Plugins\Artemis", true);
}
catch (Exception e)
{
MainManager.Logger.Error(e, "Failed to install Unreal Tournament plugin in '{0}'", path);
return;
}
var archive = new ZipArchive(stream);
try
{
Directory.CreateDirectory(path + @"\UnrealTournament\Plugins\Artemis");
archive.ExtractToDirectory(path + @"\UnrealTournament\Plugins\Artemis", true);
}
catch (Exception e)
{
MainManager.Logger.Error(e, "Failed to install Unreal Tournament plugin in '{0}'", path);
return;
}
}
MainManager.Logger.Info("Installed Unreal Tournament plugin in '{0}'", path);
}
@ -117,13 +119,8 @@ namespace Artemis.Modules.Games.UnrealTournament
var gif = Resources.redeemer;
if (gif == null)
return;
var utProfiles = ProfileProvider.GetAll()?
.Where(p => (p.GameName == "UnrealTournament") && (p.Name == "Default")).ToList();
if (utProfiles == null || !utProfiles.Any())
return;
ProfileProvider.InsertGif(utProfiles, "Redeemer GIF", gif, "redeemer");
ProfileProvider.InsertGif("UnrealTournament", "Default", "Redeemer GIF", gif, "redeemer");
}
}
}

View File

@ -66,10 +66,11 @@ namespace Artemis.Modules.Games.Witcher3
if (_witcherSettings == null)
return;
var reader = new StreamReader(
File.Open(_witcherSettings, FileMode.Open, FileAccess.Read, FileShare.ReadWrite));
var reader =
new StreamReader(File.Open(_witcherSettings, FileMode.Open, FileAccess.Read, FileShare.ReadWrite));
var configContent = reader.ReadToEnd();
reader.Close();
reader.Dispose();
var signRes = _configRegex.Match(configContent);
var parts = signRes.Value.Split('\n').Skip(1).Select(v => v.Replace("\r", "")).ToList();

View File

@ -53,7 +53,10 @@ namespace Artemis.Profiles.Layers.Types.KeyboardGif
lock (layer.GifImage)
{
var draw = layer.GifImage.GetNextFrame();
c.DrawImage(ImageUtilities.BitmapToBitmapImage(new Bitmap(draw)), rect);
using (var drawBitmap = new Bitmap(draw))
{
c.DrawImage(ImageUtilities.BitmapToBitmapImage(drawBitmap), rect);
}
}
}

View File

@ -24,7 +24,6 @@ namespace Artemis.Profiles
{
Layers = new ChildItemCollection<ProfileModel, LayerModel>(this);
LuaWrapper = new LuaWrapper(this);
DrawingVisual = new DrawingVisual();
}
/// <summary>
@ -42,9 +41,6 @@ namespace Artemis.Profiles
public int Height { get; set; }
public string LuaScript { get; set; }
[JsonIgnore]
public DrawingVisual DrawingVisual { get; set; }
[JsonIgnore]
public LuaWrapper LuaWrapper { get; set; }

View File

@ -9,6 +9,7 @@ using Artemis.DeviceProviders;
namespace Artemis.Utilities.Keyboard
{
// TODO: Obsolete
public class KeyboardRectangle
{
private readonly BackgroundWorker _blinkWorker = new BackgroundWorker {WorkerSupportsCancellation = true};

View File

@ -40,7 +40,7 @@ namespace Artemis.ViewModels.Profiles
private readonly Timer _saveTimer;
private ImageSource _keyboardPreview;
private BindableCollection<LayerModel> _layers;
private BindableCollection<ProfileModel> _profiles;
private BindableCollection<string> _profileNames;
private bool _saving;
private ProfileModel _selectedProfile;
@ -51,7 +51,7 @@ namespace Artemis.ViewModels.Profiles
_gameModel = gameModel;
_layerEditorVmFactory = layerEditorVmFactory;
Profiles = new BindableCollection<ProfileModel>();
ProfileNames = new BindableCollection<string>();
Layers = new BindableCollection<LayerModel>();
ProfileViewModel = profileViewModel;
DialogService = dialogService;
@ -76,17 +76,17 @@ namespace Artemis.ViewModels.Profiles
public bool EditorEnabled
=>
SelectedProfile != null && !SelectedProfile.IsDefault &&
_mainManager.DeviceManager.ActiveKeyboard != null;
(SelectedProfile != null) && !SelectedProfile.IsDefault &&
(_mainManager.DeviceManager.ActiveKeyboard != null);
public BindableCollection<ProfileModel> Profiles
public BindableCollection<string> ProfileNames
{
get { return _profiles; }
get { return _profileNames; }
set
{
if (Equals(value, _profiles)) return;
_profiles = value;
NotifyOfPropertyChange(() => Profiles);
if (Equals(value, _profileNames)) return;
_profileNames = value;
NotifyOfPropertyChange(() => ProfileNames);
}
}
@ -101,6 +101,23 @@ namespace Artemis.ViewModels.Profiles
}
}
public string SelectedProfileName
{
get { return SelectedProfile.Name; }
set
{
if (value == SelectedProfile.Name)
return;
SelectedProfile = ProfileProvider.GetProfile(
_mainManager.DeviceManager.ActiveKeyboard,
_gameModel,
value);
NotifyOfPropertyChange(() => SelectedProfileName);
}
}
public ProfileModel SelectedProfile
{
get { return _selectedProfile; }
@ -126,16 +143,16 @@ namespace Artemis.ViewModels.Profiles
public PreviewSettings? PreviewSettings => _mainManager.DeviceManager.ActiveKeyboard?.PreviewSettings;
public bool ProfileSelected => SelectedProfile != null;
public bool LayerSelected => SelectedProfile != null && ProfileViewModel.SelectedLayer != null;
public bool LayerSelected => (SelectedProfile != null) && (ProfileViewModel.SelectedLayer != null);
public void DragOver(IDropInfo dropInfo)
{
var source = dropInfo.Data as LayerModel;
var target = dropInfo.TargetItem as LayerModel;
if (source == null || target == null || source == target)
if ((source == null) || (target == null) || (source == target))
return;
if (dropInfo.InsertPosition == RelativeInsertPosition.TargetItemCenter &&
if ((dropInfo.InsertPosition == RelativeInsertPosition.TargetItemCenter) &&
target.LayerType is FolderType)
{
dropInfo.DropTargetAdorner = typeof(DropTargetMetroHighlightAdorner);
@ -152,7 +169,7 @@ namespace Artemis.ViewModels.Profiles
{
var source = dropInfo.Data as LayerModel;
var target = dropInfo.TargetItem as LayerModel;
if (source == null || target == null || source == target)
if ((source == null) || (target == null) || (source == target))
return;
// Don't allow a folder to become it's own child, that's just weird
@ -173,7 +190,7 @@ namespace Artemis.ViewModels.Profiles
parent.FixOrder();
}
if (dropInfo.InsertPosition == RelativeInsertPosition.TargetItemCenter &&
if ((dropInfo.InsertPosition == RelativeInsertPosition.TargetItemCenter) &&
target.LayerType is FolderType)
{
// Insert into folder
@ -185,9 +202,9 @@ namespace Artemis.ViewModels.Profiles
else
{
// Insert the source into it's new profile/parent and update the order
if (dropInfo.InsertPosition == RelativeInsertPosition.AfterTargetItem ||
dropInfo.InsertPosition ==
(RelativeInsertPosition.TargetItemCenter | RelativeInsertPosition.AfterTargetItem))
if ((dropInfo.InsertPosition == RelativeInsertPosition.AfterTargetItem) ||
(dropInfo.InsertPosition ==
(RelativeInsertPosition.TargetItemCenter | RelativeInsertPosition.AfterTargetItem)))
target.InsertAfter(source);
else
target.InsertBefore(source);
@ -222,20 +239,34 @@ namespace Artemis.ViewModels.Profiles
/// </summary>
private void LoadProfiles()
{
Profiles.Clear();
if (_gameModel == null || _mainManager.DeviceManager.ActiveKeyboard == null)
ProfileNames.Clear();
if ((_gameModel == null) || (_mainManager.DeviceManager.ActiveKeyboard == null))
return;
Profiles.AddRange(ProfileProvider.GetAll(_gameModel, _mainManager.DeviceManager.ActiveKeyboard));
ProfileNames.AddRange(ProfileProvider.GetProfileNames(_mainManager.DeviceManager.ActiveKeyboard, _gameModel));
// If a profile name was provided, try to load it
ProfileModel lastProfileModel = null;
if (!string.IsNullOrEmpty(LastProfile))
lastProfileModel = Profiles.FirstOrDefault(p => p.Name == LastProfile);
{
lastProfileModel = ProfileProvider.GetProfile(
_mainManager.DeviceManager.ActiveKeyboard,
_gameModel,
LastProfile);
}
SelectedProfile = lastProfileModel ?? Profiles.FirstOrDefault();
if (lastProfileModel != null)
SelectedProfile = lastProfileModel;
else
{
SelectedProfile = ProfileProvider.GetProfile(
_mainManager.DeviceManager.ActiveKeyboard,
_gameModel,
ProfileNames.FirstOrDefault());
}
}
public void EditLayerFromDoubleClick()
{
if (ProfileViewModel.SelectedLayer?.LayerType is FolderType)
@ -279,7 +310,6 @@ namespace Artemis.ViewModels.Profiles
// If the layer was a folder, but isn't anymore, assign it's children to it's parent.
if (!(layer.LayerType is FolderType) && layer.Children.Any())
{
while (layer.Children.Any())
{
var child = layer.Children[0];
@ -295,7 +325,6 @@ namespace Artemis.ViewModels.Profiles
layer.Profile.FixOrder();
}
}
}
UpdateLayerList(layer);
}
@ -495,7 +524,7 @@ namespace Artemis.ViewModels.Profiles
GameName = _gameModel.Name
};
if (ProfileProvider.GetAll().Contains(profile))
if (!ProfileProvider.IsProfileUnique(profile))
{
var overwrite = await DialogService.ShowQuestionMessageBox("Overwrite existing profile",
"A profile with this name already exists for this game. Would you like to overwrite it?");
@ -517,18 +546,17 @@ namespace Artemis.ViewModels.Profiles
var name = await DialogService.ShowInputDialog("Rename profile", "Please enter a unique new profile name");
// Null when the user cancelled
if (string.IsNullOrEmpty(name) || name.Length < 2)
if (string.IsNullOrEmpty(name) || (name.Length < 2))
return;
// Verify the name
while (ProfileProvider.GetAll().Any(p => p.Name == name && p.GameName == SelectedProfile.GameName &&
p.KeyboardSlug == SelectedProfile.KeyboardSlug))
while (!ProfileProvider.IsProfileUnique(SelectedProfile))
{
name =
await DialogService.ShowInputDialog("Name already in use", "Please enter a unique new profile name");
// Null when the user cancelled
if (string.IsNullOrEmpty(name) || name.Length < 2)
if (string.IsNullOrEmpty(name) || (name.Length < 2))
return;
}
@ -554,7 +582,7 @@ namespace Artemis.ViewModels.Profiles
return;
// Verify the name
while (ProfileProvider.GetAll().Contains(newProfile))
while (!ProfileProvider.IsProfileUnique(newProfile))
{
newProfile.Name =
await DialogService.ShowInputDialog("Name already in use", "Please enter a unique profile name");
@ -567,7 +595,7 @@ namespace Artemis.ViewModels.Profiles
newProfile.IsDefault = false;
ProfileProvider.AddOrUpdate(newProfile);
LoadProfiles();
SelectedProfile = Profiles.FirstOrDefault(p => p.Name == newProfile.Name);
SelectedProfile = newProfile;
}
public async void DeleteProfile()
@ -640,7 +668,7 @@ namespace Artemis.ViewModels.Profiles
profile.IsDefault = false;
// Verify the name
while (ProfileProvider.GetAll().Contains(profile))
while (!ProfileProvider.IsProfileUnique(profile))
{
profile.Name = await DialogService.ShowInputDialog("Rename imported profile",
"A profile with this name already exists for this game. Please enter a new name");
@ -653,7 +681,7 @@ namespace Artemis.ViewModels.Profiles
ProfileProvider.AddOrUpdate(profile);
LoadProfiles();
SelectedProfile = Profiles.FirstOrDefault(p => p.Name == profile.Name);
SelectedProfile = profile;
}
public void ExportProfile()
@ -711,7 +739,7 @@ namespace Artemis.ViewModels.Profiles
private void ProfileSaveHandler(object sender, ElapsedEventArgs e)
{
if (_saving || SelectedProfile == null)
if (_saving || (SelectedProfile == null))
return;
_saving = true;

View File

@ -56,8 +56,7 @@
<StackPanel Grid.Column="0" Grid.Row="2">
<StackPanel Orientation="Horizontal" Margin="0,5,0,0">
<Label Content="Active profile" />
<ComboBox Width="220" VerticalAlignment="Top" x:Name="Profiles" DisplayMemberPath="Name"
Margin="5,0,0,0" />
<ComboBox Width="220" VerticalAlignment="Top" x:Name="ProfileNames" Margin="5,0,0,0" />
<Button x:Name="AddProfile" VerticalAlignment="Top" Style="{DynamicResource SquareButtonStyle}"
Width="26" Height="26" HorizontalAlignment="Right" Margin="10,0,0,0" ToolTip="Add profile">
<Button.Content>