Automatically refresh custom components when properties change

What
Redraw custom components on the designer when property values change.

Why
Open this custom component: Anvil | Login

Then go to the Test form, select the circles_on_line_1 component and change some of its color properties:

  1. When you change line_color, the component is immediately updated
  2. When you change any other color property, the component is not updated. It is only updated when some event triggers the update, like updating the whole form, or moving the mouse over the component.

The reason why (1) works, is because the line_color property has a setter that explicitly calls self.refresh().

Yes, I could create the descriptor for every property just because I want the custom component to be updated when the properties update, but (1) it feels like useless boiler plate code, which goes against Anvil’s karma, and (2) it makes setting the default value for each property in the custom component configuration window useless and confusing, because now the default value is set inside the component constructor.

Perhaps a better approach would be to create a property_value_changed or need_refresh event, allowing me to call self.refresh() once for all properties from there. And perhaps, debouncing this event could help prevent bursts of component refreshing.

1 Like

Thanks for the feature request

Redrawing isn’t so easy
When you do certain actions in the designer, redrawing involves removing all components and then recreating them.

We don’t want to do this on every property change.
And so we call setattr(component, prop, value)

If you’re using the default custom component properties then changing a property value has no effect on the appearance of the component.

I like the idea of an event.

So if you could do something like this, would it be helpful?


class CirclesOnLine(CirclesOnLineTemplate):
    def __init__(self, **properties):
        ...
        self.init_components(**properties)
        self.add_event_handler("x-custom-component-property-changed", self.refresh)

    def refresh(self, **event_args):
        pass

Here’s a mixin class that can do this for you:


from anvil.designer import in_designer
import anvil


class DesignerComponentProperty:
    def __init__(self, component_property, name):
        self.cp = component_property
        self.name = name

    def __get__(self, obj, ob_type):
        return self.cp.__get__(obj, ob_type)

    def __set__(self, obj, value):
        self.cp.__set__(obj, value)
        obj.raise_event("x-custom-component-property-changed", prop=self.name, value=value)


class DesignerMixin:
    def __init_subclass__(cls, *args, **kws):
        super().__init_subclass__(*args, **kws)

        if not in_designer:
            return

        for prop_name in dir(cls):
            old_prop = getattr(cls, prop_name)
            if isinstance(old_prop, anvil.CustomComponentProperty):
                setattr(cls, prop_name, DesignerComponentProperty(old_prop, prop_name))


Here’s a clone link to show you it working:

1 Like

A mixin and a property wrapper class are a nice approach, thank you!

I have modified your implementation:

  • Properties with descriptor were not managed by your version, so I have added property here:
    if isinstance(prop_value, (anvil.CustomComponentProperty, property)):
  • A burst of property changes would trigger a burst of redraws, so I have added debouncing
  • Debouncing at runtime doesn’t hurt, so I have removed the in_designer test. If you really need to use in_designer for other reasons, you can still do it inside the redraw method
  • We may want to skip redraw for certain properties, so I have added the skip_properties property to the mixin
  • I don’t like to setup the event listener, so I have replaced the event with a simpler function call

All of the above is overkill for the test custom component I linked to, but it can be useful for more complex custom component with long rendering time.

The code is in the previous clone link, and it’s here:

# AutoRedrawCustomComponent.py
import anvil

class _PropertyWrapper:
    """
    Descriptor that wraps a CustomComponentProperty, and defers redrawing via a debounced Timer
    """
    def __init__(self, component_property, prop_name):
        self.property = component_property
        self.prop_name = prop_name

    def __get__(self, obj, obj_type):
        return self.property.__get__(obj, obj_type)

    def __set__(self, obj, value):
        old_value = self.property.__get__(obj, type(obj))
        self.property.__set__(obj, value)

        if value != old_value and self.prop_name not in obj.__class__.skip_properties:
            obj.schedule_redraw()


class AutoRedrawCustomComponent:
    """
    1. Subclass this mixin alongside the _Template class
    2. At the class level of the custom component list which properties should cause a redraw:
         AutoRedrawCustomComponent.skip_properties = {'prop1', 'prop2'}
         AutoRedrawCustomComponent.delay = 0.5
    3. Implement a `redraw()` method on the custom component (called after the 0.1s debounce)

    The custom component can call self.redraw() for an immediate redraw, for example inside
    mouse event handlers, or can call self.schedule_redraw() for a debounced redraw.
    """
    skip_properties = set()
    delay = 0.1

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)

        for prop_name in dir(cls):
            prop_value = getattr(cls, prop_name)
            if isinstance(prop_value, (anvil.CustomComponentProperty, property)):
                setattr(cls, prop_name, _PropertyWrapper(prop_value, prop_name))

    def schedule_redraw(self):
        if not hasattr(self, "_debounce_timer"):
            t = anvil.Timer()
            t.interval = 0
            t.set_event_handler("tick", self._on_debounce_timer_tick)
            self._debounce_timer = t
            self.add_component(t)

        self._debounce_timer.interval = max(self.delay, 0.001)

    def _on_debounce_timer_tick(self, **event_args):
        self._debounce_timer.interval = 0
        if callable(getattr(self, "redraw", None)):
            self.redraw()
# in the custom component
from ..AutoRedrawCustomComponent import AutoRedrawCustomComponent
[...]

class MyCustomComponent(AutoRedrawCustomComponent, MyCustomComponentTemplate):
    skip_properties = {'tooltip', 'auto_save'}
    delay = 0.3

    def __init__(self, **properties):
        [...]

    def redraw(self):
        [...]
2 Likes