ETO Form Flow Layout

I see, thank you for that!

Note, i did not keep track with the change from @Gijs which you need to add please. Btw. is there a bug when entering an uppercase tag it appears twice ?

Sure enough, good catch on the text case bug. I updated the UpdateDynamicLayout function to fix this and it shouldn’t happen anymore.

Here’s the updated code with all the most recent updates:

import System
import Rhino
import scriptcontext
import rhinoscriptsyntax as rs

import Eto

from System.Collections.Generic import List

class ObjectTaggingDialog(Eto.Forms.Dialog[bool]):
    def __init__(self, selected_objs):
        
        # the selected objects as class wide variable
        self.selected_objs = selected_objs
        
        Rhino.UI.EtoExtensions.UseRhinoStyle(self)
        self.Title = "Tag Object"
        self.Size = Eto.Drawing.Size(300, 220) 
        self.MinimumSize = Eto.Drawing.Size(320, 165)
        self.MaximumSize = Eto.Drawing.Size(320, -1)
        
        self.Padding = Eto.Drawing.Padding(10)
        self.Resizable = True
        self.AutoSize = False
        self.Font = Eto.Drawing.SystemFonts.Default()
        
        # searchBox
        self.search_text = ""  # Store the search text
        self.is_updating_layout = False  # Flag to indicate whether layout update is in progress
        
        self.searchBox = Eto.Forms.SearchBox()  # Define searchBox here
        self.searchBox.PlaceholderText = "Search..."
        self.searchBox.TextChanged += self.OnSearchBoxTextChanged  # Handle text changed event
        self.searchBox.KeyDown += self.OnSearchBoxKeyDown  # Handle key down event
        
        # Create TextBox For Tag Entries
        self.labelBox = Eto.Forms.TextBox(ShowBorder=False)  # Provide the placeholder text
        self.labelBox.PlaceholderText = "Add tag(s) here..."
        self.labelBox.Height = 25  # Adjust the height of the text box
        self.labelBox.KeyDown += self.OnTextBoxKeyDown
        
        # the layout for tags
        self.layout = Eto.Forms.DynamicLayout()
        self.layout.DefaultSpacing = Eto.Drawing.Size(4,4) # propagates
        self.layout.Padding = Eto.Drawing.Padding(0, 6, 0, 0) # controls the gap above the tags
        
        # scrollable 
        self.scrollable = Eto.Forms.Scrollable()
        self.scrollable.ExpandContentHeight = True
        self.scrollable.ExpandContentWidth = True
        self.scrollable.ScrollSize = Eto.Drawing.Size(260, -1) # 260 or smaller prevents that the horizontal scroll bar comes up
        self.scrollable.Border = Eto.Forms.BorderType.None
        self.scrollable.Content = self.layout
        
        # create a stack layout which has 2 sections, top section holds the searchbox
        # and text box, bottom section holds a scrollable which contains dynamic layout
        self.stack_layout = Eto.Forms.StackLayout()
        self.stack_layout.Orientation = Eto.Forms.Orientation.Vertical
        self.stack_layout.HorizontalContentAlignment = Eto.Forms.HorizontalAlignment.Stretch
        self.stack_layout.Padding = Eto.Drawing.Padding(0,0)
        self.stack_layout.Spacing = 2
        self.stack_layout.Items.Add(Eto.Forms.StackLayoutItem(self.searchBox, False))
        self.stack_layout.Items.Add(Eto.Forms.StackLayoutItem(self.labelBox, False))
        self.stack_layout.Items.Add(Eto.Forms.StackLayoutItem(self.scrollable, True)) # True means it does stretch vertically
        
        self.Content = self.stack_layout
        
        # add events
        self.SizeChanged += self.MyOnSizeChangedEvent
        self.Load += self.OnDialogLoad
        self.Closing += self.OnDialogClosing

    def MyOnSizeChangedEvent(self, sender, e):
        try:
            self.UpdateDynamicLayout()
        except Exception as ex:
            print ex
            return

    def UpdateDynamicLayout(self):
        # Store the search text before clearing the layout
        self.search_text = self.searchBox.Text.strip().lower()
        
        # Clear the layout
        self.layout.Clear()

        controls = List[Eto.Forms.Control]()
        row_controls = []
        row_width = 0

        # button_width = 120  # Fixed button width
        max_row_width = self.layout.Width  # Maximum row width based on layout width
        
        # Initialize unique tags set
        unique_tags = set()
        
        # Collect all tags from all selected objects
        for obj in self.selected_objs:
            existing_tags = rs.GetUserText(obj, "Tags")
            if existing_tags:
                # Split existing tags and add them to the unique_tags set
                unique_tags.update(existing_tags.lower().split(","))  # Convert tags to lowercase before adding
        
        # Add newly added or appended tags from the text box if they are not already in unique_tags
        new_tag_text = self.labelBox.Text.strip()
        # Check if the new tag contains commas
        if new_tag_text and ',' not in new_tag_text:
            # Only add the new tag if it's not already in unique_tags
            if new_tag_text.lower() not in unique_tags:
                unique_tags.add(new_tag_text.lower())  # Convert new tag to lowercase before adding
        
        # Filter tags based on the search text if it's not empty
        search_text = self.search_text
        # print("Search text:", search_text)
        if search_text:
            filtered_tags = [tag for tag in unique_tags if search_text in tag]
            # print("Filtered tags:", filtered_tags)
        else:
            filtered_tags = list(unique_tags)
            # print("No search text. Showing all tags.")
        
        # Iterate over filtered tags and populate the UI
        for tag in filtered_tags: 
            delete_button_text = (tag +  str("   X"))
            delete_button_width = Eto.Drawing.Font.MeasureString(self.Font, delete_button_text).Width + 30
            delete_button = Eto.Forms.Button(Text=delete_button_text, BackgroundColor=Eto.Drawing.Colors.DimGray, Width=delete_button_width)
            delete_button.BackgroundColor = Eto.Drawing.Colors.Transparent
            delete_button.Tag = tag  # Set the tag text as Tag for the button
            delete_button.Click += self.OnDeleteButtonClick
            
            if row_width + delete_button_width > max_row_width:  # Check if adding the next control exceeds the width
                row_controls.append(None)  # Add a placeholder control for spacing
                controls.AddRange(row_controls)  # Add row controls to the main controls list
                self.layout.AddSeparateRow(System.Array[Eto.Forms.Control](controls))  # Add controls as a separate row
                controls.Clear()  # Clear the controls list for the next row
                # Clear the row controls list for the next row
                row_controls = []
                row_width = 0  # Reset row width
            
            row_controls.append(delete_button)  # Add the button to the row controls
            row_width += delete_button_width  # Update the row width
        
        if row_controls:  # Add the remaining row controls if any
            controls.AddRange(row_controls)
            controls.Add(None) # Add an empty item which gets scaled
            self.layout.AddSeparateRow(System.Array[Eto.Forms.Control](controls))
        
        self.layout.AddSpace()
        
        self.layout.Create()


    def OnSearchBoxKeyDown(self, sender, e):
        # Check if Enter key is pressed
        if e.Key == Eto.Forms.Keys.Enter:
            # Call the layout update function
            self.UpdateDynamicLayout()

    def OnSearchBoxTextChanged(self, sender, e):
        # Real-Time search feedback
        self.UpdateDynamicLayout()


    def append_tags(self, objs, additional_values):
        appended_count = 0
        existing_count = 0
        
        # Ensure objs is a list, even if it contains only one object
        if not isinstance(objs, list):
            objs = [objs]
        
        for obj in objs:
            existing_value = rs.GetUserText(obj, "Tags")
            if existing_value:
                existing_values = existing_value.lower().split(",")  # Convert existing values to lowercase and split them
                new_values = [val.lower() for val in additional_values if val.lower() not in existing_values]
                if new_values:
                    new_value = ",".join(existing_values + new_values)
                    rs.SetUserText(obj, "Tags", new_value)
                    appended_count += 1
                else:
                    existing_count += 1  # Increment existing count if tag already exists for this object
            else:
                lowercased_values = [val.lower() for val in additional_values]
                rs.SetUserText(obj, "Tags", ",".join(lowercased_values))
                appended_count += 1
        
        # Print concise statement
        if appended_count > 0 or existing_count > 0:
            message = ""
            if appended_count > 0:
                message += "Tag added to {} objects".format(appended_count)
            if existing_count > 0:
                if message:
                    message += ", "
                message += "Tag already exists in {} objects".format(existing_count)
            print(message)
        
        else:
            lowercased_values = [val.lower() for val in additional_values]
            rs.SetUserText(obj, "Tags", ",".join(lowercased_values))
            print("Tags set successfully.") 

    def remove_tags(self, tag_text):
        print('"' + str(tag_text) + '"' + " tag removed successfully.")

        # Remove the tag from the user text list
        for obj in self.selected_objs:
            existing_tags = rs.GetUserText(obj, "Tags")
            if existing_tags:
                existing_tags = [tag.strip() for tag in existing_tags.split(",")]
                if tag_text in existing_tags:
                    existing_tags.remove(tag_text)
                    rs.SetUserText(obj, "Tags", ",".join(existing_tags))


    def OnDeleteButtonClick(self, sender, e):
        # Get the tag text from the button's Tag property
        tag_text = sender.Tag
        if tag_text:
            # Remove the tag from the UI
            self.remove_tags(tag_text)
            # Update the layout to reflect the changes
            self.UpdateDynamicLayout()

    def OnTextBoxKeyDown(self, sender, e):
        if e.Key == Eto.Forms.Keys.Enter:
            # Get the text entered into the text box and split it by commas
            tag_texts = self.labelBox.Text.strip().split(",")
            for tag_text in tag_texts:
                tag_text = tag_text.strip()  # Remove leading and trailing spaces
                if tag_text:
                    # Append tags to selected objects
                    if self.selected_objs:
                        tags = [tag_text]
                        self.append_tags(self.selected_objs, tags)  # Pass selected_objs as a list
                        # Update the UI tag list
                        self.UpdateDynamicLayout()
                    else:
                        print("No objects selected.")
            # Clear the text box
            self.labelBox.Text = ""

    def OnDialogLoad(self, sender, e):
        
        # restores dialog location from last use
        key = "MC_TagObjects_DialogLocation"
        if scriptcontext.sticky.has_key(key): self.Location = scriptcontext.sticky[key]
        
        # set focus to the tag entering labelBox
        self.labelBox.Focus()

    def OnDialogClosing(self, sender, e):
        
        # save dialog location for next use
        scriptcontext.sticky["MC_TagObjects_DialogLocation"] = self.Location

def GetIcon():
    
    # create bitmap from bytestring
    bytes = System.Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsIAAA7CARUoSoAAAAF9SURBVDhPpdFLSwJRFAfwcy7kpiSwqE3R7JRGgxa9xkqjktpUEqHWOlrVpkXQIoq+QJC76EFgkNrLhJBAP0O10jZBtHfhxkpP9yoTzgOLWvyZ4cL/N3fOASL6V75fpDZIensw45Gr8Tox43Zg2iuDb7YfYKZPGz8/0wAbfqT8BaPCGaOPKKN8hNHrIaOVSXz0yCDN6RADsBPEPKUZ5cKsfLqGn8VzRpTkEAeXJ4yIEQhw4I5RQIFcezPsnqxiSQB0Y44YgG0B3DOKrmNhaRSes/tYpmtGpbg5YgC2FrAoALpi9M5nIJ6irKYW4YOV5gd0gGKHWDaMlf+mS23ZBHkYk2FcA9iawOK2Q1xFzIBSjCMpRslNpF4JEhqgowXA1ggNw3UQSjB646sNKpjydYPLAHTWQSrlI76lIUhNO8G6OKibgQqYIXRb/bIoT/FyiJcD+i3UAioy4oD40x7SywGjkKIt/wiItFrB4uqCYz6wiCiLa6tlA/DXmB7+PgRf7QIEb+tmZQoAAAAASUVORK5CYII=")
    bitmap = Eto.Drawing.Bitmap(bytes)
    
    # save bitmap in memory stream
    stream = System.IO.MemoryStream()
    bitmap.Save(stream, Eto.Drawing.ImageFormat.Png)
    
    # create icon from it
    icon = Eto.Drawing.Icon(stream)
    
    return icon

def EstablishDialog():
    
    # get or prompt for objects to tag, do not open dialog if no selection is made
    selected_objs = rs.GetObjects("Select object(s) to tag", rs.filter.allobjects, preselect=True, select=True)
    if not selected_objs: return  
    
    # open dialog and pass object ids
    dialog = ObjectTaggingDialog(selected_objs)
    dialog.Icon = GetIcon()
    dialog.ShowModal(Rhino.UI.RhinoEtoApp.MainWindow)

EstablishDialog()

2 Likes