forms.SelectFromList - natural sort groups/dictionary

Hello pyRevit friends :slight_smile:

forms.SelectFromList is sorting groups with sorted() what gives me the following, unsatisfying result.

This is happening here:

So I would like to natural sort the dictionary, here is my first try on doing so:

convert = lambda text: int(text) if text.isdigit() else text.lower() 
alphanum_key = lambda key: [convert(c) for c in re.split('([0-9]+)', key)] 
x = dict(sorted(ops.items(),key=alphanum_key))

print x

Error:

line 394, in <lambda$14479>
TypeError: expected string for parameter 'string' but got 'tuple'

Would really appreciate any help for sorting the groups any better :slight_smile:
Kind Regards.

Well first of all, pythons built-in dictionaries cannot be sorted. They are inherently an unordered collection. If you want an ordered and sortable dict you can try the OrderedDict from the collections modules, but I don’t think sort and sorted work the same way with OrderedDict. Meaning the sorting method may be more complicated.

Also by python best practices you should define a function instead of assigning a lambda expression. lambda should only be used for in-lining something.
I looked at the code you pointed out some stuff that may be wrong or a little weird, but I don’t have your full code so I’m not sure how accurate it is.

convert = lambda text: int(text) if text.isdigit() else text.lower() 

# key in this lambda function looks like it is receiving a tuple and causing an error
alphanum_key = lambda key: [convert(c) for c in re.split('([0-9]+)', key)] 

# dict cannot be sorted, so converting to dict invalidates the sorting
# also key is sorting based on a list from alphanum_key. I'm not sure if that is your intention.
x = dict(sorted(ops.items(),key=alphanum_key))

print x
2 Likes

Hello Nicholas :slight_smile:

I have read a few articles about sorting dictionaries but still don´t get it.

But I can say that the sorted() method gives me a sorted dictionary that has not the same order than before. And this is also the result that I can see in the userinterface. So I don´t get why sorting should not be possible.

Here´s what ChatGPT suggests:

import re

def natural_sort_key(s):
    return [int(c) if c.isdigit() else c.lower() for c in re.split('(\d+)', s)]

def _prepare_context(self):
    if isinstance(self._context, dict) and self._context.keys():
        self._update_ctx_groups(sorted(self._context.keys(), key=natural_sort_key))
        new_ctx = {}
        for ctx_grp, ctx_items in self._context.items():
            new_ctx[ctx_grp] = self._prepare_context_items(ctx_items)
        self._context = new_ctx
    else:
        self._context = self._prepare_context_items(self._context)

Chat GPT solved it for me :slight_smile:

I changed the code in the forms__init__.py to the following code:

    def natural_sort_key(self, s):
            return [int(c) if c.isdigit() else c.lower() for c in re.split('(\d+)', s)]

    def _prepare_context(self):
        if isinstance(self._context, dict) and self._context.keys():
            sorted_keys = sorted(self._context.keys(), key=self.natural_sort_key)
            self._update_ctx_groups(sorted_keys)
            new_ctx = {}
            for ctx_grp, ctx_items in self._context.items():
                new_ctx[ctx_grp] = self._prepare_context_items(ctx_items)
            self._context = new_ctx
        else:
            self._context = self._prepare_context_items(self._context)

if you changed it here, it won’t stick when you will install a new version of pyRevit.
You may want to edit and make a PR on the repo

1 Like

I will do so @Jean-Marc
I think the best would be to just remove the sorted() method from the forms.SelectFromList so everyone can sort the groups like desired before calling the form.

The items (sheets) themselfes dont get a sorting from the form, so why do the groups need one.

For some reason the forms__init__.py file reset itself to initial state.
If this keeps happening I have a problem.

For now I´m using the method of removing the sorted() method from the init file and then sort my dictionary in the script with OrderedDict:

import re
from collections import OrderedDict

def natural_sort_key(key):
    return [int(c) if c.isdigit() else c.lower() for c in re.split('(\d+)', key)]

def SelectSheets():
    ops = {}
    AllSheets = []
    AllSheets.extend([MyOptionSheet(x) for x in Sheets if not x.IsPlaceholder])
    ops['0_All'] = AllSheets

    for SheetSetName, SheetsBySheetSet in zip(SheetSetNames,SheetsBySheetSets):
        sheets = []
        for SheetBySheetSet in SheetsBySheetSet:
            sheets.append(MyOptionSheet(SheetBySheetSet))
        ops[SheetSetName] = sheets
    
    sorted_ops = OrderedDict(sorted(ops.items(), key=lambda x: natural_sort_key(x[0])))

    if OpenSheets:
        selectedSheet = forms.SelectFromList.show(sorted_ops, 'Print Sheets PDF', group_selector_title='Sheet Sets', default_group= '0_Open', multiselect=False)
    else:
        selectedSheet = forms.SelectFromList.show(sorted_ops, 'Print Sheets PDF', group_selector_title='Sheet Sets', default_group= '0_All', multiselect=False)

    return selectedSheet

I added an additional argument to the class SelectFromList in the init.py file.
Also added a description and raising a PyRevitException for wrong argument input . Hope this will help someone in the future.

import re

class SelectFromList(TemplateUserInputWindow):
    """Standard form to select from a list of items.

    Any object can be passed in a list to the ``context`` argument. This class
    wraps the objects passed to context, in :obj:`TemplateListItem`.
    This class provides the necessary mechanism to make this form work both
    for selecting items from a list, and from a list of checkboxes. See the
    list of arguments below for additional options and features.

    Args:
        context (list[str] or dict[list[str]]):
            list of items to be selected from
            OR
            dict of list of items to be selected from.
            use dict when input items need to be grouped
            e.g. List of sheets grouped by sheet set.
        title (str, optional): window title. see super class for defaults.
        width (int, optional): window width. see super class for defaults.
        height (int, optional): window height. see super class for defaults.
        button_name (str, optional):
            name of select button. defaults to 'Select'
        name_attr (str, optional):
            object attribute that should be read as item name.
        multiselect (bool, optional):
            allow multi-selection (uses check boxes). defaults to False
        info_panel (bool, optional):
            show information panel and fill with .description property of item
        return_all (bool, optional):
            return all items. This is handly when some input items have states
            and the script needs to check the state changes on all items.
            This options works in multiselect mode only. defaults to False
        filterfunc (function):
            filter function to be applied to context items.
        resetfunc (function):
            reset function to be called when user clicks on Reset button
        group_selector_title (str):
            title for list group selector. defaults to 'List Group'
        default_group (str): name of defautl group to be selected
        sort_groups (str, optional): 
            Determines the sorting type applied to the list groups. This attribute can take one of the following values:
                'sorted': This will sort the groups in standard alphabetical order
                'natural': This will sort the groups in a manner that is more intuitive for human perception, especially when there are numbers involved.
                'unsorted': The groups will maintain the original order in which they were provided, without any reordering.
                Defaults to 'sorted'.


    Example:
        >>> from pyrevit import forms
        >>> items = ['item1', 'item2', 'item3']
        >>> forms.SelectFromList.show(items, button_name='Select Item')
        >>> ['item1']

        >>> from pyrevit import forms
        >>> ops = [viewsheet1, viewsheet2, viewsheet3]
        >>> res = forms.SelectFromList.show(ops,
        ...                                 multiselect=False,
        ...                                 name_attr='Name',
        ...                                 button_name='Select Sheet')

        >>> from pyrevit import forms
        >>> ops = {'Sheet Set A': [viewsheet1, viewsheet2, viewsheet3],
        ...        'Sheet Set B': [viewsheet4, viewsheet5, viewsheet6]}
        >>> res = forms.SelectFromList.show(ops,
        ...                                 multiselect=True,
        ...                                 name_attr='Name',
        ...                                 group_selector_title='Sheet Sets',
        ...                                 button_name='Select Sheets',
        ...                                 sort_groups='sorted')


        This module also provides a wrapper base class :obj:`TemplateListItem`
        for when the checkbox option is wrapping another element,
        e.g. a Revit ViewSheet. Derive from this base class and define the
        name property to customize how the checkbox is named on the dialog.

        >>> from pyrevit import forms
        >>> class MyOption(forms.TemplateListItem):
        ...    @property
        ...    def name(self):
        ...        return '{} - {}{}'.format(self.item.SheetNumber,
        ...                                  self.item.SheetNumber)
        >>> ops = [MyOption('op1'), MyOption('op2', True), MyOption('op3')]
        >>> res = forms.SelectFromList.show(ops,
        ...                                 multiselect=True,
        ...                                 button_name='Select Item')
        >>> [bool(x) for x in res]  # or [x.state for x in res]
        [True, False, True]

    """

    xaml_source = 'SelectFromList.xaml'

    @property
    def use_regex(self):
        """Is using regex?"""
        return self.regexToggle_b.IsChecked

    def _setup(self, **kwargs):
        # custom button name?
        button_name = kwargs.get('button_name', 'Select')
        if button_name:
            self.select_b.Content = button_name

        # attribute to use as name?
        self._nameattr = kwargs.get('name_attr', None)

        # multiselect?
        if kwargs.get('multiselect', False):
            self.multiselect = True
            self.list_lb.SelectionMode = Controls.SelectionMode.Extended
            self.show_element(self.checkboxbuttons_g)
        else:
            self.multiselect = False
            self.list_lb.SelectionMode = Controls.SelectionMode.Single
            self.hide_element(self.checkboxbuttons_g)

        # info panel?
        self.info_panel = kwargs.get('info_panel', False)

        # return checked items only?
        self.return_all = kwargs.get('return_all', False)

        # filter function?
        self.filter_func = kwargs.get('filterfunc', None)

        # reset function?
        self.reset_func = kwargs.get('resetfunc', None)
        if self.reset_func:
            self.show_element(self.reset_b)

        # context group title?
        self.ctx_groups_title = \
            kwargs.get('group_selector_title', 'List Group')
        self.ctx_groups_title_tb.Text = self.ctx_groups_title

        self.ctx_groups_active = kwargs.get('default_group', None)

        # group sorting?
        self.sort_groups = kwargs.get('sort_groups', 'sorted')
        if self.sort_groups not in ['sorted', 'unsorted', 'natural']:
            raise PyRevitException("Invalid value for 'sort_groups'. Allowed values are: 'sorted', 'unsorted', 'natural'.")

        # check for custom templates
        items_panel_template = kwargs.get('items_panel_template', None)
        if items_panel_template:
            self.Resources["ItemsPanelTemplate"] = items_panel_template

        item_container_template = kwargs.get('item_container_template', None)
        if item_container_template:
            self.Resources["ItemContainerTemplate"] = item_container_template

        item_template = kwargs.get('item_template', None)
        if item_template:
            self.Resources["ItemTemplate"] = \
                item_template

        # nicely wrap and prepare context for presentation, then present
        self._prepare_context()

        # setup search and filter fields
        self.hide_element(self.clrsearch_b)

        # active event listeners
        self.search_tb.TextChanged += self.search_txt_changed
        self.ctx_groups_selector_cb.SelectionChanged += self.selection_changed

        self.clear_search(None, None)

    def _prepare_context_items(self, ctx_items):
        new_ctx = []
        # filter context if necessary
        if self.filter_func:
            ctx_items = filter(self.filter_func, ctx_items)

        for item in ctx_items:
            if isinstance(item, TemplateListItem):
                item.checkable = self.multiselect
                new_ctx.append(item)
            else:
                new_ctx.append(
                    TemplateListItem(item,
                                     checkable=self.multiselect,
                                     name_attr=self._nameattr)
                    )

        return new_ctx

    @staticmethod
    def _natural_sort_key(key):
        return [int(c) if c.isdigit() else c.lower() for c in re.split('(\d+)', key)]

    def _prepare_context(self):
        if isinstance(self._context, dict) and self._context.keys():
            # Sort the groups if necessary
            if self.sort_groups == "sorted":
                sorted_groups = sorted(self._context.keys())
            elif self.sort_groups == "natural":
                sorted_groups = sorted(self._context.keys(), key=self._natural_sort_key)
            else:
                sorted_groups = self._context.keys()  # No sorting
            
            self._update_ctx_groups(sorted_groups)
            
            new_ctx = OrderedDict()
            for ctx_grp in sorted_groups:
                items = self._prepare_context_items(self._context[ctx_grp])
                new_ctx[ctx_grp] = items  # Do not sort the items within the groups

            self._context = new_ctx
        else:
            self._context = self._prepare_context_items(self._context)