• Visit Rebornbuddy
  • [Development] Automatic WPF-based Settings Window

    Discussion in 'Archives' started by Apoc, Nov 4, 2011.

    1. Apoc

      Apoc Moderator Staff Member Moderator

      Joined:
      Jan 16, 2010
      Messages:
      2,790
      Likes Received:
      94
      Trophy Points:
      48
      So, I finally decided to change the settings window for Singular, and wanted to get an automatic design setup to make life easier when adding/removing/changing settings. Instead of having a *gigantic* WinForms setup like most usually do (the old Singular form just had PropertyGrid, which worked great, but sometimes seemed a bit daunting to use), I wanted a sleek WPF UI instead.

      That said, what I'm going to present is more of a proof-of-concept way to deal with it. Its far from finished, and admittedly, not the greatest thing to look at either (yet).

      So far, it supports pretty much any type of setting, excluding lists/arrays/etc. (I haven't figured out how I want to deal with that just yet)

      [​IMG]

      First of all, I'll explain the "caveats" of using this type of system.

      1. It's WPF - Things may not always be what they seem with WPF, so this is really meant for people who have at least *some* experience with it.
      2. It requires some extra setup to work properly due to the databinding aspect of it. (More on that later)
      3. It requires you to use Honorbuddy's built in Settings class (again, more on it later)
      4. Some stuff isn't supported... yet

      As far as databinding goes, I'll just use Singular as an example.

      Code:
      using System.ComponentModel;
      using System.Diagnostics;
      
      using Styx;
      using Styx.Helpers;
      
      using DefaultValue = Styx.Helpers.DefaultValueAttribute;
      
      namespace Singular.Settings
      {
          internal class SingularSettings : Styx.Helpers.Settings, INotifyPropertyChanged
          {
              private static SingularSettings _instance;
      
              public SingularSettings() : base(SettingsPath + ".xml")
              {
              }
      
              public static string SettingsPath { get { return string.Format("{0}\\Settings\\SingularSettings_{1}", Logging.ApplicationPath, StyxWoW.Me.Name); } }
      
              public static SingularSettings Instance { get { return _instance ?? (_instance = new SingularSettings()); } }
      
              #region INotifyPropertyChanged Members
      
              public event PropertyChangedEventHandler PropertyChanged;
      
              #endregion
      
              ~SingularSettings()
              {
                  Save();
              }
      
              private void Notify()
              {
                  StackFrame[] frames = new StackTrace(false).GetFrames();
                  if (frames != null)
                  {
                      string name = frames[1].GetMethod().Name;
                      if (name.StartsWith("set_"))
                      {
                          name = name.Replace("set_", "");
                      }
                      if (PropertyChanged != null)
                      {
                          PropertyChanged(this, new PropertyChangedEventArgs(name));
                      }
                  }
              }
      
              #region Category: General
      
              private bool _disableAllMovement;
      
              [Setting]
              [DefaultValue(false)]
              [Category("Movement")]
              [DisplayName("Disable Movement")]
              [Description("Disable all movement within the CC. This will NOT stop it from charging, blinking, etc. Only moving towards units, and facing will be disabled.")]
              public bool DisableAllMovement
              {
                  get { return _disableAllMovement; }
                  set
                  {
                      _disableAllMovement = value;
                      Notify();
                  }
              }
      Most of this is some generic settings stuff, with the inclusion of "INotifyPropertyChanged" to the inheritance list. This is how WPF deals with "two way" bindings. (An event needs to be fired to tell WPF you changed something at runtime)

      I've included the "Notify" method to make it far easier to maintain. (Albeit, a bit slower since it *is* doing a stacktrace to pull the calling method's name. Its easier to maintain since you don't need to copy/paste the name of the property when you add/change them.)

      In the DisableAllMovement property, you'll see how it's used. (Not going to bother explaining it, unless absolutely necessary)

      There are a few extra attributes tagged onto the property, to provide the UI some sorting/tooltip/etc info. DisplayName is what will be displayed in any labels for settings. Description will be displayed in a tooltip for the setting. Category, will move the setting to a "GroupBox" labeled with the category name, just to make organization a bit easier.

      Other than that, its really that simple. Add your setting properties, and off you go.

      The following is a small example of usage:

      Code:
      new ConfigWindow("Singular Configuration", "Singular", "Community Driven", 300, 400, SingularSettings.Instance, null).ShowDialog();
      You'll notice you can set basically all the config vars for the window, without having to touch any code. (That was the idea!) Some stuff may still require a bit of manual tweaking, but thats up to the person using this code.

      .. and finally... the actual code.

      Code:
      using System;
      using System.Collections.Generic;
      using System.ComponentModel;
      using System.Linq;
      using System.Reflection;
      using System.Windows;
      using System.Windows.Controls;
      using System.Windows.Controls.Primitives;
      using System.Windows.Data;
      using System.Windows.Media;
      
      using Styx.Helpers;
      
      namespace Singular.GUI
      {
          internal class ConfigWindow : Window
          {
              private readonly Grid _mainCanvas = new Grid();
      
              public ConfigWindow(
                  string title, string logo, string logoTagLine, int width, int height, Styx.Helpers.Settings mainSettingsSource,
                  Styx.Helpers.Settings secondarySettingsSource)
              {
                  WindowStyle = WindowStyle.SingleBorderWindow;
                  Topmost = true;
                  Width = width;
                  Height = height;
                  Title = title;
      
                  // Store _mainCanvas outside this so we don't have to cast and whatnot.
                  Content = _mainCanvas;
                  _mainCanvas.RowDefinitions.Add(new RowDefinition());
                  _mainCanvas.RowDefinitions.Add(new RowDefinition { Height = new GridLength(50) });
      
                  // Setup theme vars to match HB's window.
                  ResourceDictionary theme = Application.Current.Resources;
                  Resources.Source = theme.Source;
                  Background = Application.Current.MainWindow.Background;
      
                  // Add tab controls...
                  var tabs = new TabControl();
                  Grid.SetRow(tabs, 0);
                  _mainCanvas.Children.Add(tabs);
      
                  #region General Tab
      
                  var mainTab = new TabItem { Header = "General" };
                  tabs.Items.Add(mainTab);
      
                  // Create a stack panel...
                  var mainTabPanel = new StackPanel();
                  // Create a scrollable area
                  var sv = new ScrollViewer { Content = mainTabPanel };
                  sv.BorderBrush = Brushes.LightGray;
                  sv.BorderThickness = new Thickness(2);
                  mainTab.Content = sv;
      
                  // And go thru and add settings!
                  // Pull all the properties, and grab ones with the [Setting] attribute.
                  IEnumerable<PropertyInfo> settingsProperties =
                      mainSettingsSource.GetType().GetProperties().Where(p => p.GetCustomAttributes(false).Any(a => a is SettingAttribute));
      
                  BuildSettings(mainTabPanel, settingsProperties, mainSettingsSource);
      
                  #endregion
      
                  #region Singular "Logo"
      
                  var logoPanel = new StackPanel();
                  Grid.SetRow(logoPanel, 1);
                  _mainCanvas.Children.Add(logoPanel);
      
                  var logoMain = new TextBlock
                      {
                          Text = logo,
                          Foreground = new SolidColorBrush(Colors.White),
                          FontSize = 20,
                          FontFamily = new FontFamily("Impact"),
                          Padding = new Thickness(5, 0, 0, 0)
                      };
                  //logoMain.FontWeight = FontWeights.Bold;
                  logoPanel.Children.Add(logoMain);
      
                  var logoTag = new TextBlock
                      {
                          Text = logoTagLine,
                          Foreground = new SolidColorBrush(Colors.White),
                          FontWeight = FontWeights.Bold,
                          Padding = new Thickness(5)
                      };
      
                  logoPanel.Children.Add(logoTag);
      
                  #endregion
              }
      
              private static T GetAttribute<T>(PropertyInfo pi) where T : Attribute
              {
                  object attr = pi.GetCustomAttributes(false).FirstOrDefault(a => a is T);
                  if (attr != null)
                  {
                      return attr as T;
                  }
                  return null;
              }
      
              private void BuildSettings(Panel tabPanel, IEnumerable<PropertyInfo> settings, object source)
              {
                  var categories = new Dictionary<string, StackPanel>();
      
                  foreach (PropertyInfo pi in settings.OrderBy(p => p.PropertyType.Name))
                  {
                      // First get some display stuff.
                      // By default, any settings w/o a category set, go into "Misc"
                      string category = GetAttribute<CategoryAttribute>(pi) != null
                                            ? GetAttribute<CategoryAttribute>(pi).Category
                                            : "Miscellaneous";
                      string displayName = GetAttribute<DisplayNameAttribute>(pi) != null
                                               ? GetAttribute<DisplayNameAttribute>(pi).DisplayName
                                           // Default to the property name if no display name is given.
                                               : pi.Name;
                      string description = GetAttribute<DescriptionAttribute>(pi) != null
                                               ? GetAttribute<DescriptionAttribute>(pi).Description
                                               : null;
      
                      if (!categories.ContainsKey(category))
                      {
                          categories.Add(category, new StackPanel());
                      }
      
                      StackPanel group = categories[category];
      
                      //Logger.Write(displayName + " -> " + description);
      
                      Type returnType = pi.PropertyType;
      
                      // Deal with enums in a "special" way. The typecode for any enum will be int32 by default (unless marked as something else)
                      // So we really want a dropdown, not a textbox.
                      if (returnType.IsEnum)
                      {
                          AddComboBoxForEnum(group, source, pi, displayName, description, returnType);
                          continue;
                      }
      
                      // Easiest way to blanket-statement a bunch of editable values. (Quite a few will just be textbox editors)
                      switch (Type.GetTypeCode(returnType))
                      {
                          case TypeCode.Boolean:
                              AddCheckbox(group, source, pi, displayName, description);
                              break;
                          case TypeCode.Char:
                          case TypeCode.SByte:
                          case TypeCode.Byte:
                          case TypeCode.Int16:
                          case TypeCode.UInt16:
                          case TypeCode.Int32:
                          case TypeCode.UInt32:
                          case TypeCode.Int64:
                          case TypeCode.UInt64:
                          case TypeCode.Single:
                          case TypeCode.Double:
                          case TypeCode.Decimal:
                              AddSlider(group, source, pi, displayName, description);
                              break;
                          case TypeCode.String:
                              AddEditBox(group, source, pi, displayName, description);
                              break;
                          default:
                              Logger.Write("Don't know how to display " + returnType);
                              break;
                      }
                  }
      
                  foreach (var sp in categories.OrderBy(kv => kv.Key))
                  {
                      var gb = new GroupBox { Content = sp.Value, Header = sp.Key };
                      tabPanel.Children.Add(gb);
                  }
              }
      
              private void AddBinding(FrameworkElement ctrl, string xpath, object source, PropertyInfo bindTo, DependencyProperty depProp)
              {
                  var b = new Binding(xpath) { Source = source, Path = new PropertyPath(bindTo.Name), Mode = BindingMode.TwoWay };
                  ctrl.SetBinding(depProp, b);
              }
      
              private void AddCheckbox(Panel ctrl, object source, PropertyInfo bindTo, string label, string tooltip)
              {
                  var cb = new CheckBox { Content = label, ToolTip = !string.IsNullOrEmpty(tooltip) ? tooltip : null };
      
                  // And the binding so we don't have to do a lot of nasty event handling.
                  AddBinding(cb, "IsChecked", source, bindTo, ToggleButton.IsCheckedProperty);
      
                  ctrl.Children.Add(cb);
              }
      
              private void AddSlider(Panel ctrl, object source, PropertyInfo bindTo, string label, string tooltip)
              {
                  var display = new StackPanel { Orientation = Orientation.Horizontal, Width = ctrl.Width };
                  var l = new Label { Content = label, Margin = new Thickness(5, 5, 5, 3) };
                  var sldLbl = new Label();
      
                  var s = new Slider();
                  // Find min/max
                  var attr = GetAttribute<LimitAttribute>(bindTo);
                  if (attr != null)
                  {
                      s.Maximum = attr.High;
                      s.Minimum = attr.Low;
                      s.TickFrequency = Math.Abs(attr.High - attr.Low) / 10;
                      s.SmallChange = s.TickFrequency;
                      s.LargeChange = s.TickFrequency * 1.1f;
                  }
                  s.MinWidth = 65;
                  s.TickPlacement = TickPlacement.BottomRight;
                  s.ToolTip = tooltip;
                  AddBinding(s, "Value", source, bindTo, RangeBase.ValueProperty);
      
                  var b = new Binding("Value") { Source = s, Path = new PropertyPath("Value"), Mode = BindingMode.TwoWay };
                  sldLbl.SetBinding(ContentProperty, b);
                  sldLbl.ContentStringFormat = "N1";
      
                  display.Children.Add(sldLbl);
                  display.Children.Add(s);
                  display.Children.Add(l);
      
                  ctrl.Children.Add(display);
              }
      
              private void AddEditBox(Panel ctrl, object source, PropertyInfo bindTo, string label, string tooltip)
              {
                  // This is a bit tricky. We want to stack the edit box to the right of the label.
                  // We do this via another stack panel, with a changed "stack" way.
      
                  var display = new StackPanel { Orientation = Orientation.Horizontal, Width = ctrl.Width };
                  var l = new Label { Content = label, Margin = new Thickness(5, 5, 5, 3) };
      
                  var tb = new TextBox
                      {
                          ToolTip = tooltip,
                          Background = Background,
                          BorderBrush = new SolidColorBrush(Colors.LightGray),
                          Margin = new Thickness(2, 3, 5, 3),
                          MinWidth = 50
                      };
      
                  // Add the textbox/label to the stack panel so we can have it side-to-side.
                  display.Children.Add(tb);
                  display.Children.Add(l);
      
                  // Don't forget the damned binding.
                  AddBinding(tb, "Text", source, bindTo, TextBox.TextProperty);
      
                  // And add it to the main control.
                  ctrl.Children.Add(display);
              }
      
              private void AddComboBoxForEnum(Panel ctrl, object source, PropertyInfo bindTo, string label, string tooltip, Type enumType)
              {
                  var display = new StackPanel { Orientation = Orientation.Horizontal, Width = ctrl.Width };
                  var l = new Label { Content = label, Margin = new Thickness(5, 5, 5, 3) };
      
                  var cb = new ComboBox
                      {
                          ToolTip = tooltip, Background = Background, BorderBrush = new SolidColorBrush(Colors.LightGray),
                          BorderThickness = new Thickness(2)
                      };
                  foreach (object val in Enum.GetValues(enumType))
                  {
                      cb.Items.Add(val);
                  }
      
                  AddBinding(cb, "SelectedItem", source, bindTo, Selector.SelectedItemProperty);
      
                  display.Children.Add(cb);
                  display.Children.Add(l);
      
                  ctrl.Children.Add(display);
              }
          }
      
          [AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
          internal sealed class LimitAttribute : Attribute
          {
              public LimitAttribute(double low, double high)
              {
                  Low = low;
                  High = high;
              }
      
              public double High { get; set; }
              public double Low { get; set; }
          }
      }
      I've tried to comment everything, and made some styling tweaks to account for certain things. Overall, it should handle everything excluding enumerable objects.

      Please let me know if you have any comments/suggestions or bug reports. I plan to extend upon this even further in the near future.


      Known Issues
      • The "secondarySettingsSource" does not do anything yet. This will very shortly.
      • Arrays/Lists do not work. Once I have a way to handle this "gracefully" I'll do so.
      • ComboBoxes are hard to see. This is mostly a styling issue, once I figure it out, I'll post an update to fix it.
       
      Last edited: Nov 4, 2011
    2. exemplar

      exemplar New Member

      Joined:
      Mar 16, 2010
      Messages:
      167
      Likes Received:
      32
      Trophy Points:
      0
      I don't see enough singletons, tbh. Horrible developer.
       
    3. Apoc

      Apoc Moderator Staff Member Moderator

      Joined:
      Jan 16, 2010
      Messages:
      2,790
      Likes Received:
      94
      Trophy Points:
      48
      I lol'd.
       
    4. no1knowsy

      no1knowsy Well-Known Member

      Joined:
      Feb 28, 2010
      Messages:
      3,927
      Likes Received:
      57
      Trophy Points:
      48
      Your IRC is not up? WTF Apoc?

      public static WoWDevIRC Apocs()
      {
      return _fromApoc ?? (_fromApoc = new AskApocForIRCinfo()) };
      }


      Damn...
      Shoulda used a Singleton.
       

    Share This Page