pythonmodel-view-controlleripywidgetsenaml

How do I separate model and view in ipywidgets?


Consider the example of a simple foreign exchange calculator app.

I can define my model using traitlets:

from traitlets import HasTraits, Float, observe, Enum
import math

class FXModel(HasTraits):
    domestic_qty = Float()
    foreign_qty = Float()
    fx_rate  = Float(float('nan')) # in units of domestic_qty/foreign_qty
    lock = Enum(['domestic', 'foreign'], default_value='domestic')
    _calculating = Enum([None, 'domestic', 'foreign'], default_value=None)

    def calc_foreign(self):
        if not math.isnan(self.fx_rate):
            self._calculating = 'foreign'
            self.foreign_qty = self.domestic_qty / self.fx_rate
            self._calculating = None

    def calc_domestic(self):
        if not math.isnan(self.fx_rate):
            self._calculating = 'domestic'
            self.domestic_qty = self.foreign_qty * self.fx_rate
            self._calculating = None

    @observe('domestic_qty')
    def on_domestic(self, change):
        if self._calculating is None:
            self.calc_foreign()

    @observe('foreign_qty')
    def on_foreign(self, change):
        if self._calculating is None:
            self.calc_domestic()

    @observe('fx_rate')
    def on_fxrate(self, change):
        if self.lock == 'domestic':
            self.calc_foreign()
        else:
            self.calc_domestic()

And a corresponding simple "print" based view:

class FXView:
    def __init__(self, model):
        self.model = model

    def show(self):
        print("""
        domestic_qty: {:.4g}
        foreign_qty:  {:.4g}
        fx_rate:      {:.4g}
        lock:         {}""".format(
            self.model.domestic_qty,
            self.model.foreign_qty,
            self.model.fx_rate,
            self.model.lock
        ))

Here's how it works:

>> fx_model = FXModel(domestic_qty = 100., fx_rate = 200.)
>> fx_view = FXView(fx_model)
>> fx_view.show()
    domestic_qty: 100
    foreign_qty:  0.5
    fx_rate:      200
    lock:         domestic
>> fx_model.fx_rate = 195.
>> fx_view.show()
    domestic_qty: 100
    foreign_qty:  0.5128
    fx_rate:      195
    lock:         domestic

I have also created a view using ipywidgets:

import ipywidgets as widgets
domestic_label = widgets.Label("Domestic quantity")
domestic_field = widgets.FloatText()

foreign_label = widgets.Label("Foreign quantity")
foreign_field = widgets.FloatText()

fx_label = widgets.Label("Exchange rate (domestic/foreign)")
fx_field = widgets.FloatText()

lock_label = widgets.Label("If rates change, keep ")
lock_field = widgets.Dropdown(options=["domestic", "foreign"])
lock_label_post = widgets.Label('fixed')

ipyview = widgets.HBox([widgets.VBox([domestic_label, foreign_label, fx_label, lock_label]),
              widgets.VBox([domestic_field, foreign_field, fx_field, widgets.HBox([lock_field, lock_label_post])])])

It looks really good:

enter image description here

My question is; how can I "bind" my model and my ipyview together? I have some experience with enaml where this is possible via the operator := and friends.

What's the best way of doing this with ipywidgets?


Solution

  • Great question! I posted an answer in an ipywidgets issue: https://github.com/jupyter-widgets/ipywidgets/issues/2296

    So here's how to make a custom view using ipywidget views, using the widgets from the post. The key is the link calls to bind attributes to widget values.

    import ipywidgets as widgets
    from traitlets import link
    from IPython.display import display
    
    class FXWidgetView:
        def __init__(self, model):
            self.model = model
            self.domestic_label = widgets.Label("Domestic quantity")
            self.domestic_field = widgets.FloatText()
    
            self.foreign_label = widgets.Label("Foreign quantity")
            self.foreign_field = widgets.FloatText()
    
            self.fx_label = widgets.Label("Exchange rate (domestic/foreign)")
            self.fx_field = widgets.FloatText()
    
            self.lock_label = widgets.Label("If rates change, keep ")
            self.lock_field = widgets.Dropdown(options=["domestic", "foreign"])
            self.lock_label_post = widgets.Label('fixed')
    
            self.ipyview = widgets.HBox([widgets.VBox([self.domestic_label, self.foreign_label, self.fx_label, self.lock_label]),
                          widgets.VBox([self.domestic_field, self.foreign_field, self.fx_field, widgets.HBox([self.lock_field, self.lock_label_post])])])
    
            link((model, 'domestic_qty'), (self.domestic_field, 'value'))
            link((model, 'foreign_qty'), (self.foreign_field, 'value'))
            link((model, 'fx_rate'), (self.fx_field, 'value'))
            link((model, 'lock'), (self.lock_field, 'value'))
    
        def _ipython_display_(self):
            display(self.ipyview)
    
    

    I'll point out that Param has been advocating this sort of separation for a while, and @jbednar has pointed out that ipywidgets could implement convenience functions to support this pattern as well. I think that's a good idea - to have some simple convenience functions that are a step up from the interact functions that take a HasTraits class, introspect it, and provide default widgets for different traits for common cases.