Looking for Comprehensive, In-Depth Eto / MVVM Reference

Hello friends,
I’ve been working with RhinoCommon and Eto for a couple of years now, and I find myself running into the limits of the documentation that I’ve been able to find for Eto. It seems to me that Eto itself is based on concepts established in other UI frameworks (like Windows Forms or ??? I don’t know much about them, except that Eto is intended to be a cross-platform system so we don’t have to re-write for Windows vs MacOS). It seems like Eto uses certain concepts that are used elsewhere (like data contexts and data bindings) but I’ve not yet found explanations/examples/documentation of those concepts specifically for Eto.

Does anyone have any suggestions for a detailed reference work on a framework similar to Eto, perhaps, that would help guide me on these advanced issues?

One specific example where my conceptual understanding is weak (in addition to the general concept of how data contexts and bindings work) is building a TreeGridView, that can assign true/false properties (i.e. using check boxes) to an object. Some implementation details:

  • For each object, its property data is stored in Attributes.UserDictionary, in a string[], (e.g. {"main", "all"}, or {"balcony", "all"})
  • The plugin class has a Dictionary<string, HashSet<Guid>> that collects all of the string names of the property, as well as all of the Guids of the objects that have that property.
  • Right now, this property is set on a per-Layer basis from another command, with the following code (excerpted from the specific command):
            var hitLayers = from layer in doc.Layers
                            where doc.Objects.GetObjectList(AR_Helpers.ARAllSrfObjTypesEnumSett(layer)).AllObjectsHit(editlist)
                            select layer.Index;
            if (Rhino.UI.Dialogs.ShowSelectMultipleLayersDialog(hitLayers, $"Layers to show Hits on list {editlist}", false, out int[] layerIndices))
            {
                foreach(int layer in hitLayers)
                    foreach (RhinoObject obj in doc.Objects.GetObjectList(AR_Helpers.ARAllSrfObjTypesEnumSett(doc.Layers[layer])))
                    {
                        ObjectAttributes newattr = obj.Attributes.Duplicate();
                        if(newattr.UserDictionary.ContainsKey(ARHitObjectID))
                        {
                            string[] hitlists = newattr.UserDictionary[ARHitObjectID] as string[];
                            string[] newlist = hitlists.Except(new[] { editlist }).ToArray();
                            if (newlist.Length > 0)
                                newattr.UserDictionary.Set(ARHitObjectID, newlist);
                            else
                                newattr.UserDictionary.Remove(ARHitObjectID);
                            doc.Objects.ModifyAttributes(obj, newattr, true);
                        }
                    }

                foreach (int layer in layerIndices)
                    foreach(RhinoObject obj in doc.Objects.GetObjectList(AR_Helpers.ARAllSrfObjTypesEnumSett(doc.Layers[layer])))
                    {
                        ObjectAttributes newattr = obj.Attributes.Duplicate();
                        if (newattr.UserDictionary.ContainsKey(ARHitObjectID))
                        {
                            string[] hitlists = newattr.UserDictionary[ARHitObjectID] as string[];
                            string[] newlist = hitlists.Append(editlist).ToArray();
                            newattr.UserDictionary.Set(ARHitObjectID, newlist);
                        }
                        else newattr.UserDictionary.Set(ARHitObjectID, new[] { editlist });
                        doc.Objects.ModifyAttributes(obj, newattr, true);
                    }
                return Result.Success;
            }
            else return Result.Cancel;
  • The previous code sets the list on each object in the layer, then the following code is triggered by the RhinoDoc.ModifyObjectAttributes event (excerpted), to add or remove those modified surfaces from the Dictionary in the plugin class (described above, which here is represented by parent.ARSim_HitLayers:
                    if (rhobj.Attributes.UserDictionary.ContainsKey(ARHitObjectID))
                    {
                        string[] lists = rhobj.Attributes.UserDictionary[ARHitObjectID] as string[];
                        foreach (string list in lists)
                        {
                            if (!parent.ARSim_HitLayers.ContainsKey(list)) parent.ARSim_HitLayers.Add(list, new HashSet<Guid>());
                            parent.ARSim_HitLayers[list].Add(rhobj.Id);
                        }
                    }

The point of the panel I’m writing is to be able to modify this attribute (i.e. the members of the string[] stored in the Attributes.UserDictionary) on a per-object basis, rather than a per-layer basis. (Please don’t worry about the Absorptive/Reflective/Transparent property; that part works just fine.)

The code for the entire panel follows:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Rhino;
using Rhino.UI;
using Rhino.DocObjects;
using static AcousticRays.AR_Helpers;
using static AcousticRays.Simulation.AR_Helpers_Simulation;
using Eto.Forms;
using Eto.Drawing;
using System.Security.Cryptography.X509Certificates;

namespace AR
{
    public class AR_PropPage_ObjectMaterial : ObjectPropertiesPage
    {
        public override string EnglishPageTitle => "AR Material";

        private AR_Panel_ObjectMaterial panel;

        public override object PageControl => panel ?? (panel = new AR_Panel_ObjectMaterial(p));

        public AR_PropPage_ObjectMaterial(ARPlugin p) 
        {
            this.p = p;
        }

        private ARPlugin p;

        public override bool ShouldDisplay(ObjectPropertiesPageEventArgs e)
        {
            //DebugLine($"arobjmatl shoulddisplay; {e.ObjectCount} objects, includes relevant: {e.IncludesObjectsType(ARSurfaceObjectTypes)}");
            return e.IncludesObjectsType(ARSurfaceObjectTypes);
        }

        public override void UpdatePage(ObjectPropertiesPageEventArgs e)
        {
            RhinoObject[] selectedObjects = e.GetObjects(ARSurfaceObjectTypes);
            panel.SelObjCount = selectedObjects.Length;
            panel.CountAbsorptive = 0;
            panel.CountReflective = 0;
            panel.CountTransparent = 0;
            panel.AnyLocked = false;
            foreach (RhinoObject obj in selectedObjects)
            {
                if (obj.Attributes.UserDictionary.TryGetEnumValue(ARMatID, out ARMaterial t))
                    switch (t)
                    {
                        case ARMaterial.Absorptive:
                            panel.CountAbsorptive++;
                            break;
                        case ARMaterial.Reflective:
                            panel.CountReflective++;
                            break;
                        case ARMaterial.Transparent:
                            panel.CountTransparent++;
                            break;
                    }
                if (obj.Attributes.UserDictionary.GetBool(ARLockObject, false))
                    panel.AnyLocked = true;
            }

            panel.ActiveOnly = false;
            panel.TransparentOnly = false;
            if (panel.CountReflective == panel.SelObjCount)
            {
                panel.ActiveOnly = true;
                panel.Status = ARMaterial.Reflective;
            }
            else if (panel.CountAbsorptive == panel.SelObjCount)
            {
                panel.ActiveOnly = true;
                panel.Status = ARMaterial.Absorptive;
            }
            else if (panel.CountTransparent == panel.SelObjCount)
            {
                panel.TransparentOnly = true;
                panel.Status = ARMaterial.Transparent;
            }
            else
            {
                panel.Status = ARMaterial.Unset;
                if (panel.CountReflective == 0 && panel.CountAbsorptive == 0)
                    panel.TransparentOnly = true;
                else if ((panel.CountAbsorptive + panel.CountReflective) == panel.SelObjCount)
                    panel.ActiveOnly = true;
            }

            panel.doc = e.Document;
            panel.RhinoObjects = selectedObjects;
            panel.UpdatePanelData();
            //DebugLine($"Updatepage SelObjCount: {panel.SelObjCount}, active: {panel.CountActive}, absorptive: {panel.CountAbsorptive}, transparent: {panel.CountTransparent}");
            return;
        }

        public override ObjectType SupportedTypes => ARSurfaceObjectTypes;
    }
    
    public class AR_Panel_ObjectMaterial : Panel
    {
        public ARMaterial Status { get; set; }
        public bool ActiveOnly { get; set; }
        public bool TransparentOnly { get; set; }
        public bool AnyLocked { get; set; }
        private RadioButton Reflective;
        private RadioButton Absorptive;
        private RadioButton Transparent;
        private Button clear;
        private GridColumn chkColumn, listColumn;
        private TreeGridView hitlayerlist;
        private bool inUpdate;
        internal RhinoDoc doc { private get; set; }
        public int CountReflective , CountAbsorptive , CountTransparent , SelObjCount ;
        public RhinoObject[] RhinoObjects;
        private ARPlugin p;

        public AR_Panel_ObjectMaterial(AcousticRays p)
        {
            DebugLine("material panel constructor");
            this.p = p;
            Reflective = new RadioButton();
            Absorptive = new RadioButton(Reflective);
            Transparent = new RadioButton(Reflective);
            clear = new Button { Text = "Clear Material", Size = new Size(-1, -1) };
            inUpdate = true;

            Reflective.CheckedChanged += Radio_CheckedChanged;
            Absorptive.CheckedChanged += Radio_CheckedChanged;
            Transparent.CheckedChanged += Radio_CheckedChanged;
            clear.Click += (sender, e) => { UpdateMaterial(clear: true); };

            CustomCell checkboxcells = new CustomCell
            {
                CreateCell = r =>
                {
                    TreeGridItem item = r.Item as TreeGridItem;
                    string listname = item.Values[0] as string;
                    var checkbox = new CheckBox();
                    checkbox.Checked = (bool)item.Values[0];
                    checkbox.CheckedChanged += (sender, args) =>
                    {
                        CheckBox box = sender as CheckBox;
                        bool enable = box.Checked ?? false;
                        foreach (var obj in RhinoObjects)
                        {
                            var newattr = obj.Attributes.Duplicate();
                            if (enable)
                            {
                                if (newattr.UserDictionary.ContainsKey(ARHitObjectID))
                                {
                                    string[] hitlists = newattr.UserDictionary[ARHitObjectID] as string[];
                                    string[] newlist = hitlists.Append(listname).ToArray();
                                    newattr.UserDictionary.Set(ARHitObjectID, newlist);
                                }
                                else
                                    newattr.UserDictionary.Set(ARHitObjectID, new[] { listname });
                            }
                            else if (newattr.UserDictionary.ContainsKey(ARHitObjectID))
                            {
                                string[] hitlists = newattr.UserDictionary[ARHitObjectID] as string[];
                                string[] newlist = hitlists.Except(new[] { listname }).ToArray();
                                newattr.UserDictionary.Set(ARHitObjectID, newlist);
                            }
                            doc.Objects.ModifyAttributes(obj, newattr, true);
                        }
                    };
                    return checkbox;
                }
            };

            chkColumn = new GridColumn
            {
                Editable = true,
                HeaderText = "",
                Resizable = false,
                Width = 20,
                DataCell = checkboxcells
            };

            listColumn = new GridColumn
            {
                Editable = false,
                HeaderText = "Hit List",
                Resizable = true,
                Width = 100,
                DataCell = new TextBoxCell(1)
            }; 
            hitlayerlist = new TreeGridView
            {
                Border = BorderType.Line,
                ShowHeader = true,
                AllowColumnReordering = false,
                GridLines = GridLines.None,
                Size = new Size(-1, -1),
                AllowMultipleSelection = true,
                Columns = { chkColumn, listColumn }
            };


            Padding = new Padding(5);

            var layout = new DynamicLayout()
            {
                Spacing = new Size(5, 5),
                Padding = new Padding(5),
                Size = new Size(-1, -1),
            };
            layout.AddRow(Reflective, Absorptive, Transparent, null);
            layout.AddRow(clear, null);
            layout.Add(null);

            var table = new TableLayout
            {
                Padding = new Padding(5),
                Size = new Size(-1, -1),
                Rows = { new TableRow(layout), new TableRow(hitlayerlist) }
            };
            Content = table;
        }

        private void Radio_CheckedChanged(object sender, EventArgs e)
        {
            if ((sender as RadioButton).Checked && !inUpdate) UpdateMaterial();
        }

        private void UpdatePanelText()
        {
            Reflective.Text = $"Reflective: {CountReflective}";
            Absorptive.Text = $"Absorptive: {CountAbsorptive}";
            Transparent.Text = $"Transparent: {CountTransparent}";
        }

        public void UpdatePanelData() // there's probably a better way to do this with events and bindings.
            // it doesn't update the item counts always, need extra click somewhere? Radios are fine tho...
        {
            //DebugLine("updatepanel");
            inUpdate = true;
            Content.Enabled = !AnyLocked;

            UpdatePanelText();
            switch (Status)
            {
                case ARMaterial.Reflective:
                    Reflective.Checked = true;
                    break;
                case ARMaterial.Absorptive:
                    Absorptive.Checked = true;
                    break;
                case ARMaterial.Transparent:
                    Transparent.Checked = true;
                    break;
                case ARMaterial.Unset:
                    Reflective.Checked = false;
                    Absorptive.Checked = false;
                    Transparent.Checked = false;
                    break;
            }
            HashSet<Guid> objectids = new HashSet<Guid>(RhinoObjects.Select(x => x.Id));
            TreeGridItemCollection hitlayers = new TreeGridItemCollection();
            foreach (KeyValuePair<string, HashSet<Guid>> hitlist in p.ARSim_HitLayers)
            {
                hitlayers.Add(new TreeGridItem(hitlist.Value.IsSupersetOf(objectids), hitlist.Key));
            }
            hitlayerlist.DataStore = hitlayers;
            inUpdate = false;
        }

        private void UpdateMaterial(bool clear=false)
        {
            uint undo = doc.BeginUndoRecord("PanelMaterialChange");
            if (clear) Status = ARMaterial.Unset;
            else if (Reflective.Checked) Status = ARMaterial.Reflective;
            else if (Absorptive.Checked) Status = ARMaterial.Absorptive;
            else if (Transparent.Checked) Status = ARMaterial.Transparent;

            //DebugLine($"updatematerial status: {Status}, obj count: {objects.Length}, objects: {objects}");

            CountAbsorptive = 0;
            CountReflective = 0;
            CountTransparent = 0;
            
            foreach (var obj in RhinoObjects)
            {
                var newattr = obj.Attributes.Duplicate();
                newattr.UserDictionary.SetEnumValue(ARMatID, Status);
                doc.Objects.ModifyAttributes(obj, newattr, true);
            }

            switch (Status)
            {
                case ARMaterial.Reflective:
                    CountReflective = SelObjCount;
                    break;
                case ARMaterial.Absorptive:
                    CountAbsorptive = SelObjCount;
                    break;
                case ARMaterial.Transparent:
                    CountTransparent = SelObjCount;
                    break;
                case ARMaterial.Unset:
                    break;
            }

            if (clear) UpdatePanelData();
            else UpdatePanelText();
            doc.EndUndoRecord(undo);
            return;
        }
    }
}

This code produces the following panel:

Amazingly, all this works. But I don’t entirely understand why. (I pasted the whole code so that others might benefit from my own struggle, and have a working example to start with.) This StackOverflow topic is the best example of how to use the CustomCell class I’ve found, but there’s not much meat there.

If I were to initialize the chkColumn with generic CheckBoxCell as such:

            chkColumn = new GridColumn
            {
                Editable = true,
                HeaderText = "",
                Resizable = false,
                Width = 20,
                DataCell = new CheckBoxCell(0)
            };

I don’t understand how I might get access to each CheckBoxCell to be able to confirm or modify its state (un/checked). I suppose the question is "Is CustomCell required, or can it be done with CheckBoxCell? (In this condition, nothing happens.)

I did have some success in the past with the TreeGridView to make an ersatz-Layer panel that operates as a command dialog (based on the Python code here: Eto.TreeGridView - Keyboard Cell Focus - #2 by spineribjoint1 ):

                int levels = 0;
                TreeGridItemCollection layerCollection = new TreeGridItemCollection();

                foreach (Layer layer in doc.Layers)
                {
                    //need to think about layer.IsReference? Could be for worksessions?
                    //EXCELLENT It does capture worksession layers, and correctly, too...

                    string[] names = layer.FullPath.Split(new string[] { Layer.PathSeparator }, StringSplitOptions.None);
                    int level = names.Length - 1;
                    if (level > levels) levels = level;
                    TreeGridItem item = new TreeGridItem(names[level], layer.UserDictionary.GetBool("AR_SWSD_Hit",false), layer.Index);
                    item.Expanded = layer.IsExpanded;
                    nodes.Add(new layerlistnodes { level = level, layer = layer, treeItem = item, layerIdx=layer.Index });
                }
                for (int i = 0; i <= levels; i++)
                {
                    foreach (layerlistnodes node in nodes)
                    {
                        if (node.level == i)
                        {
                            Guid parent = node.layer.ParentLayerId;
                            if (parent != Guid.Empty)
                            {
                                foreach (layerlistnodes ld in nodes)
                                {
                                    if (ld.layer.Id == parent)
                                    {
                                        ld.treeItem.Children.Add(node.treeItem);
                                    }
                                }
                            }
                            else layerCollection.Add(node.treeItem);
                        }
                    }
                }
                TreeGridView layerList = new TreeGridView
                {
                    Border = BorderType.Line,
                    ShowHeader = true,
                    AllowColumnReordering = false,
                    GridLines = GridLines.Horizontal,
                    DataStore = layerCollection,
                    Size = new Size(-1, -1),
                    AllowMultipleSelection = true,
                    Columns = {layColumn,chkColumn}
                };

However in this case the condition of each check box is read when the semi-modal panel is closed and the relevant process is started - no need to subscribe to any CheckBox.CheckedChanged events. Here’s the list box:

So, thanks to everyone who made it this far, and if you don’t have any suggestions on the code that’s fine, I’m really looking for an Eto reference.

Dan

Hi,

I’m not coding MVVM in Eto, but I do so professionally for WPF, Avalonia and other UI framewoks.

Foremost, sorry for not deep-diving into your code base. But from what I see there, I can’t spot any MVVM pattern inside. Usually, MVVM separates the UI from the core functionality. It uses a mediator in between both worlds called the ViewModel. Most UI elements can be created declarative, so one of the most important differences between the View and the ViewModel is the fact, that the View handles how the UI looks a like, while the ViewModel processes the interaction/logic between the User and the code.

Simple example: A “materials view” creates a material preview and some sliders and buttons. The “view model” then handles what happens if the user presses one of the buttons, and the “model” does a computation such as changing the material properties, or applying it to a geometry. If the model then answers that the look of the material has changed, the “view model” will then inform the “view” and providing all the necessary data to perform a partial re-render.

There is an important thing to know. The view doesn’t know the view model and vice versa. They are both linked by a classic observer pattern using data-binding. Also, the model doesn’t know any “viewmodel”. However, usually the viewmodel knows the model. Usually also by an interface e.g. to mock-out the model for certain test scenarios.

The main advantages of MVVM is that you can better test UI’s, that you can replace views without any change on how things work and that have a higher encapsulation and separation of concerns.

All the magic of MVVM is just about the observer pattern and INotifyPropertyChanged and ICommand interface. In C# this is quite the same for WPF, Avalonia or Eto.

In other words, if you know how to create MVVM in one of the other (better documented) technologies, the transition to Eto will be quite straightforward.

However, in C#, MVVM is not very intuitive at first. Also, there are many flavors and many level on how strict MVVM is supposed to be. Highly reactive UI’s are much harder to implement with strict MVVM.
So at some point you either break the pattern or over-engineer solutions.

However, the essence of this post is. If you want to know Eto and MVVM, just don’t use it for learning. Use Wpf or Avalonia. Besides this, if you write your own plugins, you can always utilize other UI frameworks. This is doable, you just need to include all the dependencies.

Hi Tom
Thanks for writing, and the straightforward explanation of MVVM concepts.
You’re right, I am definitely not following that pattern in my code examples. I can imagine that switching to a MVVM would be a major re-think for those items. Interesting to hear that it is often not so strictly adhered-to, and also that in C# it is not clear. There are other ideas percolating where it might benefit.
For learning MVVM on a different framework, do you have any literature references that you like?
Dan

Not sure if you are German,

So for me the best book about WPF and also MVVM is “WPF 4.5 and XAML”
ISBN: 978-3-446-43467-7
https://www.hanser-elibrary.com/doi/book/10.3139/9783446435414

It’s a bit outdated, but still explains WPF in a very comprehensive way. MVVM is a bit short, but it has everything you need to understand it. Without any extra dependencies…

You can also read this, but I find it not very easy to understand. It’s a bit overcomplicated:

Also, there are small libraries for MVVM like Prismn or ReactiveUI. I even think there is finally also a Microsoft MVVM library. I do have my own, because It’s not really complicated. It all boils down to write a ViewModelBase and a custom RelayCommand. And to implement some synchronization-helper methods like “InvokeOnUIThread()”.

1 Like

Hello Tom, I was thinking about using Avalonia in Rhino Windows/mac plugins for some time, but I did not had time to investigate this yet. Have you encountered any specific problems with integration of Avalonia in Rhino?

Thank you for any info.

Best
Łukasz

I haven’t tested it with Rhino, only standalone. Avalonia is a nice improvement of WPF. With some differences, but in general, those differences are rather addressing issues in WPF. On top of that, it’s cross-platform. I could imagine that the .Net version may conflict with the current Rhino one. But in general, you can always design the system to run in a separate process. Only having a small middleware doing communication with your GUI. Usually I like to do this extra work, because it gives you much more freedom in developing your own things, but of course it always depends on a couple of things. E.g. if you depend on Rhinocommon, then it might be a showstopper.
Anyway, for the purpose of learning, it’s a nice solution. And I guess once you understand how MVVM works, you can also better deal with Eto.

Hey @TomTom, do you know any good repo or resources for learning Avalonia? Or even examples of how to make middleware between your app and rhino?

I wasn’t sure if you might have any open projects or resources you use for learning.

I am trying to learn how to make nicer GUI in and out of rhino. Avalonia looks really nice!
Also, thanks for breaking down MVVM. I am going to be looking into this much more.

From some time I am working daily with WPF and when I have to switch to Eto it feels like step back. It would be nice if devs could give some examples UI in xeto. Dear @dale do you think this is possible?

@mlukasz87 - I am happy to code up sample. If you want to see something, please start a new thread and outline what you need help with.

Thanks,

– Dale