MoPyX is a MobX/Vue inspired reactive model driven UI library. UI Toolkit independent.
Reactive UI is a concept of having the UI automatically update as a reaction to changes being done in the backend model. This happens without manually registering listeners, and the reactive framework keeping track of what parts of the model affect what parts of the application..
PySide2 MoPyX Demo
Full demo project source is here: https://github.com/germaniumhq/mopyx-sample
You decorate your model classes with @model. All the properties of that class will be monitored for changes. Whenever one of those properties will change, the affected renderers (only the renderer functions that used that property) will be re-invoked on model changes.
@model class FormModel: def __init__(self): self.first_name = "John" self.last_name "Doe"
You decorate your UI rendering functions with @render, or invoke them with render_call. MoPyX will map what render method used what properties in the model. The parameters for the function will be also recorded and sent to the renderer function.
class UiForm: def __init__(self): # ... self.render_things() @render def render_things(self): self.first_name_label.set_text(self.model.first_name) self.last_name_label.set_text(self.model.last_name)
Whenever either first_name or last_name change in our model, render_things will be invoked again.
In order to optimize the number of UI updates, only the relevant @render functions will be called, not always the topmost one.
So you could break down the previous @render method into two methods:
@render def render_things(self): self.render_first_name() self.render_last_name() @render def render_first_name(self): self.first_name_label.set_text(self.model.first_name) @render def render_last_name(self): self.last_name_label.set_text(self.model.last_name)
Now if only the first_name changes in the model, the set_text for the last_name will not be invoked. This happens automatically, and only the needed renderers will be invoked.
To type less, render_call() will just wrap the given callable into a @render. For example we can rewrite our renderer to be shorter:
@render def render_things(self): render_call(lambda: self.first_name_label.set_text(self.model.first_name)) render_call(lambda: self.last_name_label.set_text(self.model.last_name))
@render methods are not allowed to do model changes while running. If setting an UI value will trigger a model change, read the ignore_updates section.
If they’re not wrapped in an action, every property is also an action, so after the property change, a rendering will trigger. To improve performance you can wrap multiple model updates into a single @action. An action method can call other methods, including other @actions.
When when the top most @action finishes the rendering will be invoked. MoPyX will find out what renderers need to be called, and what computed properties should be updated, in order to get the UI into a consistent state.
Internally all the properties setters in the @model classes are wrapped in @actions.
@action # withonut this would trigger a render after each assignment def change_model(self): self.first_name = "Jane" self.last_name = "Mary"
You can also create properties on the model using the @computed decorator. This works similarly with a regular python @property but it will be invoked only when one of the other properties it depends on (including from other MoPyX models) change. Otherwise calling this property will return the previously computed value.
This is great for difficult to compute properties. Have a list that must be accessed as sorted, but comes from the data store as unsorted? You can wrap it in a @computed method. Again, note that the @computed method will only be invoked when the used properties by that @computed method will change:
@model class RootModel: def __init__(self): self.backend_data =  @action def fetch_data(self): self.backend_data = fetch_data_from_service() @computed def first_five_items(self): # will only be invoked when self.backend_data changes result = list(self.backend_data) result.sort() result = result[0:5] return result class UiRenderer: # ... @render def render_items(self): # will be invoked only when first_five_items changes for item in self.root_model.first_five_items: self.render_item(item)
@computed properties are not allowed to change the state of the object.
If one of the properties is a list, the list will be replaced with a special implementation, that will also notify its changes on the top property.
@model class RootModel: def __init__(self): self.items =  class UiComponent: @render def update_ui(self): for item in self.items: self.render_sub_component(item) model = RootModel() ui = UiComponent(model) model.items.append("new item") # this will trigger the update_ui rerender.
If the renderer will call a value that sets something in the UI that will make the UI trigger an event, that will in turn might land in an action (model updates are also actions), you can disable the rendering using the ignore_updates attribute. This will suppress all action invocations from that rendering method, including all model updates.
This is great for onchange events for input edits, or tree updates such as selected nodes that otherwise would enter an infinite recursion.
To check what goes on, you can export in your environment:
- MOPYX_DEBUG - this will print the rendering process on the console.
- MOPYX_THREAD_CHECK - this will throw an exception if the thread for @render methods change.