Icon Viewer

2024-02-24

Preset browser with entries for all icons

I had recently been looking at rewriting an SDK sample from C++ to Python, the Color Synth Path. Modo allows developers to extend the preset browser with file like items (synthetic entries) for users to interact with (preset.do when double-clicked).

But as is usual for Modo it comes with tons of boilerplate, many interconnected components and little to no documentation. To piece together how it works there is the sample just mentioned and also the Cloud Assets browser, which was implemented in Python very similar to Color Synth Path. But it has seen a radical change since version 14.0v1 so if you're looking for preset examples in Python you need to install an earlier version.

During work I came across documentation for a Blender addon Icon Viewer that allowed developers to see all icons available. I remembered some Modo user had been looking for just that few years ago, so figured how hard can it be to make the preset browser do this.

Starting out, I already knew how to fetch all icons that Modo used. An icon in Modo sadly doesn't do much beyond give you it's name, an alias to a registered image. And optionally the position for the icon in the source image. Either as "location" - coordinates in pixels, or "grid" which is basically "third icon in the second row".

Icons are defined in configs (.cfg), Modo's xml which you are likely to have to learn to make mostly anything in Modo.

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <atom type="UIElements">
        <hash type="Image" key="my_big_icons">my_big_icons.tga</hash>
        <hash type="Icon" key="my.icon_32">
            <atom type="Source">my_big_icons</atom>
            <atom type="Location">64 32 32 32</atom>
        </hash>
    </atom>
</configuration>

Here we have the icon my.icon with the suffix _32 which Modo will know to be a big icon, 32x32 pixels. Small ones are 13x13 and mid sized 20x20. We could replace the Location with a Grid like this <atom type="Grid">3 2 32 32</atom>. Or if the source image is 32x32 we could skip defining either. Above the icon we define an Image which the icon will refer to by it's key. Its contents will be a relative path to the image, usually a tga or png. You can read more about how to define icons in the current official documentation here.

To find all icons, we get all import paths - folders that Modo will search for scrips, configs and probably much more. Then get the contents of those paths, filtering for any configs and attempt to parse these, I say attempt as some configs are malformatted according to xml.etree. Knowing the xml structure to define icons we can narrow down to only read those using XPath that ElementTree can use for searching.

import os
from xml.etree import ElementTree

import lx

platform_service = lx.service.Platform()
import_paths = [platform_service.ImportPathByIndex(i) for i in range(platform_service.ImportPathCount())]

icons = set()

for import_path in import_paths:
    for filename in os.listdir(import_path):
        if not filename.lower().endswith('.cfg'):
            continue
        config_path = os.path.join(import_path, config_path)
        try:
            root = ElementTree.parse(config_path)
            _icons = root.findall('.//atom[@type="UIElements"]/hash[@type="Icon"]')
            for icon in _icons:
                icons.add(icon)
        except ElementTree.ParseError:
            lx.out(f"Failed to parse {config_path}")

So with all the icons, I realized I would need to get path to the source images. As well the pixels that makes the icon from the grid or location atoms. To be extra cautious I skip any icon missing a key. It's valid xml but an invalid icon definition. Again, using XPath we get the child atom elements by types. element.find(xpath) will return the first found element, or None.

We do branching depending on if a location or grid defines the icon coordinates. If grid we will multiply the x by the icon width and y by the height, as the image should consist of a grid of icons. I chose to skip icons which were direct images but could be added at a later date.

To make it more readable, I could have saved icons as a set of Named Tuples instead of like now where it's a plain tuple.

for icon in _icons:
    key = icon.get('key', '')
    if not key:
        lx.out(f"No key for icon in {config_path}")
        continue

    # source will reference a registered image resource,
    source = icon.find('atom[@type="Source"]')

    # The x,y and width height will be stored as either a location, or grid entry
    location = icon.find('atom[@type="Location"]')
    grid = icon.find('atom[@type="Grid"]')

    # If it's a location, the x and y will be exact pixels icon starts at
    if isinstance(location, ElementTree.Element):
        x, y, w, h = [int(x) for x in location.text.strip().split(' ') if x]
    # Otherwise if it's defined as a grid, it will say sample icon x y in row col of
    # icons with same size.
    elif isinstance(grid, ElementTree.Element):
        x, y, w, h = [int(x) for x in grid.text.strip().split(' ') if x]
        x *= w
        y *= h
    else:
        lx.out(f"No location or grid specified for icon in {config_path}")
        continue

    icons.add((key, source.text.strip(), (x, y, w, h)))

But the icons don't reference images immediately as you remember. So we have to not only search for icons, but also all images. Some of these paths are stored as "aliases" - like 'resource:icons.png'. So earlier next to the Platform service we can also add the File service to solve the aliases.

...
root = ElementTree.parse(config_path)
_images = root.findall('.//atom[@type="UIElements"]/hash[@type="Image"]')
for image in _images:
    image_path = file_service.ToLocalAlias(image.text.strip())
    if not os.path.isabs(image_path):
        image_path = os.path.join(import_path, image_path)
    if os.path.isfile(image_path):
        images[image.get('key')] = image_path
...

I will not go into all the boilerplate code for how the preset is implemented. But in short there will be a DirCacheSyntheticEntry for each entry, it's methods pretty straight forward. Mostly "getters" for name, index, size etc.

Most of the logic will be in the DirCacheSynthetic, where the method for looking up entries is defined. As I just want to display all icons together and not as a file tree, all entries will be added to a root entry and we really don't need much of anything in a lookup method, but I still copied it over from my previous translation.

Then we need to define the PresetType class, as well as the PresetMetrics. Last one will have the method for returning the image what will be displayed as an icon in the preset browser. While the preset type will create a preset metric instance given a path to an entry.

I copied the code from ealier for getting all icons and images to the constructor of the synthetic. Creating a root entry, and an entry for each icon. Getting absolute path to any image instead of any alias.

for key, resource, size in icons:
    image_path = images.get(resource, '')
    if not image_path:
        continue  # failed to parse the source location for this image,

    entry = IconViewerPBSyntheticEntry(
        path=ICONVIEWERPRESET_SYNTH + ":" + key,
        name=key,
        is_file=True,
        size=size,
        resource=image_path
    )

    self.root.files.append(entry)

Now that all entries had all information I needed. It was time to get and display the icon images. If the docstrings from SDK is to be trusted, Modo will attempt to get the image from Preset Metrics, Thumbnail Image method. If that fails, check if the Thumbnail Resource returns anything. And as a fallback use the resource returned by the Preset Type, Generic Thumbnail Resource method.

I remembered having seen a method in the Image service, Create Crop which sounded just like what I was looking for to get icons from the full image resources. It took me a bug report to get information about how this should work. The x,y width and height input should all be floats, similar to texture lookups in games given u,v values. But there seem to be something I still don't understand with it, as using this method to create the icon images the preset browser would hang and cause Modo to become unstable. I guess that is for another bug report to help figure out.

...
image_service = lx.service.Image()
image = image_service.Load(image_path)
width, height = image.Size()
crop = image_service.CreateCrop(
    image,
    x / width,  # float inputs 0.0 .. 1.0 for realtive pixel positions
    y / height,
    (x + crop_width) / width,
    (y + crop_height) / height
)
...

I ended up copying the icons, pixel by pixel. Pixels are defined as a storage object, of type 'b' for byte - assuming we won't have icons of floating point types. I only added pixel storage objects for the formats that icons in default install had.

We first load the image from the path stored as "resource" on the entry. We get the width and height to check for index out of range errors. Some icon definitions likely contains typos like "sample the 67th icon in this row of 7 icons". When these malformatted icons are used in forms users will be met with a box of magenta.

An image is created for the icon, matching the icon size and resource image format. Then an image write instance is made from this, as images are read only we can only write to it if we have an image write interface. We can then finally start copying all pixels from the resource to the icon that we return.

def pmet_ThumbnailImage(self):
    image_service = lx.service.Image()

    if not os.path.isfile(self.entry.resource):
        lx.notimpl()

    # load the image resource the icon is using,
    resource = image_service.Load(self.entry.resource)
    w, h = resource.Size()
    fmt = resource.Format()

    image = image_service.Create(
        self.entry.width,
        self.entry.height,
        resource.Format(),
        0
    )

    image_write = lx.object.ImageWrite(image)

    # TODO: add any other formats that icons are using. These were all the needed ones for default icons.
    if fmt in (lx.symbol.iIMP_RGBA32, lx.symbol.iIMP_IRGBA32):
        pixel = lx.object.storage('b', 4)
    elif fmt == lx.symbol.iIMP_RGB24:
        pixel = lx.object.storage('b', 3)
    else:
        lx.out(f'pixel format not implemented in icon browser for resource {self.entry.resource}:{fmt}')
        lx.notimpl()

    # Index out of bounds check, print information about which icon is causing the issue,
    if (self.entry.x + self.entry.width) > w:
        lx.out(f"X of range in {self.entry.name} image {self.entry.resource}")
        lx.notimpl()
    if (self.entry.y + self.entry.height) > h:
        lx.out(f"Y of range in {self.entry.name} image {self.entry.resource}")
        lx.notimpl()

    for y in range(self.entry.y, self.entry.y + self.entry.height):
        for x in range(self.entry.x, self.entry.x + self.entry.width):
            resource.GetPixel(x, y, fmt, pixel)
            image_write.SetPixel(x - self.entry.x, y - self.entry.y, fmt, pixel)

    return image

Beyond this we want the metric to also return the ideal size for the displayed icons. Which will be same as the size of our icons.

def pmet_ThumbnailIdealSize(self):
    """ Return the ideal size of the thumbnail """
    return self.entry.width, self.entry.height

You can find the full code for the kit here.

I can see more uses for the preset browser in the future. Like integrating it with Perforce to show lxo files available on the server. We already have something like this in PySide but I could see benefits to having a more "native" feeling integration.