Wednesday, September 12, 2007

Localization of a complete PropertyGrid in .NET

 

While developing an application for my company, I had to use a PropertyGrid (default WinForms-usercontrol).

The problem was the localization of this grid.
The client wanted to localize the categories, property-names,  enum selection values and the descriptions.

So I did some research and developed a couple of handy classes that can do the job.

The first step was to create some resx resource files:

  • EngineProperties.resx
    Used for Description and Categories
  • EngineProperties.nl.resx
  • EngineProperties.fr.resx
  • PropertyNames.resx
    Used for enum-value- and propertyname localizations.
  • PropertyNames.nl.resx

( I created them by clicking on the project and adding a new resource file )

The first class we need is a class to localize the descriptions visible in the PropertyGrid control.

   1: using System;
   2: using System.Collections.Generic;
   3: using System.Text;
   4: using System.ComponentModel;
   5:  
   6: namespace Whizzo3D.Engine.Localization
   7: {
   8:     /// <summary>
   9:     /// Specifies a description for a property or event.
  10:     /// Localized version of the description class.
  11:     /// Used to localize messages in a propertygrid for example.
  12:     /// </summary>
  13:     public class GlobalDescriptionAttribute : DescriptionAttribute
  14:     {
  15:         public GlobalDescriptionAttribute(string descriptionKey)
  16:             : base()
  17:         {
  18:             // Set the value to the localized description of our key.
  19:             base.DescriptionValue = 
  20:                 EngineProperties.ResourceManager.GetString(descriptionKey);
  21:         }
  22:     }
  23: }

The second class we need is a class to localize the categories in the PropertyGrid control.



   1: using System;
   2: using System.Collections.Generic;
   3: using System.Text;
   4: using System.ComponentModel;
   5:  
   6: namespace Whizzo3D.Engine.Localization
   7: {
   8:     /// <summary>
   9:     /// Specifies the name of the category in which to group the property or event
  10:     /// when displayed in a System.Windows.Forms.PropertyGrid control set to Categorized
  11:     /// mode.
  12:     /// </summary>
  13:     public class GlobalCategoryAttribute : CategoryAttribute
  14:     {
  15:         public GlobalCategoryAttribute(string categoryKey)
  16:             : base(categoryKey)
  17:         {
  18:     
  19:         }
  20:  
  21:         /// <summary>
  22:         /// Fetch a localized string for the culture defined
  23:         /// in the Thread.CurrentThread.CurrentUICulture
  24:         /// </summary>
  25:         /// <param name="value">Key to fetch value for</param>
  26:         /// <returns>Requested value.</returns>
  27:         protected override string GetLocalizedString(string value)
  28:         {
  29:             return EngineProperties.ResourceManager.GetString(value);
  30:         }
  31:     }
  32: }

Image a test-class InfoMessage for using the attributes above:
(This class is just a class from my project, look at the attributes located above the property declarations of Message and Title)



   1: /// <summary>
   2: /// Respresents an information-message about the domain,
   3: /// that appears to the screen when the domain is loaded.
   4: /// </summary>
   5: [DataContract()]
   6: [Serializable]
   7: public class InfoMessage : Concurrency
   8: {
   9:     #region Fields
  10:  
  11:     // ...
  12:  
  13:     #endregion
  14:  
  15:     #region Constructor(s)
  16:  
  17:     // ...
  18:  
  19:     #endregion
  20:  
  21:     #region Properties
  22:  
  23:     /// <summary>
  24:     /// Gets/sets the ID of the InfoMessage
  25:     /// </summary>
  26:     [Browsable(false)]    // This property does not appear in the PropertyGrid Control.
  27:     [DataMember]
  28:     public Guid IDInfoMessage
  29:     {
  30:         get { return _iDInfoMessage; }
  31:         set { _iDInfoMessage = value; }
  32:     }
  33:  
  34:     /// <summary>
  35:     /// Gets/sets the message.
  36:     /// </summary>
  37:     [DataMember]
  38:     [GlobalDescription("InfoMessage_Message"), GlobalCategory("InfoMessage")]
  39:     public string Message
  40:     {
  41:         get { return _message; }
  42:         set { _message = value; }
  43:     }
  44:  
  45:     /// <summary>
  46:     /// Gets/sets the title.
  47:     /// </summary>
  48:     [DataMember]
  49:     [GlobalDescription("InfoMessage_Title"), GlobalCategory("InfoMessage")]
  50:     public string Title
  51:     {
  52:         get { return _title; }
  53:         set { _title = value; }
  54:     }
  55:  
  56:     #endregion
  57: }

The third class/part is to create some classes that can localize the Property-names displayed in the PropertyGrid. This is a little bit nifty because the control uses reflection to get the property-names that it's going to display.
The client want to replace the property-name 'Message' with the localized dutch value 'Bericht:' for example.

The things we must do to get this job done:



  1. Create an attribute for defining which property to change
    It's not needed when the values are formatted in a certain way in the resource file.
    The format is: classname_propertyname

  2. Create a custom PropertyDescriptor to fetch the localized description values.

  3. Create an object that implements ICustomTypeDescriptor so that the 'GetProperties' method is overridden. This method is called by the PropertyGrid when it requests the names of the properties that it wants do display.

This knowing, we can start programming the actual classes.


The Attribute (1):



   1: using System;
   2: using System.Collections.Generic;
   3: using System.Text;
   4:  
   5: namespace Whizzo3D.Engine.Localization
   6: {
   7:     /// <summary>
   8:     /// Defines an attribute for giving a key to localize the propertyname itself.
   9:     /// </summary>
  10:     /// <remarks>
  11:     /// We don't need this attribute of the keys are in the form of
  12:     /// classname_propertyname in the resource file.
  13:     /// </remarks>
  14:     [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
  15:     public class GlobalPropertyAttribute : Attribute
  16:     {
  17:         private string _resourceKey = "";
  18:  
  19:         public GlobalPropertyAttribute()
  20:         {
  21:             _resourceKey = string.Empty;
  22:         }
  23:  
  24:         public GlobalPropertyAttribute(string nameKey)
  25:         {
  26:             _resourceKey = nameKey;
  27:         }
  28:  
  29:         public String NameKey
  30:         {
  31:             get { return _resourceKey; }
  32:             set { _resourceKey = value; }
  33:         }
  34:     }
  35: }

The PropertyDescriptor (2):



   1: using System;
   2: using System.Collections.Generic;
   3: using System.Text;
   4: using System.ComponentModel;
   5:  
   6: namespace Whizzo3D.Engine.Localization
   7: {
   8:     /// <summary>
   9:     /// GlobalizedPropertyDescriptor enhances the base class obtaining the display name for a property
  10:     /// from the resource.
  11:     /// </summary>
  12:     public class GlobalPropertyDescriptor : PropertyDescriptor
  13:     {
  14:         private PropertyDescriptor basePropertyDescriptor;
  15:         private string localizedName = "";
  16:  
  17:         #region Constructor(s)
  18:  
  19:         public GlobalPropertyDescriptor(PropertyDescriptor basePropertyDescriptor)
  20:             : base(basePropertyDescriptor)
  21:         {
  22:             // Set the property-descriptor where we work on.
  23:             this.basePropertyDescriptor = basePropertyDescriptor;
  24:         }
  25:  
  26:         #endregion
  27:  
  28:         #region Abstract Dummy Methods
  29:  
  30:         public override bool CanResetValue(object component)
  31:         {
  32:             return basePropertyDescriptor.CanResetValue(component);
  33:         }
  34:  
  35:         public override Type ComponentType
  36:         {
  37:             get { return basePropertyDescriptor.ComponentType; }
  38:         }
  39:  
  40:         public override object GetValue(object component)
  41:         {
  42:             return this.basePropertyDescriptor.GetValue(component);
  43:         }
  44:  
  45:         public override bool IsReadOnly
  46:         {
  47:             get { return this.basePropertyDescriptor.IsReadOnly; }
  48:         }
  49:  
  50:         public override string Name
  51:         {
  52:             get { return this.basePropertyDescriptor.Name; }
  53:         }
  54:  
  55:         public override Type PropertyType
  56:         {
  57:             get { return this.basePropertyDescriptor.PropertyType; }
  58:         }
  59:  
  60:         public override void ResetValue(object component)
  61:         {
  62:             this.basePropertyDescriptor.ResetValue(component);
  63:         }
  64:  
  65:         public override bool ShouldSerializeValue(object component)
  66:         {
  67:             return this.basePropertyDescriptor.ShouldSerializeValue(component);
  68:         }
  69:  
  70:         public override void SetValue(object component, object value)
  71:         {
  72:             this.basePropertyDescriptor.SetValue(component, value);
  73:         }
  74:  
  75:         public override string Description
  76:         {
  77:             get { return basePropertyDescriptor.Description; }
  78:         }
  79:  
  80:         #endregion
  81:  
  82:         #region Abstract Overrides
  83:  
  84:         /// <summary>
  85:         /// Gets the displayname for a property
  86:         /// </summary>
  87:         public override string DisplayName
  88:         {
  89:             get
  90:             {
  91:                 // Get the propertyName from the resources file.
  92:                 // For this 3D Engine it is 'PropertyNames'
  93:  
  94:                 // The displaynameKey for this property (localized)
  95:                 string displayNameKey = string.Empty;
  96:                 // Look for the defined attribute 'GlobalizedPropertyAttribute'
  97:                 foreach (Attribute oAttrib in this.basePropertyDescriptor.Attributes)
  98:                 {
  99:                     if (oAttrib.GetType().Equals(typeof(GlobalPropertyAttribute)))
 100:                     {
 101:                         displayNameKey = (oAttrib as GlobalPropertyAttribute).NameKey;
 102:                         break;
 103:                     }
 104:                 }
 105:  
 106:                 if (string.IsNullOrEmpty(displayNameKey))
 107:                     displayNameKey = basePropertyDescriptor.DisplayName;
 108:  
 109:                 this.localizedName = PropertyNames.ResourceManager.GetString(displayNameKey);
 110:  
 111:                 if (string.IsNullOrEmpty(this.localizedName))
 112:                     this.localizedName = basePropertyDescriptor.DisplayName;
 113:  
 114:                 return this.localizedName;
 115:             }
 116:         }
 117:  
 118:         #endregion
 119:     }
 120: }

The base-class where we have to inherit from with the localizable objects (3):



   1: using System;
   2: using System.Collections.Generic;
   3: using System.Text;
   4: using System.ComponentModel;
   5: using System.Resources;
   6:  
   7: using Whizzo3D.Engine.Localization;
   8:  
   9: namespace Whizzo3D.Engine.Objects
  10: {
  11:     /// <summary>
  12:     /// This base-object implements ICustomTypeDescriptor.
  13:     /// The main-task of this object is to instatiate our own
  14:     /// specialized property descriptor.
  15:     /// </summary>
  16:     public abstract class GlobalizedObject : ICustomTypeDescriptor
  17:     {
  18:         private PropertyDescriptorCollection _globalizedProps;
  19:  
  20:         /// <summary>
  21:         /// Instantiate a new localized object.
  22:         /// </summary>
  23:         protected GlobalizedObject()
  24:         {
  25:             // Default constructor
  26:         }
  27:  
  28:         #region ICustomTypeDescriptor Members
  29:  
  30:         public string GetClassName()
  31:         {
  32:             string className = TypeDescriptor.GetClassName(this, true);
  33:             return className;
  34:         }
  35:  
  36:         public AttributeCollection GetAttributes()
  37:         {
  38:             return TypeDescriptor.GetAttributes(this, true);
  39:         }
  40:  
  41:         public String GetComponentName()
  42:         {
  43:             return TypeDescriptor.GetComponentName(this, true);
  44:         }
  45:  
  46:         public TypeConverter GetConverter()
  47:         {
  48:             return TypeDescriptor.GetConverter(this, true);
  49:         }
  50:  
  51:         public EventDescriptor GetDefaultEvent()
  52:         {
  53:             return TypeDescriptor.GetDefaultEvent(this, true);
  54:         }
  55:  
  56:         public PropertyDescriptor GetDefaultProperty()
  57:         {
  58:             return TypeDescriptor.GetDefaultProperty(this, true);
  59:         }
  60:  
  61:         public object GetEditor(Type editorBaseType)
  62:         {
  63:             return TypeDescriptor.GetEditor(this, editorBaseType, true);
  64:         }
  65:  
  66:         public EventDescriptorCollection GetEvents(Attribute[] attributes)
  67:         {
  68:             return TypeDescriptor.GetEvents(this, attributes, true);
  69:         }
  70:  
  71:         public EventDescriptorCollection GetEvents()
  72:         {
  73:             return TypeDescriptor.GetEvents(this, true);
  74:         }
  75:  
  76:         /// <summary>
  77:         /// Called to get the properties of a type.
  78:         /// </summary>
  79:         /// <param name="attributes"></param>
  80:         /// <returns></returns>
  81:         public PropertyDescriptorCollection GetProperties(Attribute[] attributes)
  82:         {
  83:             if (_globalizedProps == null)
  84:             {
  85:                 // Get the collection of properties
  86:                 PropertyDescriptorCollection baseProps = TypeDescriptor.GetProperties(this, attributes, true);
  87:  
  88:                 _globalizedProps = new PropertyDescriptorCollection(null);
  89:  
  90:                 // For each property use a property descriptor of our own that is able to be globalized
  91:                 for (int i = 0; i < baseProps.Count; i++)
  92:                     _globalizedProps.Add(new GlobalPropertyDescriptor(baseProps[i]));
  93:             }
  94:             return _globalizedProps;
  95:         }
  96:  
  97:         public PropertyDescriptorCollection GetProperties()
  98:         {
  99:             // Only do once
 100:             if (_globalizedProps == null)
 101:             {
 102:                 // Get the collection of properties
 103:                 PropertyDescriptorCollection baseProps = TypeDescriptor.GetProperties(this, true);
 104:                 _globalizedProps = new PropertyDescriptorCollection(null);
 105:  
 106:                 // For each property use a property descriptor of our own that is able to be globalized
 107:                 foreach (PropertyDescriptor oProp in baseProps)
 108:                 {
 109:                     _globalizedProps.Add(new GlobalPropertyDescriptor(oProp));
 110:                 }
 111:             }
 112:             return _globalizedProps;
 113:         }
 114:  
 115:         public object GetPropertyOwner(PropertyDescriptor pd)
 116:         {
 117:             return this;
 118:         }
 119:  
 120:         #endregion
 121:     }
 122:  
 123: }

Now, this in mind, look back at the InfoMessage test-class. It inherits from Concurrency (it's an object of me to keep track of concurrency), and the Concurrency object inherits from the GlobalizedObject.


As far as the implementions it's done. Now we have to adjust the resource file(s).


The PropertyDescriptor uses the PropertyNames resource file(s), to get the localized values for the keys.
The descriptor makes it's own keys, all you have to is fill them in in the resource file.
Something like this:




If you supply an InfoMessage object to the PropertyGrid, then it will be localized.


The only problem now is the localization of enum-values if you have enums in the class that you want to supply to the PropertyGrid.


For localizing the enum-values, we need a TypeConverter, more specific the EnumConverter from .NET
We have to inherit from it to create our own converter.
And we have to cache the relation between the localized values and the original enum-values.


The converter looks like this:



   1: /// <summary>
   2: /// This class is a converter for enums.
   3: /// Purpose is to localize enum values.
   4: /// </summary>
   5: public class GlobalEnumConverter : EnumConverter
   6: {
   7:     Dictionary<CultureInfo, Dictionary<string, object>> _lookupTables;
   8:  
   9:     /// <summary>
  10:     /// Instantiate a new Enum Converter
  11:     /// </summary>
  12:     /// <param name="type">Type of the enum to convert</param>
  13:     public GlobalEnumConverter(Type type)
  14:         : base(type)
  15:     {
  16:         _lookupTables = new Dictionary<CultureInfo, Dictionary<string, object>>();
  17:     }
  18:  
  19:     /// <summary>
  20:     /// The lookuptable holds the references between the original values and the localized values.
  21:     /// </summary>
  22:     /// <param name="culture">Culture for which the localization pairs must be fetched (or created)</param>
  23:     /// <returns>Dictionary</returns>
  24:     private Dictionary<string, object> GetLookupTable(CultureInfo culture)
  25:     {
  26:         Dictionary<string, object> result = null;
  27:         if (culture == null)
  28:             culture = CultureInfo.CurrentCulture;
  29:  
  30:         if (!_lookupTables.TryGetValue(culture, out result))
  31:         {
  32:             result = new Dictionary<string, object>();
  33:             foreach (object value in GetStandardValues())
  34:             {
  35:                 string text = ConvertToString(null, culture, value);
  36:                 if (text != null)
  37:                 {
  38:                     result.Add(text, value);
  39:                 }
  40:             }
  41:             _lookupTables.Add(culture, result);
  42:         }
  43:         return result;
  44:     }
  45:  
  46:     /// <summary>
  47:     /// Convert the localized value to enum-value
  48:     /// </summary>
  49:     /// <param name="context"></param>
  50:     /// <param name="culture"></param>
  51:     /// <param name="value"></param>
  52:     /// <returns></returns>
  53:     public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
  54:     {
  55:         if (value is string)
  56:         {
  57:             Dictionary<string, object> lookupTable = GetLookupTable(culture);
  58:             //LookupTable lookupTable = GetLookupTable(culture);
  59:             object result = null;
  60:             if (!lookupTable.TryGetValue(value as string, out result))
  61:             {
  62:                 result = base.ConvertFrom(context, culture, value);
  63:             }
  64:             return result;
  65:             //return base.ConvertFrom(context, culture, value);
  66:         }
  67:         else
  68:         {
  69:             return base.ConvertFrom(context, culture, value);
  70:         }
  71:     }
  72:  
  73:     /// <summary>
  74:     /// Convert the enum value to a localized value
  75:     /// </summary>
  76:     /// <param name="context"></param>
  77:     /// <param name="culture"></param>
  78:     /// <param name="value"></param>
  79:     /// <param name="destinationType"></param>
  80:     /// <returns></returns>
  81:     public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)
  82:     {
  83:         if (value != null && destinationType == typeof(string))
  84:         {
  85:             Type type = value.GetType();
  86:             string resourceName = string.Format("{0}_{1}", type.Name, value.ToString());
  87:             string result = PropertyNames.ResourceManager.GetString(resourceName, culture);
  88:             if (result == null)
  89:                 result = resourceName;
  90:             return result;
  91:         }
  92:         else
  93:         {
  94:             return base.ConvertTo(context, culture, value, destinationType);
  95:         }
  96:     }
  97: }

If you want to use it, take your enum-declaration and put the TypeConverterAttribute above it:



   1: // The EnumMember and DataContract attributes are stuff from .NET 3.0...
   2: [Flags]
   3: [DataContract()]
   4: [Serializable]
   5: [TypeConverter(typeof(GlobalEnumConverter))]
   6: public enum StatusFlag : int
   7: {
   8:     [EnumMember]
   9:     None = 0,
  10:     [EnumMember]
  11:     Draft = 1,          // Kladwerk
  12:     [EnumMember]
  13:     Deleted = 2,        // Verwijderd
  14:     [EnumMember]
  15:     ToValidate = 4,     // Te valideren
  16:     [EnumMember]
  17:     Invalid = 8,        // Ongeldig
  18:     [EnumMember]
  19:     Valid = 16,         // Geldig
  20:     [EnumMember]
  21:     ToRevise = 32         // Te herbekijken
  22: }

Look at the resource image for seeing the declaration of the keys needed to localize this enum-values.


Mind the [TypeConverter(typeof(GlobalEnumConverter))] attribute on top of the declaration, that line makes sure that the converter is used when the PropertyGrid wants to display the enum-values.


Just change your Culture of you current thread and see that the PropertyGrid will change it's values ;)


Hope this helps, if there are any questions, you know where to find me.


Regards,


Whizzo

3 comments:

Anonymous said...

Waarom maak je niet een GlobalDisplayNameAttribute afgeleid van DisplayNameAttribute ipv een TypeDescriptor gebruiken?

Anonymous said...

This Custom Property Grid Control is able to display the properties of any object in a user-friendly way and allows the end users of your applications edit the properties of the object.

Unknown said...

If you're interested in software localization tools that can help you better manage the translation of an app's strings, I recommend you check out the collaborative localization platform https://poeditor.com/