Repository: invent-framework/invent (language: python)

Issue:
Refactor: who has responsibility for layout (container or widget?).
The code that handles the layout of components on a page is currently in two places: the parent container and the child component. It should only be in one place (current preference is for the container). Let's refactor that, shall we..?


Discussion / hints:

Fix patch:
diff --git a/Makefile b/Makefile
index a98eaf2..0854c24 100644
--- a/Makefile
+++ b/Makefile
@@ -36,7 +36,7 @@ lint-all:
 	flake8 src/invent tests/*
 
 serve: clean tidy zip
-	python utils/serve.py 8000
+	python utils/serve.py
 
 test:
 	python -m webbrowser http://localhost:8000/index.html
diff --git a/examples/calculator/main.py b/examples/calculator/main.py
index 20a1911..e1a549c 100644
--- a/examples/calculator/main.py
+++ b/examples/calculator/main.py
@@ -105,7 +105,7 @@ def calculate(data) -> None:
                     columns=4,
                     content=[
                         invent.ui.TextInput(
-                            column_span=4,
+                            layout=dict(column_span=4),
                             value=invent.ui.from_datastore("numbers"),
                         ),
                         invent.ui.Button(
@@ -163,7 +163,7 @@ def calculate(data) -> None:
                             label="+", purpose="SUCCESS", channel="calculator"
                         ),
                         invent.ui.Button(
-                            column_span=2,
+                            layout=dict(column_span=2),
                             label="0",
                             purpose="DEFAULT",
                             channel="calculator",
diff --git a/examples/catfacts/main.py b/examples/catfacts/main.py
index ae77ce3..c812cfb 100644
--- a/examples/catfacts/main.py
+++ b/examples/catfacts/main.py
@@ -61,7 +61,7 @@ def ready(value):
                         Image(
                             image=invent.media.images.puff.svg,
                             visible=from_datastore("working"),
-                            align_self="center",
+                            layout=dict(align_self="center"),
                         ),
                         Button(
                             name="cat_fact_button",
@@ -77,7 +77,7 @@ def ready(value):
                             text=from_datastore(
                                 "cat_fact", with_function=handle_cat_fact
                             ),
-                            align_self="center",
+                            layout=dict(align_self="center"),
                         ),
                     ]
                 ),
diff --git a/examples/farmyard/main.py b/examples/farmyard/main.py
index 964088f..20cd393 100644
--- a/examples/farmyard/main.py
+++ b/examples/farmyard/main.py
@@ -69,10 +69,10 @@ def make_pigs(number_of_oinks):
                         Image(
                             image=invent.media.images.goose.png,
                             channel="honk",
-                            align_self="center",
+                            layout=dict(align_self="center"),
                         ),
                         Row(
-                            align_self="center",
+                            layout=dict(align_self="center"),
                             content=[
                                 Button(
                                     name="button honk",
@@ -91,13 +91,13 @@ def make_pigs(number_of_oinks):
                                 TextBox(
                                     name="number_of_honks",
                                     text=from_datastore("number_of_honks"),
-                                    align_self="center",
+                                    layout=dict(align_self="center"),
                                 ),
                                 Slider(
                                     value=from_datastore("number_of_honks"),
                                     name="Honk Slider",
                                     step=1,
-                                    flex=1,
+                                    layout=dict(flex=1),
                                 ),
                             ]
                         ),
@@ -125,10 +125,10 @@ def make_pigs(number_of_oinks):
                         Image(
                             image=invent.media.images.pig.png,
                             channel="oink",
-                            align_self="center",
+                            layout=dict(align_self="center"),
                         ),
                         Row(
-                            align_self="center",
+                            layout=dict(align_self="center"),
                             content=[
                                 Button(
                                     name="button oink",
@@ -143,7 +143,7 @@ def make_pigs(number_of_oinks):
                                 TextBox(
                                     name="number_of_oinks",
                                     text=from_datastore("number_of_oinks"),
-                                    align_self="center",
+                                    layout=dict(align_self="center"),
                                 ),
                             ],
                         ),
@@ -180,13 +180,13 @@ def make_pigs(number_of_oinks):
                         name="to_lucy",
                         label="Visit Lucy",
                         channel="navigate",
-                        flex=1,
+                        layout=dict(flex=1),
                     ),
                     Button(
                         name="to_percy",
                         label="Visit Percy",
                         channel="navigate",
-                        flex=1,
+                        layout=dict(flex=1),
                     ),
                 ]
             ),
diff --git a/src/invent/compatability.py b/src/invent/compatability.py
index 89d110a..d783afe 100644
--- a/src/invent/compatability.py
+++ b/src/invent/compatability.py
@@ -30,6 +30,11 @@ def iscoroutinefunction(obj):
     return inspect.iscoroutinefunction(obj)
 
 
+def capitalize(s):
+    """Cross-interpreter implementation of str.capitalize."""
+    return s[0].upper() + s[1:].lower()
+
+
 async def sleep_ms(ms):
     """Asynchronous sleep for 'ms' milliseconds."""
 
diff --git a/src/invent/ui/containers/box.py b/src/invent/ui/containers/box.py
index d460c50..7d58231 100644
--- a/src/invent/ui/containers/box.py
+++ b/src/invent/ui/containers/box.py
@@ -1,9 +1,27 @@
-from ..core import Container, ChoiceProperty
+from ...compatability import capitalize
+from ..core import Container, ChoiceProperty, TextProperty
 from ..core.component import _TSHIRT_SIZES, ALIGNMENTS
 
-justify_content_kwargs = dict(
-    choices=ALIGNMENTS, default_value="start", map_to_style="justify-content"
-)
+
+def justify_content_property(direction):
+    return ChoiceProperty(
+        f"{capitalize(direction)} alignment of children.",
+        choices=ALIGNMENTS,
+        default_value="start",
+        map_to_style="justify-content"
+    )
+
+
+def flex_property(direction):
+    # TODO: validate input
+    return TextProperty(
+        f"How much {direction} space to consume. " +
+        "May be blank to take no extra space, "
+        "'auto' to take an equal portion of any free space, "
+        "or an integer to take the given proportion of the total space.",
+        default_value="",
+        map_to_style="flex",
+    )
 
 
 class Box(Container):
diff --git a/src/invent/ui/containers/column.py b/src/invent/ui/containers/column.py
index 26a25ec..c50d520 100644
--- a/src/invent/ui/containers/column.py
+++ b/src/invent/ui/containers/column.py
@@ -1,5 +1,10 @@
-from ..core import ChoiceProperty
-from .box import Box, justify_content_kwargs
+from ..core.component import align_self_property, Layout
+from .box import Box, flex_property, justify_content_property
+
+
+class ColumnLayout(Layout):
+    align_self = align_self_property("horizontal")
+    flex = flex_property("vertical")
 
 
 class Column(Box):
@@ -11,9 +16,7 @@ class Column(Box):
     def icon(cls):
         return '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><path fill="currentColor" d="M104 32H64a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H64V48h40Zm88-176h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40Z"/></svg>'  # noqa
 
+    layout_class = ColumnLayout
     flex_direction = "column"
 
-    justify_content = ChoiceProperty(
-        "Vertical alignment of children",
-        **justify_content_kwargs,
-    )
+    justify_content = justify_content_property("vertical")
diff --git a/src/invent/ui/containers/grid.py b/src/invent/ui/containers/grid.py
index 915705f..80c969a 100644
--- a/src/invent/ui/containers/grid.py
+++ b/src/invent/ui/containers/grid.py
@@ -1,5 +1,29 @@
 from ..core import Container, ChoiceProperty, IntegerProperty
-from ..core.component import _TSHIRT_SIZES
+from ..core.component import (
+    _TSHIRT_SIZES,
+    ALIGNMENTS_STRETCH,
+    Layout,
+    align_self_property,
+)
+
+
+class GridLayout(Layout):
+    align_self = align_self_property("vertical")
+    justify_self = ChoiceProperty(
+        "Horizontal alignment.",
+        choices=ALIGNMENTS_STRETCH,
+        default_value="stretch",
+        map_to_style="justify-self",
+    )
+
+    column_span = IntegerProperty("Number of columns to fill.", 1)
+    row_span = IntegerProperty("Number of rows to fill.", 1)
+
+    def on_column_span_changed(self):
+        self.element.style.gridColumn = f"span {self.column_span}"
+
+    def on_row_span_changed(self):
+        self.element.style.gridRow = f"span {self.row_span}"
 
 
 class Grid(Container):
@@ -7,6 +31,8 @@ class Grid(Container):
     A grid.
     """
 
+    layout_class = GridLayout
+
     column_gap = ChoiceProperty(
         "The gap between columns in the grid.",
         choices=_TSHIRT_SIZES,
@@ -40,12 +66,3 @@ def render(self):
         element = super().render()
         element.style.display = "grid"
         return element
-
-    def update_child(self, child, index):
-        grid_row_span = child.row_span
-        if grid_row_span:
-            child.element.style.gridRow = "span " + str(grid_row_span)
-
-        grid_column_span = child.column_span
-        if grid_column_span:
-            child.element.style.gridColumn = "span " + str(grid_column_span)
diff --git a/src/invent/ui/containers/row.py b/src/invent/ui/containers/row.py
index c9858a7..21f3c2f 100644
--- a/src/invent/ui/containers/row.py
+++ b/src/invent/ui/containers/row.py
@@ -1,5 +1,10 @@
-from ..core import ChoiceProperty
-from .box import Box, justify_content_kwargs
+from ..core.component import Layout, align_self_property
+from .box import Box, flex_property, justify_content_property
+
+
+class RowLayout(Layout):
+    align_self = align_self_property("vertical")
+    flex = flex_property("horizontal")
 
 
 class Row(Box):
@@ -11,9 +16,7 @@ class Row(Box):
     def icon(cls):
         return '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><path fill="currentColor" d="M208 136H48a16 16 0 0 0-16 16v40a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16v-40a16 16 0 0 0-16-16m0 56H48v-40h160zm0-144H48a16 16 0 0 0-16 16v40a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16m0 56H48V64h160z"/></svg>'  # noqa
 
+    layout_class = RowLayout
     flex_direction = "row"
 
-    justify_content = ChoiceProperty(
-        "Horizontal alignment of children",
-        **justify_content_kwargs,
-    )
+    justify_content = justify_content_property("horizontal")
diff --git a/src/invent/ui/core/__init__.py b/src/invent/ui/core/__init__.py
index 976cbf2..f3a736c 100644
--- a/src/invent/ui/core/__init__.py
+++ b/src/invent/ui/core/__init__.py
@@ -2,6 +2,7 @@
     _DEFAULT_ICON,
     Component,
     Container,
+    Layout,
     MessageBlueprint,
     Widget,
 )
@@ -29,6 +30,7 @@
     "FloatProperty",
     "IntegerProperty",
     "ListProperty",
+    "Layout",
     "NumericProperty",
     "Property",
     "TextProperty",
diff --git a/src/invent/ui/core/component.py b/src/invent/ui/core/component.py
index f2ef040..cd33f28 100644
--- a/src/invent/ui/core/component.py
+++ b/src/invent/ui/core/component.py
@@ -21,14 +21,13 @@
 from pyscript import document
 
 import invent
-from invent.compatability import getmembers_static
+from invent.compatability import capitalize, getmembers_static
 from invent.i18n import _
+from .model import Model
 from .property import (
     BooleanProperty,
     ChoiceProperty,
-    IntegerProperty,
     ListProperty,
-    Property,
     TextProperty,
 )
 
@@ -36,6 +35,16 @@
 ALIGNMENTS = ["start", "center", "end"]
 ALIGNMENTS_STRETCH = ALIGNMENTS + ["stretch"]
 
+
+def align_self_property(direction):
+    return ChoiceProperty(
+        f"{capitalize(direction)} alignment.",
+        choices=ALIGNMENTS_STRETCH,
+        default_value="stretch",
+        map_to_style="align-self",
+    )
+
+
 #: T-shirt sizes used to indicate relative sizes of things.
 _TSHIRT_SIZES = (
     None,
@@ -104,7 +113,7 @@ def as_dict(self):
         }
 
 
-class Component:
+class Component(Model):
     """
     A base class for all user interface components (Widget, Container).
 
@@ -129,46 +138,6 @@ class Component:
         "The component is visible is set to True.", default_value=True
     )
 
-    # TODO: once this is extracted into a layout class, make the description
-    # say whether the alignment is:
-    #   * horizontal (Column) or vertical (Row and Grid)
-    #   * inside its parent (Row and Column) or inside its grid cell (Grid)
-    align_self = ChoiceProperty(
-        "The component's alignment.",
-        choices=ALIGNMENTS_STRETCH,
-        default_value="stretch",
-        map_to_style="align-self",
-    )
-
-    # This property is only valid in a Grid, so its description can be more
-    # specific.
-    justify_self = ChoiceProperty(
-        "The component's horizontal alignment inside its grid cell.",
-        choices=ALIGNMENTS_STRETCH,
-        default_value="stretch",
-        map_to_style="justify-self",
-    )
-
-    # TODO: validate input
-    # TODO: once this is extracted into a layout class, make the description
-    # say whether the space is horizontal (Row) or vertical (Column).
-    flex = TextProperty(
-        "How much space the component will consume. May be blank to take no "
-        "extra space, 'auto' to take an equal portion of any free space, or "
-        "an integer to take the given proportion of the total space.",
-        map_to_style="flex",
-    )
-
-    column_span = TextProperty(
-        "The component's requested column-span in a grid",
-        default_value="1",
-    )
-
-    row_span = TextProperty(
-        "The component's requested row-span in a grid.",
-        default_value="1",
-    )
-
     def __init__(self, **kwargs):
         if invent.is_micropython:  # pragma: no cover
             # When in MicroPython, ensure all the properties have a reference
@@ -176,15 +145,16 @@ def __init__(self, **kwargs):
             for property_name, property_obj in type(self).properties().items():
                 property_obj.__set_name__(self, property_name)
         self.element = self.render()
-        self.update(**kwargs)
+        self._parent = None
+        self._layout = {}
+        super().__init__(**kwargs)
+
         type(self)._component_counter += 1
         if not self.id:
             self.id = type(self)._generate_unique_id()
         if not self.name:
             self.name = type(self)._generate_name()
         Component._components_by_id[self.id] = self
-        # To reference the container's parent in the DOM tree.
-        self.parent = None
 
     @property
     def is_container(self):
@@ -195,21 +165,62 @@ def is_container(self):
         """
         return isinstance(self, Container)
 
-    def update(self, **kwargs):
-        """
-        Given the **kwargs dict, iterate of the items and if the key identifies
-        a property on this class, set the property's value to the associated
-        value in the dict.
-        """
-        # Set values from the **kwargs
-        for k, v in kwargs.items():
-            if hasattr(self, k):
-                setattr(self, k, v)
-        # Set values from the property's own default_values.
-        for property_name, property_obj in type(self).properties().items():
-            if property_name not in kwargs:
-                if property_obj.default_value is not None:
-                    setattr(self, property_name, property_obj.default_value)
+    @property
+    def parent(self):
+        return self._parent
+
+    @parent.setter
+    def parent(self, parent):
+        old_parent = self._parent
+        self._parent = parent
+
+        if parent:
+            # Validate the layout against the new parent.
+            try:
+                self.layout = self.layout
+            except Exception:
+                self._parent = old_parent
+                raise
+
+    @property
+    def layout(self):
+        return self._layout
+
+    @layout.setter
+    def layout(self, layout):
+        def type_error():
+            raise TypeError(
+                f"container type {type(self.parent).__name__} " +
+                f"doesn't support layout type {type(layout).__name__}"
+            )
+
+        if isinstance(layout, Layout):
+            if self.parent:
+                if type(layout) is self.parent.layout_class:
+                    # Create a new Layout object for this component, and copy
+                    # all properties.
+                    self._layout = self.parent.layout_class(
+                        self,
+                        **{
+                            key: getattr(layout, key)
+                            for key, prop in layout.properties().items()
+                        }
+                    )
+                else:
+                    type_error()
+            else:
+                # Validate it later, when we have a parent.
+                self._layout = layout
+
+        elif isinstance(layout, dict):
+            if self.parent:
+                self._layout = self.parent.layout_class(self, **layout)
+            else:
+                # Validate it later, when we have a parent.
+                self._layout = layout
+
+        else:
+            type_error()
 
     def on_id_changed(self):
         """
@@ -231,22 +242,6 @@ def on_visible_changed(self):
         """
         self.element.style.visibility = "visible" if self.visible else "hidden"
 
-    def on_column_span_changed(self):
-        """
-        Automatically called to update the column span information relating to
-        the HTML element associated with the component.
-        """
-        if self.element.parentElement:
-            self.parent.update_children()
-
-    def on_row_span_changed(self):
-        """
-        Automatically called to update the row span information relating to
-        the HTML element associated with the component.
-        """
-        if self.element.parentElement:
-            self.parent.update_children()
-
     @classmethod
     def _generate_unique_id(cls):
         """
@@ -294,22 +289,6 @@ def icon(cls):
         """
         return _DEFAULT_ICON
 
-    @classmethod
-    def properties(cls):
-        """
-        Returns a dictionary of properties associated with the component. The
-        key is the property name, the value an instance of the relevant
-        property class.
-
-        Implementation detail: we branch on interpreter type because of the
-        different behaviour of `getmembers`.
-        """
-        return {
-            name: value
-            for name, value in getmembers_static(cls)
-            if isinstance(value, Property)
-        }
-
     @classmethod
     def message_blueprints(cls):
         """
@@ -346,33 +325,20 @@ def blueprint(cls):
 
     def as_dict(self):
         """
-        Return a dict representation of the state of this instance's
-        properties and message blueprints.
+        Return a dict representation of the state of this instance.
         """
-        properties = {}
-        for property_name, property_obj in type(self).properties().items():
-            # If the component is a Container, we deal with its content
-            # separately (for the recursive case). A Widget may well define
-            # its own custom "content" property though, so we handle that
-            # just like any other property.
-            if isinstance(self, Container) and property_name == "content":
-                continue
-
-            from_datastore = self.get_from_datastore(property_name)
-            if from_datastore:
-                property_value = repr(from_datastore)
-
-            else:
-                property_value = getattr(self, property_name)
-
-            properties[property_name] = property_value
+        properties = super().as_dict()
+        layout_dict = (
+            self.layout
+            if isinstance(self.layout, dict)
+            else self.layout.as_dict()
+        )
+        if layout_dict:
+            properties["layout"] = layout_dict
 
+        # If the component is a Container, we format its content recursively.
         if isinstance(self, Container):
-            from_datastore = self.get_from_datastore("content")
-            if from_datastore:
-                properties["content"] = repr(from_datastore)
-
-            else:
+            if not self.get_from_datastore("content"):
                 properties["content"] = [
                     item.as_dict() for item in self.content
                 ]
@@ -380,20 +346,8 @@ def as_dict(self):
         return {
             "type": type(self).__name__,
             "properties": properties,
-            "message_blueprints": {
-                key: value.as_dict()
-                for key, value in type(self).message_blueprints().items()
-            },
         }
 
-    def get_from_datastore(self, property_name):
-        """
-        Return the "from_datastore" instance for a property or None if it is a
-        simple/literal property.
-        """
-
-        return getattr(self, f"_{property_name}_from_datastore", None)
-
     def update_attribute(self, attribute_name, attribute_value):
         """
         Convenience method to update an HTML attribute on self.element. If
@@ -503,18 +457,6 @@ class Container(Component):
         default_value=None,
     )
 
-    column_width = IntegerProperty(
-        "The default width of the container.",
-        default_value=100,
-        maximum=100,
-        minimum=0,
-    )
-    height = IntegerProperty(
-        "The default height of the container.",
-        default_value=100,
-        maximum=100,
-        minimum=0,
-    )
     background_color = TextProperty("The color of the container's background.")
     border_color = TextProperty("The color of the container's border.")
     border_width = ChoiceProperty(
@@ -547,12 +489,6 @@ def on_content_changed(self):
             self.element.appendChild(child.element)
         self.update_children()
 
-    def on_height_changed(self):
-        self.element.style.height = f"{self.height}%"
-
-    def on_width_changed(self):
-        self.element.style.width = f"{self.width}%"
-
     def on_background_color_changed(self):
         if self.background_color:
             self.element.style.setProperty(
@@ -705,3 +641,10 @@ def update_children(self):
 
     def update_child(self, child, index):
         pass
+
+
+class Layout(Model):
+    def __init__(self, component, **kwargs):
+        self.component = component
+        self.element = self.component.element
+        super().__init__(**kwargs)
diff --git a/src/invent/ui/core/model.py b/src/invent/ui/core/model.py
new file mode 100644
index 0000000..e6dcc7e
--- /dev/null
+++ b/src/invent/ui/core/model.py
@@ -0,0 +1,68 @@
+from ...compatability import getmembers_static
+from .property import Property
+
+
+class Model:
+    """
+    Base for a class which contains Properties.
+    """
+
+    def __init__(self, **kwargs):
+        # MicroPython incorrectly calls __set_name__ with the Model instance,
+        # not the class. And it doesn't call it early enough on Layout classes
+        # for some reason, which would cause the default values loop below to
+        # fail.
+        for key, prop in self.properties().items():
+            prop.__set_name__(type(self), key)
+
+        self.update(**kwargs)
+
+        # Set default values.
+        for property_name, property_obj in type(self).properties().items():
+            if property_name not in kwargs:
+                if property_obj.default_value is not None:
+                    setattr(self, property_name, property_obj.default_value)
+
+    @classmethod
+    def properties(cls):
+        return {
+            name: value
+            for name, value in getmembers_static(cls)
+            if isinstance(value, Property)
+        }
+
+    def update(self, **kwargs):
+        for k, v in kwargs.items():
+            if hasattr(self, k):
+                setattr(self, k, v)
+            else:
+                raise AttributeError(k)
+
+    def as_dict(self):
+        properties = {}
+        for property_name, property_obj in sorted(self.properties().items()):
+            from_datastore = self.get_from_datastore(property_name)
+            if from_datastore:
+                property_value = repr(from_datastore)
+            else:
+                property_value = getattr(self, property_name)
+
+            properties[property_name] = property_value
+
+        return properties
+
+    def get_from_datastore(self, property_name):
+        """
+        Return the "from_datastore" instance for a property, or None if it is
+        an unbound property.
+        """
+        return self.properties()[property_name].get_from_datastore(self)
+
+    def set_from_datastore(self, property_name, *args, **kwargs):
+        """
+        Set the "from_datastore" instance for a property. Pass None to make it
+        an unbound property.
+        """
+        self.properties()[property_name].set_from_datastore(
+            self, *args, **kwargs
+        )
diff --git a/src/invent/ui/core/property.py b/src/invent/ui/core/property.py
index 118453e..b02fe5b 100644
--- a/src/invent/ui/core/property.py
+++ b/src/invent/ui/core/property.py
@@ -31,7 +31,7 @@ def __repr__(self):
         Create the expression for a property that gets its value from the
         datastore.
         """
-        expression = f'from_datastore("{self.key}"'
+        expression = f"from_datastore({self.key!r}"
         if self.with_function:
             expression += f", with_function={self.with_function.__name__}"
         expression += ")"
@@ -82,7 +82,9 @@ def __set_name__(self, owner, name):
         Helps with the descriptor protocol, as it provides a name against
         which to store the descriptor's value.
         """
-        self.private_name = "_" + name
+        self.name = name
+        self.private_name = f"_{name}"
+        self.from_datastore_name = f"_{name}_from_datastore"
 
     def __get__(self, obj, objtype=None):
         """
@@ -105,8 +107,8 @@ def __set__(self, obj, value):
         # to set the value *in* the datastore. This will then trigger the
         # property's reactor. This is used when any "input" properties are
         # changed on a widget (e.g. a value setting on a slider).
-        binding = getattr(obj, self.private_name + "_from_datastore", None)
-        if binding:
+        binding = self.get_from_datastore(obj)
+        if binding and not isinstance(value, from_datastore):
             validated_value = self.validate(value)
             invent.datastore[binding.key] = validated_value
             return
@@ -129,11 +131,8 @@ def reactor(message):  # pragma: no cover
                 self._react_on_change(obj, self.private_name)
 
             # Attach the "from_datastore" instance to the object.
-            setattr(obj, self.private_name + "_from_datastore", value)
-            # Subscribe to store events for the specified key.
-            invent.subscribe(
-                reactor, to_channel="store-data", when_subject=value.key
-            )
+            self.set_from_datastore(obj, value, reactor)
+
             # Update value to the actual value from the datastore.
             value = invent.datastore.get(value.key, self.default_value)
 
@@ -146,6 +145,23 @@ def reactor(message):  # pragma: no cover
         setattr(obj, self.private_name, self.validate(value))
         self._react_on_change(obj, self.private_name)
 
+    def get_from_datastore(self, obj):
+        return getattr(obj, self.from_datastore_name, None)
+
+    def set_from_datastore(self, obj, value, reactor=None):
+        reactor_prop = f"_{self.name}_reactor"
+        old_value = self.get_from_datastore(obj)
+        if old_value:
+            invent.unsubscribe(
+                getattr(obj, reactor_prop), "store-data", old_value.key
+            )
+            delattr(obj, reactor_prop)
+
+        setattr(obj, self.from_datastore_name, value)
+        if value:
+            invent.subscribe(reactor, "store-data", value.key)
+            setattr(obj, reactor_prop, reactor)
+
     def _react_on_change(self, obj, property_name):
         """
         Ensure any reactive behaviour relating to the setting of the property
@@ -179,7 +195,7 @@ def coerce(self, value):
         Return value as an acceptable type (or raise an exception while doing
         so).
         """
-        return value  # pragma: no cover
+        return value
 
     def validate(self, value):
         """
@@ -421,7 +437,10 @@ def validate(self, value):
         """
         if value in self.choices or value is None:
             return super().validate(value)
-        raise ValidationError(_("The value must be one of the valid choices."))
+        raise ValidationError(
+            f"The value {value!r} is not one of the valid choices " +
+            f"{self.choices}"
+        )
 
     def as_dict(self):
         """
diff --git a/src/invent/ui/export.py b/src/invent/ui/export.py
index 405fe28..646068e 100644
--- a/src/invent/ui/export.py
+++ b/src/invent/ui/export.py
@@ -219,6 +219,9 @@ def _pretty_repr_component(component, lines, indent=""):
     # The component's properties.
     _pretty_repr_component_properties(component, lines, indent + "    ")
 
+    # The component's layout.
+    _pretty_repr_component_layout(component.layout, lines, indent + "    ")
+
     # If the component is a Container, its "content" property.
     if isinstance(component, Container):
         _pretty_repr_container_content_property(
@@ -228,13 +231,11 @@ def _pretty_repr_component(component, lines, indent=""):
     # The last line of the component's constructor e.g.")" :).
     lines.append(f"{indent}),")
 
-    return lines
-
 
 def _pretty_repr_component_properties(component, lines, indent):
     """Generate a pretty repr of a Component's properties."""
 
-    for property_name, property_obj in type(component).properties().items():
+    for property_name, property_obj in sorted(component.properties().items()):
         # If the component is a Container, we deal with its content separately
         # (for the recursive case). A Widget may well define its own custom
         # "content" property though, so we handle that just like any other
@@ -252,6 +253,15 @@ def _pretty_repr_component_properties(component, lines, indent):
         lines.append(f"{indent}{property_name}={repr(property_value)},")
 
 
+def _pretty_repr_component_layout(layout, lines, indent):
+    layout_dict = layout if isinstance(layout, dict) else layout.as_dict()
+    if layout_dict:
+        dict_args = [
+            f"{key}={value!r}" for key, value in sorted(layout_dict.items())
+        ]
+        lines.append(f"{indent}layout=dict({', '.join(dict_args)}),")
+
+
 def _pretty_repr_container_content_property(component, lines, indent):
     """
     Generate a pretty repr of a Container's "content" property.
diff --git a/src/tools/builder/public/python/builder.py b/src/tools/builder/public/python/builder.py
index 6c73fad..00a1f5b 100644
--- a/src/tools/builder/public/python/builder.py
+++ b/src/tools/builder/public/python/builder.py
@@ -6,6 +6,7 @@
 from pyscript import document
 from pyscript.ffi import create_proxy
 
+from invent import datastore
 from invent.ui import (
     App, AVAILABLE_COMPONENTS, Column, Container, create_component, export, Grid, Page,
     Row, Widget, from_datastore
@@ -223,38 +224,64 @@ def get_component_properties(self, component_id):
 
         # Plain dicts aren't ordered yet in Micropython
         # (https://github.com/micropython/micropython/issues/6170).
-        properties = OrderedDict(
-            sorted(type(component).blueprint()["properties"].items())
-        )
+        properties = OrderedDict()
+        for name, value in sorted(component.properties().items()):
+            properties[name] = value.as_dict()
+            properties[name]["is_layout"] = False
+
+        # The root component (Page) will have layout={}.
+        layout = component.layout
+        if layout:
+            for name, value in sorted(layout.properties().items()):
+                properties[name] = value.as_dict()
+                properties[name]["is_layout"] = True
+
         for name, value in properties.items():
-            if hasattr(component, f"_{name}_from_datastore"):
+            if value["is_layout"]:
+                target = component.layout
+            else:
+                target = component
+
+            binding = target.get_from_datastore(name)
+            if binding:
                 value["is_from_datastore"] = True
-                datastore_value = getattr(component, f"_{name}_from_datastore")
-                value["value"] = datastore_value.key
+                value["value"] = binding.key
             else:
-                value["value"] = getattr(component, name)
+                value["value"] = getattr(target, name)
             
         if component.is_container:
             properties.pop("content")
 
         return json.dumps(properties)
     
-    def set_component_property(self, component_id, property_name, value, is_from_datastore=False):
+    def set_component_property(self, component_id, property_name, value, is_layout, is_from_datastore=False):
         """
         Set a property on a component (that has already been added to the page).
         """
         component = self._app.get_component_by_id(component_id)
+
+        if is_layout:
+            target = component.layout
+        else:
+            target = component
+
         if is_from_datastore:
-            setattr(component, property_name, from_datastore(value))
+            setattr(target, property_name, from_datastore(value))
         else:
-            setattr(component, property_name, value)
+            target.set_from_datastore(property_name, None)
+            setattr(target, property_name, value)
 
     def show_page(self, page_id):
         result = self._app.get_page_by_id(page_id)
         if result:
             result.show()
             return result.element
-        
+
+    # Datastore ###################################################################
+
+    def update_datastore(self, key, value):
+        datastore[key] = value
+
     # Channels ####################################################################
         
     def get_channels(self):
@@ -401,7 +428,9 @@ def _on_click_component(self, event, component):
 
         event.preventDefault()
         event.stopPropagation()
+        self._open_properties(component)
 
+    def _open_properties(self, component):
         self._js_builder_model.openPropertiesForComponent(
             json.dumps(type(component).blueprint()), component.id
         )
@@ -517,12 +546,14 @@ def _on_drop_component(self, event, component):
             # Remove the component being moved from its old location.
             component_to_drop = Component.get_component_by_id(move_data)
             self._remove_js_event_handlers_from_component(component_to_drop)
+            old_container = component_to_drop.parent
             component_to_drop.parent.remove(component_to_drop)
 
         # Or...
         #
         # b) Adding a new component to the page.
         else:
+            old_container = None
             component_blueprint_json = event.dataTransfer.getData("widget")
             if component_blueprint_json:
                 component_blueprint = json.loads(component_blueprint_json)
@@ -540,6 +571,10 @@ def _on_drop_component(self, event, component):
 
         container = component if component.is_container else component.parent
 
+        # When moving to a different container, clear the layout properties.
+        if old_container is not container:
+            component_to_drop.layout = {}
+
         # If the container is empty then a simple append will do...
         if len(container.content) == 0:
             self.append_component(container, component_to_drop)
@@ -552,6 +587,10 @@ def _on_drop_component(self, event, component):
             else:
                 insert_after = component.content[-1] if component.is_container else component
                 self.insert_component_after(insert_after, component_to_drop)
+        
+        # Moving to a different container will clear the layout properties, so
+        # refresh the properties panel.
+        self._open_properties(component_to_drop)
 
     def _remove_js_event_handlers_from_app(self, app):
         """
diff --git a/src/tools/builder/src/data/models/widget-property-model.ts b/src/tools/builder/src/data/models/widget-property-model.ts
index cfff4f6..862c175 100644
--- a/src/tools/builder/src/data/models/widget-property-model.ts
+++ b/src/tools/builder/src/data/models/widget-property-model.ts
@@ -8,6 +8,7 @@ export interface WidgetPropertyModel {
     max_length?: number;
     choices: Array<string>;
     is_from_datastore: boolean;
+    is_layout: boolean;
     id: string;
     name: string;
 }
\ No newline at end of file
diff --git a/src/tools/builder/src/utilities/builder-utilities.ts b/src/tools/builder/src/utilities/builder-utilities.ts
index 5e30d28..cfaafb0 100644
--- a/src/tools/builder/src/utilities/builder-utilities.ts
+++ b/src/tools/builder/src/utilities/builder-utilities.ts
@@ -49,8 +49,14 @@ export class BuilderUtilities {
 		return JSON.parse(this.builder().get_component_properties(componentId));
 	}
 
-	public static setComponentProperty(componentId: string, key: string, value: string, isFromDatastore?: boolean) {
-		this.builder().set_component_property(componentId, key, value, isFromDatastore);
+	public static setComponentProperty(componentId: string, key: string, value: string, is_layout: boolean, isFromDatastore?: boolean) {
+		this.builder().set_component_property(componentId, key, value, is_layout, isFromDatastore);
+	}
+
+	// Datastore ///////////////////////////////////////////////////////////////////////
+
+	public static updateDatastore(key: string, value: string) {
+		this.builder().update_datastore(key, value);
 	}
 
 	// Channels ////////////////////////////////////////////////////////////////////////
diff --git a/src/tools/builder/src/views/builder/builder-model.ts b/src/tools/builder/src/views/builder/builder-model.ts
index bc44c69..5c1b5ed 100644
--- a/src/tools/builder/src/views/builder/builder-model.ts
+++ b/src/tools/builder/src/views/builder/builder-model.ts
@@ -75,6 +75,7 @@ export class BuilderModel extends ViewModelBase {
 				"blocks": {}
 			}
 		}
+		
 		BuilderUtilities.getAppFromDict(data.app);
 
 		// TODO: Not sure we need to do this on the next tick - please check after
@@ -107,13 +108,13 @@ export class BuilderModel extends ViewModelBase {
 		const datastore: string = this.getDatastoreValues();
 		const generatedCode: string = pythonGenerator.workspaceToCode(Blockly.getMainWorkspace());
 		const code: string = `${this.state.functions}\n${generatedCode}`;
-		const psdc: any = BuilderUtilities.exportAsPyScriptApp(datastore, code);
+		const sourceCode: any = BuilderUtilities.exportAsPyScriptApp(datastore, code);
 
 		return {
 			app: JSON.stringify(BuilderUtilities.getAppAsDict(), null, 2),
 			blocks: JSON.stringify(Blockly.serialization.workspaces.save(Blockly.getMainWorkspace())),
 			datastore: JSON.stringify(this.state.datastore),
-			psdc
+			sourceCode
 		};
 	}
 
@@ -141,10 +142,10 @@ export class BuilderModel extends ViewModelBase {
 			 * host to decide where it is actually saved.
 			 */
 			case "save-request": {
-				event.source?.postMessage({
+				window.parent.postMessage({
 					type: "save-response",
 					data: this.save(),
-				});
+				}, "*");
 				break;
 			}
 
@@ -236,9 +237,9 @@ export class BuilderModel extends ViewModelBase {
 		this.state.activeWidgetProperties = BuilderUtilities.getComponentProperties(componentId);
 	}
 
-	public setComponentProperty(key: string, value: string, isFromDatastore?: boolean) {
+	public setComponentProperty(key: string, value: string, isLayout: boolean, isFromDatastore?: boolean,) {
 		BuilderUtilities.setComponentProperty(
-			this.state.activeWidgetId, key, value, isFromDatastore
+			this.state.activeWidgetId, key, value, isLayout, isFromDatastore
 		);
 	}
 
@@ -315,6 +316,9 @@ export class BuilderModel extends ViewModelBase {
 			options: {
 				onAddValue: (isValid: boolean, datastoreValue: DatastoreValueModel): void => {
 					if (isValid){
+						BuilderUtilities.updateDatastore(
+							datastoreValue.key, datastoreValue.default_value
+						);
 						this.state.datastore[datastoreValue.key] = datastoreValue;
 						ModalUtilities.closeModal();
 					}
@@ -332,7 +336,7 @@ export class BuilderModel extends ViewModelBase {
 		 * add a media file. The host will send us an "add-media-response" message
 		 * if/when a media file has been added.
 		 */
-		window.parent.postMessage({type: "add-media-request"});
+		window.parent.postMessage({type: "add-media-request"}, "*");
 	}
 
 	public getImageFiles(): Array<IbSelectOption> {
diff --git a/src/tools/builder/src/views/builder/builder.vue b/src/tools/builder/src/views/builder/builder.vue
index ea99ef6..0e2b5e0 100644
--- a/src/tools/builder/src/views/builder/builder.vue
+++ b/src/tools/builder/src/views/builder/builder.vue
@@ -103,7 +103,7 @@
                                     :label="key" 
                                     :options="view.getDatastoreOptions()" 
                                     v-model="property.value"
-                                    @input="view.setComponentProperty(key as string, $event, true)"
+                                    @input="view.setComponentProperty(key as string, $event, property.is_layout, true)"
                                 />
                                 <div class="w-full" v-else>
                                     <ib-select 
@@ -111,7 +111,7 @@
                                         :label="key" 
                                         :options="view.getImageFiles()" 
                                         v-model="property.value"
-                                        @input="view.setComponentProperty(key as string, $event)"
+                                        @input="view.setComponentProperty(key as string, $event, property.is_layout)"
                                     />
 
                                     <ib-select 
@@ -119,7 +119,7 @@
                                         :label="key" 
                                         :options="view.getSoundFiles()" 
                                         v-model="property.value"
-                                        @input="view.setComponentProperty(key as string, $event)"
+                                        @input="view.setComponentProperty(key as string, $event, property.is_layout)"
                                     />
 
                                     <ib-select 
@@ -127,14 +127,14 @@
                                         :label="key" 
                                         :options="view.getChoicePropertyOptions(property.choices)" 
                                         v-model="property.value"
-                                        @input="view.setComponentProperty(key as string, $event)"
+                                        @input="view.setComponentProperty(key as string, $event, property.is_layout)"
                                     />
 
                                     <ib-toggle 
                                         v-else-if="property.property_type === 'BooleanProperty'" 
                                         :label="key" 
                                         v-model="property.value"
-                                        @input="view.setComponentProperty(key as string, $event)"
+                                        @input="view.setComponentProperty(key as string, $event, property.is_layout)"
                                     />
 
                                     <ib-input 
@@ -143,7 +143,7 @@
                                         type="text"
                                         :required="property.required" 
                                         v-model="property.value"
-                                        @input="view.setComponentProperty(key as string, $event)"
+                                        @input="view.setComponentProperty(key as string, $event, property.is_layout)"
                                     />
                                 </div>
 
diff --git a/src/tools/wrapper/index.html b/src/tools/wrapper/index.html
index e7139bc..d43f262 100644
--- a/src/tools/wrapper/index.html
+++ b/src/tools/wrapper/index.html
@@ -3,13 +3,13 @@
     <title>Invent tooling wrapper</title>
   </head>
   <body>
-  <button id="load">Load</button>
-  <button id="save">Save</button>
-  <button id="run">Run</button>
-  <br/>
-  <iframe id="builder"
-      src="http://localhost:5173/invent"
-      style="width:100%; height:100%">
-  </iframe>
+    <button id="load">Load</button>
+    <button id="save">Save</button>
+    <br/>
+    <iframe id="builder"
+    src="http://localhost:5173/invent"
+    style="width:100%; height:100%">
+    </iframe>
+  <script src="wrapper.js"></script>
   </body>
 </html>
diff --git a/src/tools/wrapper/wrapper.js b/src/tools/wrapper/wrapper.js
new file mode 100644
index 0000000..84f2973
--- /dev/null
+++ b/src/tools/wrapper/wrapper.js
@@ -0,0 +1,36 @@
+const builder = document.getElementById("builder");
+const save = document.getElementById("save");
+const load = document.getElementById("load");
+
+window.addEventListener("message", (event) => {
+    if (event.source === builder.contentWindow){
+        const data = event.data;
+
+        switch (data.type) {
+            case "save-response":
+                console.log(data.data);
+                localStorage.setItem("data", JSON.stringify(data.data));
+                break;
+        }
+    }
+});
+
+save.addEventListener("click", () => {
+    builder.contentWindow.postMessage({
+        type: "save-request"
+    }, "*");
+});
+
+load.addEventListener("click", () => {
+    const data = JSON.parse(localStorage.getItem("data"));
+
+    builder.contentWindow.postMessage({
+        type: "load-request",
+        data: {
+            app: data.app && JSON.parse(data.app),
+            datastore: data.datastore && JSON.parse(data.datastore),
+            blocks: data.blocks && JSON.parse(data.blocks),
+            media: {}
+        }
+    }, "*");
+});
\ No newline at end of file
diff --git a/static/main.py b/static/main.py
index 5ca6c7c..966becb 100644
--- a/static/main.py
+++ b/static/main.py
@@ -1,5 +1,5 @@
 import pytest
 
 
-pytest.main(["-v", "--cov=invent", "--cov-report", "term-missing", "tests/"])
+pytest.main(["-vv", "--cov=invent", "--cov-report", "term-missing", "tests/"])
 
diff --git a/utils/serve.py b/utils/serve.py
index 351b862..2b4d705 100644
--- a/utils/serve.py
+++ b/utils/serve.py
@@ -23,7 +23,7 @@ def end_headers(self):
 
 if __name__ == "__main__":
     parser = argparse.ArgumentParser()
-    parser.add_argument("port", type=int, default=8000)
+    parser.add_argument("port", type=int, default=8000, nargs="?")
     args = parser.parse_args()
     server.test(
         HandlerClass=MyHTTPRequestHandler, bind="localhost", port=args.port


Test patch:
diff --git a/tests/ui/core/test_component.py b/tests/ui/core/test_component.py
index 44451cc..592610b 100644
--- a/tests/ui/core/test_component.py
+++ b/tests/ui/core/test_component.py
@@ -1,7 +1,7 @@
 import pytest
 from pyscript import document
 from unittest import mock
-from invent.ui import core
+from invent.ui import core, export
 
 
 def test_message_blueprint():
@@ -128,6 +128,10 @@ def render(self):
     with pytest.raises(core.ValidationError):
         TestComponent(text=None)
 
+    # Cannot initialize a nonexistent property.
+    with pytest.raises(AttributeError, match="no_such_prop"):
+        TestComponent(no_such_prop=None)
+
 
 def test_component_get_component_by_id():
     """
@@ -250,7 +254,7 @@ def test_component_as_dict():
     dictionary.
     """
 
-    class MyWidget(core.Widget):
+    class MyWidget(core.Component):
         """
         A test widget.
         """
@@ -273,12 +277,137 @@ class MyWidget(core.Widget):
         def render(self):
             return document.createElement("div")
 
-    w = MyWidget()
-    result = w.as_dict()
-    assert result["type"] == "MyWidget"
-    assert result["properties"]["foo"] == "bar"
-    assert result["properties"]["numberwang"] == 42
-    assert result["properties"]["favourite_colour"] == "black"
+    mw = MyWidget(
+        foo=core.from_datastore("ds_key"),
+        numberwang=55,
+        layout=dict(alpha="a", bravo="b"),
+    )
+    expected = {
+        "type": "MyWidget",
+        "properties": {
+            "foo": "from_datastore('ds_key')",
+            "numberwang": 55,
+            "favourite_colour": "black",
+            "id": "invent-mywidget-1",
+            "name": "MyWidget 1",
+            "enabled": True,
+            "visible": True,
+            "layout": dict(alpha="a", bravo="b"),
+        }
+    }
+    assert mw.as_dict() == expected
+
+    with mock.patch("invent.ui.MyWidget", MyWidget, create=True):
+        mw2 = export._component_from_dict(expected)
+        assert isinstance(mw2, MyWidget)
+        assert mw2.foo == "bar"
+        assert isinstance(
+            foo_fd := mw2.get_from_datastore("foo"), core.from_datastore
+        )
+        assert foo_fd.key == "ds_key"
+        assert mw2.numberwang == 55
+        assert mw2.layout == dict(alpha="a", bravo="b")
+
+    export._pretty_repr_component(mw, lines := [])
+    assert lines == [
+        "MyWidget(",
+        "    enabled=True,",
+        "    favourite_colour='black',",
+        "    foo=from_datastore('ds_key'),",
+        "    id='invent-mywidget-1',",
+        "    name='MyWidget 1',",
+        "    numberwang=55,",
+        "    visible=True,",
+        "    layout=dict(alpha='a', bravo='b'),",
+        "),"
+    ]
+
+
+def test_container_as_dict():
+    """
+    Ensure the expected state of a container is returned as a Python
+    dictionary.
+    """
+
+    class MyLayout(core.Layout):
+        layout_prop = core.TextProperty("Layout prop", "lp")
+
+    class MyContainer(core.Container):
+        layout_class = MyLayout
+        container_prop = core.TextProperty("Container prop", "cp")
+
+    class MyWidget(core.Component):
+        widget_prop = core.TextProperty("Widget prop", "wp")
+
+        def render(self):
+            return document.createElement("div")
+
+    mc = MyContainer()
+    mc.append(MyWidget())
+    expected = {
+        "type": "MyContainer",
+        "properties": {
+            "container_prop": "cp",
+            "id": "invent-mycontainer-1",
+            "name": "MyContainer 1",
+            "enabled": True,
+            "visible": True,
+            "background_color": None,
+            "border_color": None,
+            "border_width": None,
+            "border_style": None,
+            "content": [
+                {
+                    "type": "MyWidget",
+                    "properties": {
+                        "widget_prop": "wp",
+                        "id": "invent-mywidget-1",
+                        "name": "MyWidget 1",
+                        "enabled": True,
+                        "visible": True,
+                        "layout": dict(layout_prop="lp"),
+                    }
+                }
+            ]
+        }
+    }
+    assert mc.as_dict() == expected
+
+    with (
+        mock.patch("invent.ui.MyContainer", MyContainer, create=True),
+        mock.patch("invent.ui.MyWidget", MyWidget, create=True),
+    ):
+        mc2 = export._component_from_dict(expected)
+        assert isinstance(mc2, MyContainer)
+        assert len(mc2.content) == 1
+        mw2 = mc2.content[0]
+        assert isinstance(mw2, MyWidget)
+        assert isinstance(mw2.layout, MyLayout)
+
+    export._pretty_repr_component(mc, lines := [])
+    assert lines == [
+        "MyContainer(",
+        "    background_color=None,",
+        "    border_color=None,",
+        "    border_style=None,",
+        "    border_width=None,",
+        "    container_prop='cp',",
+        "    enabled=True,",
+        "    id='invent-mycontainer-1',",
+        "    name='MyContainer 1',",
+        "    visible=True,",
+        "    content=[",
+        "        MyWidget(",
+        "            enabled=True,",
+        "            id='invent-mywidget-1',",
+        "            name='MyWidget 1',",
+        "            visible=True,",
+        "            widget_prop='wp',",
+        "            layout=dict(layout_prop='lp'),",
+        "        ),",
+        "    ],",
+        "),",
+    ]
 
 
 def test_component_update_attribute():
@@ -445,6 +574,104 @@ def my_second_handler(message):
         assert mock_sub.call_count == 1
 
 
+def test_layout():
+    """
+    A component's layout property interacts correctly with its parent.
+    """
+
+    class LayoutA(core.Layout):
+        alpha = core.TextProperty("Alpha", "a")
+
+    class LayoutB(core.Layout):
+        bravo = core.TextProperty("Bravo", "b")
+
+    class ContainerA(core.Container):
+        layout_class = LayoutA
+
+    class ContainerB(core.Container):
+        layout_class = LayoutB
+
+    class TestComponent(core.Component):
+        def render(self):
+            return document.createElement("div")
+
+    tc = TestComponent(layout=dict(alpha="apple"))
+    assert tc.layout == dict(alpha="apple")
+
+    # A Layout object is created from the dict when the widget gets a parent.
+    a = ContainerA()
+    a.append(tc)
+    layout_a = tc.layout
+    assert type(tc.layout) is LayoutA
+    assert tc.layout.element is tc.element
+    assert tc.layout.alpha == "apple"
+    assert tc.layout.component is tc
+    assert tc.layout.element is tc.element
+
+    # Layout properties can be set like any other property.
+    tc.layout.alpha = "antelope"
+    assert tc.layout.alpha == "antelope"
+
+    # Layout can be set from a dict with compatible keys.
+    tc.layout = dict(alpha="avocado")
+    assert tc.layout is not layout_a
+    layout_a1 = tc.layout
+    assert type(tc.layout) is LayoutA
+    assert tc.layout.alpha == "avocado"
+
+    with pytest.raises(AttributeError, match="no_such_prop"):
+        tc.layout = dict(alpha="aardvark", no_such_prop=None)
+    assert tc.layout is layout_a1
+    assert tc.layout.alpha == "avocado"
+
+    # Layout can be set from a layout object of the correct type.
+    layout_a2 = LayoutA(tc, alpha="apple")
+    tc.layout = layout_a2
+    assert tc.layout is not layout_a2  # A copy has been made.
+    assert type(tc.layout) is LayoutA
+    assert tc.layout.alpha == "apple"
+
+    # Layout cannot be set from any other type.
+    for value in [LayoutB(tc), "a string", ["a list"]]:
+        with pytest.raises(
+            TypeError,
+            match=(
+                "container type ContainerA doesn't support layout type "
+                + type(value).__name__
+            ),
+        ):
+            tc.layout = value
+
+    # The layout object is kept when the widget loses its parent, and can be
+    # copied to another parent of the same type.
+    layout_a3 = tc.layout
+    a.remove(tc)
+    assert tc.layout is layout_a3
+    assert layout_a3.alpha == "apple"
+
+    a2 = ContainerA()
+    a2.append(tc)
+    assert tc.layout is not layout_a3  # A copy has been made.
+    assert layout_a3.alpha == "apple"
+
+    # A different parent type requires a different layout type.
+    a2.remove(tc)
+    b = ContainerB()
+    with pytest.raises(
+        TypeError,
+        match="container type ContainerB doesn't support layout type LayoutA",
+    ):
+        b.append(tc)
+
+    layout_b = LayoutB(tc, bravo="banana")
+    tc.layout = layout_b
+    assert tc.layout is layout_b
+    b.append(tc)
+    assert tc.layout is not layout_b  # A copy has been made.
+    assert type(tc.layout) is LayoutB
+    assert tc.layout.bravo == "banana"
+
+
 def test_container_on_content_changed():
     """
     The children are re-rendered when the content list is changed.
diff --git a/tests/ui/core/test_property.py b/tests/ui/core/test_property.py
index 7897e4f..68ab282 100644
--- a/tests/ui/core/test_property.py
+++ b/tests/ui/core/test_property.py
@@ -26,7 +26,7 @@ def test_fn(val):
     fds = from_datastore("foo", with_function=test_fn)
     assert fds.key == "foo"
     assert fds.with_function == test_fn
-    assert repr(fds) == 'from_datastore("foo", with_function=test_fn)'
+    assert repr(fds) == "from_datastore('foo', with_function=test_fn)"
 
 
 def test_property_init():
@@ -78,13 +78,37 @@ class FakeWidget:
 
     test_fn = mock.MagicMock()
     fw = FakeWidget()
-    with mock.patch("invent.subscribe") as mock_sub:
+    with (
+        mock.patch("invent.subscribe") as mock_sub,
+        mock.patch("invent.unsubscribe") as mock_unsub,
+    ):
         fw.my_property = from_datastore("test", with_function=test_fn)
-        assert mock_sub.call_args[1]["to_channel"] == "store-data"
-        assert mock_sub.call_args[1]["when_subject"] == "test"
-        assert mock_sub.call_count == 1
+        mock_unsub.assert_not_called()
+        mock_sub.assert_called_once_with(mock.ANY, "store-data", "test")
+        reactor = mock_sub.call_args.args[0]
         test_fn.assert_called_once_with(None)
 
+        mock_sub.reset_mock()
+        fw.my_property = from_datastore("test2")
+        mock_unsub.assert_called_once_with(reactor, "store-data", "test")
+        mock_sub.assert_called_once_with(mock.ANY, "store-data", "test2")
+
+
+def test_from_datastore_react_on_change():
+    """
+    If the property is set a value from_datastore, subsequently setting it to
+    another value should update the datastore.
+    """
+
+    class FakeWidget:
+        my_property = Property("A test property")
+
+    fw = FakeWidget()
+    with mock.patch("invent.datastore", {}) as datastore:
+        fw.my_property = from_datastore("test")
+        fw.my_property = "value1"
+        assert datastore == {"test": "value1"}
+
 
 def test_property_react_on_change():
     """