Repository: beeware/toga

Issue:
New Feature: Get a reference to a widget by id
It is perfectly valid Python to define either a full or partial GUI widget hierarchy in the following way.

        self.content = Box(
            style=Pack(flex=1, direction=COLUMN),
            children=[
                TextInput(
                    id='node_name_input',
                    placeholder='Enter topic name'
                ),
                Box(
                    style=Pack(flex=1),
                    children=[
                        Button(
                            'Ok',
                            on_press=self.on_accept
                        ),
                        Button(
                            'Cancel',
                            on_press=self.on_close
                        )
                    ]
                )
            ]
        )

In this example I subsequently want to get the value of the TextInput box when the 'Ok' button is clicked but because of the way it has been declared I have no immediate reference to it. It would be useful to have a method in the base Window class that mimics the HTML DOM getElementById() that returns a reference to the widget given its 'id'.


Discussion:
Definite +1 to the idea of being able to retrieve widgets by ID.

My question is about how that API is exposed. One of the underlying principles of Toga's design is to present a Pythonic API - not just copy the APIs exposed by underlying systems. Directly emulating the DOM API has some merit in that it's a "known quantity"; but it's not especially Pythonic - the existence of getter and setter methods in a Python API is usually a bit of a code smell. 

A couple of possible alternatives:
* Use the Pythonic idiom for lookup-by-attribute: `__getitem__`. This could be on the window itself (`window['my_node_id']`), or made explicit that it's a widget lookup using a descriptor (`window.widgets['my_node_id']`)
* Put the 'DOM-analog' API behind a DOM namespace, so it's obvious that you're moving out of a "Pythonic" context: `window.dom.get_widget_by_id('my_node_id')`

By way of defensive API design, I'd also like to take a moment to consider what *other* DOM APIs we might want to import here. It's easy to imagine that lookup by "class" and/or selector could be useful (if only for applying a CSS stylesheet) - are there any other DOM-like APIs that we might want to add to Toga? The first API implementation doesn't necessarily need to *implement* any or all of these other APIs - but we should make sure we don't box ourselves into a corner by adding *one* method in a way that precludes adding others.


Thanks for the feedback and certainly some things to think about there. I am not particularly attached to the current implementation, it was put together to get me out of a hole using the simplest available solution. That said the typical documentation for the 'id' field is currently along the lines of 'The DOM identifier for the window. This id can be used to target CSS directives' (taken from the window.py) implying that its intended usage is analogous to the HTML id attribute. Consequently I felt in general that your intention was to mimic the HTML DOM/CSS approach. 

'Use the Pythonic idiom for lookup-by-attribute: __getitem__. This could be on the window itself (window['my_node_id']), or made explicit that it's a widget lookup using a descriptor (window.widgets['my_node_id'])'

I did briefly think about a dictionary/attribute approach but discounted it as I felt it is, to some extent, duplicating information that is already available via the node tree. As such it would require extra maintenance and consideration in light of any future structural changes/updates. TBH having said that it would still be my preferred solution if you want a more pythonic approach.

'Put the 'DOM-analog' API behind a DOM namespace, so it's obvious that you're moving out of a "Pythonic" context: window.dom.get_widget_by_id('my_node_id')'.

I am not so keen on this approach personally as it introduces another level of complexity alongside native, _impl, interface etc. Also widgets are not added via 'window.dom.content' or 'widget.dom.add_children' so I would find having to use a 'dom' namespace to recover references to them somewhat idiosyncratic.

As for the wider consideration of other DOM API calls, I am happy to have a look through and see what may be useful. 



I think that another approach we should consider is using a **DOM singleton** as our widget source.
Here some code example of what I mean:
```python
from toga import DOM
from travertino.color import BLUE

# Get element by ID
my_box = DOM.get_element_by_id(id="my_box")
my_box.style.backgorund_color = BLUE

# Get elements by name
elements_list = DOM.get_elements_by_name(name="push_buttons")
for element in elements_list:
   element.label = "Push me"
```

I know it very HTML-driven, but I think it makes some sense even for Windows, Mac, and Linux.
What do you think?
I would find it inconsistent and incongruous to have get the instance via a DOM namespace when I didn't have to use one to add the widgets in the first place.  What would represent the Document? The app? The main window? A non-main window? I can see the appeal of having something HTML-like but would it be right?
I don't think that it should be added to the `MainWindow` class because not all widgets are showed in the main window. As I see it, there are 2 options:
1. Add it to the `Window` class where the ids are **window specific**, ie. ids can be duplicated between windows
2. Add it to the `App` class where the ids are **not window specific**, ie. ids cannot be duplicated between windows.

Between the two, I prefer the second one.
Thanks for the input. 
I totally agree that it shouldn't be added to the main window class but I prefer the first option of the two by quite a long way :-)

My use case was to recover the value of a text input field from a form on a non-modal pop-up window in lieu of modal windows being implemented. The widget only exists for the short lifetime of the window and the widget tree is unique to that window. The window specific id is perfect for this because I don't have to worry about name duplication across all possible displayable windows in an app. In fact it allows me to be consistent with my naming convention if I so desire. Also, once again, since I have to add my main widget (usually a container) to a window and other widgets are added to the tree under that main widget, I wouldn't expect to have to go to the app instance in order to obtain the instance ref I am after.
> the typical documentation for the 'id' field is currently along the lines of 'The DOM identifier for the window. This id can be used to target CSS directives' (taken from the window.py) implying that its intended usage is analogous to the HTML id attribute. Consequently I felt in general that your intention was to mimic the HTML DOM/CSS approach.

You're completely correct in saying that the functional role of the ID is identical to that of the HTML DOM/CSS ID. However, that doesn't mean the *API* we expose must to be named the same as the DOM API.

> 'Use the Pythonic idiom for lookup-by-attribute: **getitem**. This could be on the window itself (window['my_node_id']), or made explicit that it's a widget lookup using a descriptor (window.widgets['my_node_id'])'
> 
> I did briefly think about a dictionary/attribute approach but discounted it as I felt it is, to some extent, duplicating information that is already available via the node tree. As such it would require extra maintenance and consideration in light of any future structural changes/updates. TBH having said that it would still be my preferred solution if you want a more pythonic approach.

I think we may have some crossed wires here. I'm not proposing a change in *functionality* - I'm talking purely about the name of the function that is used to access this capability. You're suggesting `get_widget_by_id()`; I'm suggesting `__getitem__()` (which means that in practice, you use `[]` to invoke the function). The implementation of the method would be otherwise identical. 

I think there is some value in there actually being a lookup map behind the scenes, but that's an implementation issue (that I brought up on the related), not an API design issue. The real test of a good API is whether you can replace the implementation without changing the API at all - and in this case, "get the widget that has ID X" betrays nothing about the implementation, no matter how you spell it.

> 'Put the 'DOM-analog' API behind a DOM namespace, so it's obvious that you're moving out of a "Pythonic" context: window.dom.get_widget_by_id('my_node_id')'.
> 
> I am not so keen on this approach personally as it introduces another level of complexity alongside native, _impl, interface etc. Also widgets are not added via 'window.dom.content' or 'widget.dom.add_children' so I would find having to use a 'dom' namespace to recover references to them somewhat idiosyncratic.

I *completely* agree that this would be idiosyncratic :-) My question is whether it would be *too* idiosyncratic to make sense. The purpose behind the `.dom` "spacer" is purely to provide a mental "past this point the functions won't have Pythonic names" barrier. 

That said: I'd rather avoid non-pythonic names altogether; and looking at this with fresh eyes, it also occurs to me that `get_widget_by_id()` isn't the DOM name anyway. The DOM name would be `get_element_by_id()`, which doesn't make *any* sense in the Toga context. Leaning on the DOM analogy too hard (either by having a DOM namespace, or using a DOM-like function name) probably isn't a good idea.

> As for the wider consideration of other DOM API calls, I am happy to have a look through and see what may be useful.

I think it's worth a cursory survey - it provides useful guidance over whether we're talking about one or two methods, which could be added to the App/Window namespace without much problem; or if we're talking about dozens, in which case a widget-search/manipulation namespace may be called for - with the specific name being a separate matter.
> As I see it, there are 2 options:
> 
>     1. Add it to the `Window` class where the ids are **window specific**, ie. ids can be duplicated between windows
> 
>     2. Add it to the `App` class where the ids are **not window specific**, ie. ids cannot be duplicated between windows.
> 
> Between the two, I prefer the second one.

The scope of IDs is (or should be) global; so I can see the instinct to make this an App API. There's also the fact that *Windows* have IDs.

However, I can also see @Bobw147's point; from the perspective of the app builder, the app has to exist, but it's the Window that owns widgets.

Maybe we actually need "get by ID" APIs on both App *and* Window? Other than the fact that we'd be duplicating an API, is there any reason having a consistent API in both locations would be bad? Presumably, the Window version would be scoped so that it would only return widgets that belonged to the window, whereas the App lookup would be global.

> Maybe we actually need "get by ID" APIs on both App _and_ Window? Other than the fact that we'd be duplicating an API, is there any reason having a consistent API in both locations would be bad? Presumably, the Window version would be scoped so that it would only return widgets that belonged to the window, whereas the App lookup would be global.

I don't think it's a good idea. It adds more points of failure and will be hard to debug in the future if bugs will occur. It is also very un-SOLID (violates single responsibility).

I still stick to to my opinion that the API should be only for the `App` class if we choose IDs to be located globally without duplications. If you want to enable duplications, stick to `Window` API. Don't do both.

Maybe it is worth raising this up in the Gitter chat and see what other people thinks about it.
'I still stick to to my opinion that the API should be only for the App class if we choose IDs to be located globally without duplications. If you want to enable duplications, stick to Window API. Don't do both.'

I agree with this, I don't like the idea of the dual approach and the complexity it introduces. Regardless of whether id's should be unique or not a widget id will only exist for the lifetime of the window it is contained in. Since windows will be created and released dynamically throughout the lifetime of the app I don't see any value in a solution based on the latter. The need to access a widget only exists for the duration that its containing window is in scope (displayed or hidden) and a reference to that widget typically would be required to either set/get a value or possibly modify a style element. There may be other use cases (these are the ones that immediately spring to mind) but do any them violate the above principle?

On this basis my vote still goes firmly for a Window implementation rather than App regardless of the method of implementation.

I agree that it may be time to get a wider consensus
Hey everyone. It's been a while since we disucessed this issue :)

My opinions have changed since we last spoke about this PR two years ago. Now, I do think that getting a widget by ID should be implemented in both `Window` and `App`. It is true that the `App` is where all widgets are stored, but we can't ignore the fact that in the end every widget belongs to a specific window, and we should be able to search for a widget in a window.

Using the `__getitem__` function to get the widgets seems like the right pythonic approach. My only question is, should the API be `app["widget_id"]` or `app.widgets["widget_id"]`? I think the latter is more expressive than the first.

If we agree on this, I'll go ahead and implement it.
It is way overdue 😅.
I'd say `app.widgets[]`/`window.widgets[]` is the right API here. If you're up for implementing this, then go ahead!

Resolving patch:
diff --git a/src/core/src/toga/app.py b/src/core/src/toga/app.py
--- a/src/core/src/toga/app.py
+++ b/src/core/src/toga/app.py
@@ -10,6 +10,7 @@
 from toga.handlers import wrapped_handler
 from toga.icons import Icon
 from toga.platform import get_platform_factory
+from toga.widgets.base import WidgetRegistry
 from toga.window import Window
 
 try:
@@ -161,6 +162,9 @@ def __init__(
         on_exit=None,
         factory=None,
     ):
+        # Initialize empty widgets registry
+        self.widgets = WidgetRegistry()
+
         # Keep an accessible copy of the app instance
         App.app = self
 
diff --git a/src/core/src/toga/widgets/base.py b/src/core/src/toga/widgets/base.py
--- a/src/core/src/toga/widgets/base.py
+++ b/src/core/src/toga/widgets/base.py
@@ -6,6 +6,41 @@
 from toga.style import Pack, TogaApplicator
 
 
+class WidgetRegistry(dict):
+    # WidgetRegistry is implemented as a subclass of dict, because it provides
+    # a mapping from ID to widget. However, it exposes a set-like API; add()
+    # and update() take instances to be added, and iteration is over values.
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+    def __setitem__(self, key, value):
+        # We do not want to allow setting items directly but to use the "add"
+        # method instead.
+        raise RuntimeError(
+            "WidgetRegistry does not allow using item settings directly"
+        )
+
+    def update(self, widgets):
+        for widget in widgets:
+            self.add(widget)
+
+    def add(self, widget):
+        if widget.id in self:
+            # Prevent from adding the same widget twice
+            # or adding 2 widgets with the same id
+            raise KeyError(
+                f'There is already a widget with "{widget.id}" id'
+            )
+        super().__setitem__(widget.id, widget)
+
+    def remove(self, id):
+        del self[id]
+
+    def __iter__(self):
+        return iter(self.values())
+
+
 class Widget(Node):
     """ This is the base widget implementation that all widgets in Toga
     derive from.
@@ -153,16 +188,29 @@ def app(self):
 
     @app.setter
     def app(self, app):
-        # raise an error when we already have an app and attempt to override it
-        # with a different app
-        if self._app and app and self._app != app:
-            raise ValueError("Widget %s is already associated with an App" % self)
-        elif self._impl:
+        # If the widget is already assigned to an app,
+        if self._app:
+            if app is None:
+                # Deregister the widget.
+                self._app.widgets.remove(self.id)
+            elif self._app != app:
+                # raise an error when we already have an app and attempt to override it
+                # with a different app
+                raise ValueError("Widget %s is already associated with an App" % self)
+            else:
+                # If app is the same as the previous app, return
+                return
+
+        if self._impl:
             self._app = app
             self._impl.set_app(app)
             for child in self.children:
                 child.app = app
 
+        if app is not None:
+            # Add this widget to the application widget registry
+            app.widgets.add(self)
+
         # Provide an extension point for widgets with
         # more complex widget heirarchies
         self._set_app(app)
@@ -183,6 +231,10 @@ def window(self):
 
     @window.setter
     def window(self, window):
+        # Remove the widget from the widget registry it is currently a part of
+        if self.window is not None:
+            self.window.widgets.remove(self.id)
+
         self._window = window
         if self._impl:
             self._impl.set_window(window)
@@ -191,6 +243,10 @@ def window(self, window):
             for child in self._children:
                 child.window = window
 
+        if window is not None:
+            # Add this widget to the window's widget registry
+            window.widgets.add(self)
+
         # Provide an extension point for widgets with
         # more complex widget heirarchies
         self._set_window(window)
diff --git a/src/core/src/toga/window.py b/src/core/src/toga/window.py
--- a/src/core/src/toga/window.py
+++ b/src/core/src/toga/window.py
@@ -4,6 +4,7 @@
 from toga.command import CommandSet
 from toga.handlers import wrapped_handler
 from toga.platform import get_platform_factory
+from toga.widgets.base import WidgetRegistry
 
 
 class Window:
@@ -30,6 +31,7 @@ def __init__(self, id=None, title=None,
                  closeable=True, minimizable=True,
                  factory=None, on_close=None,
                  ):
+        self.widgets = WidgetRegistry()
 
         self._id = id if id else identifier(self)
         self._impl = None
@@ -88,6 +90,10 @@ def app(self, app):
 
         self._app = app
         self._impl.set_app(app._impl)
+        app.widgets.update(self.widgets)
+
+        if self.content:
+            self.content.app = app
 
     @property
     def title(self):


Test patch:
diff --git a/src/core/tests/test_app.py b/src/core/tests/test_app.py
--- a/src/core/tests/test_app.py
+++ b/src/core/tests/test_app.py
@@ -2,6 +2,7 @@
 
 import toga
 import toga_dummy
+from toga.widgets.base import WidgetRegistry
 from toga_dummy.utils import TestCase
 
 
@@ -14,6 +15,8 @@ def setUp(self):
         self.id = 'dom-id'
 
         self.content = MagicMock()
+        self.content_id = 'content-id'
+        self.content.id = self.content_id
 
         self.started = False
 
@@ -58,6 +61,10 @@ def test_app_app_id(self):
     def test_app_id(self):
         self.assertEqual(self.app.id, self.id)
 
+    def test_widgets_registry(self):
+        self.assertTrue(isinstance(self.app.widgets, WidgetRegistry))
+        self.assertEqual(len(self.app.widgets), 0)
+
     @patch('toga.app.get_platform_factory')
     def test_app_init_with_no_factory(self, mock_function):
         toga.App(self.name, self.app_id)
diff --git a/src/core/tests/test_widget_registry.py b/src/core/tests/test_widget_registry.py
new file mode 100644
--- /dev/null
+++ b/src/core/tests/test_widget_registry.py
@@ -0,0 +1,93 @@
+from unittest.mock import Mock
+
+from toga.widgets.base import WidgetRegistry
+from toga_dummy.utils import TestCase
+
+
+def widget_mock(id):
+    widget = Mock()
+    widget.id = id
+    widget.__repr__ = Mock(return_value=f"Widget(id={id})")
+    return widget
+
+
+class TestWidgetsRegistry(TestCase):
+
+    def setUp(self):
+        super().setUp()
+        self.widget_registry = WidgetRegistry()
+
+    def test_empty_registry(self):
+        self.assertEqual(len(self.widget_registry), 0)
+        self.assertEqual(list(self.widget_registry), [])
+        self.assertEqual(str(self.widget_registry), "{}")
+
+    def test_add_widget(self):
+        id1 = 1234
+        widget = widget_mock(id1)
+        self.widget_registry.add(widget)
+
+        self.assertEqual(len(self.widget_registry), 1)
+        self.assertEqual(list(self.widget_registry), [widget])
+        self.assertEqual(str(self.widget_registry), "{1234: Widget(id=1234)}")
+        self.assertEqual(self.widget_registry[id1], widget)
+
+    def test_add_two_widgets(self):
+        id1, id2 = 1234, 6789
+        widget1, widget2 = widget_mock(id1), widget_mock(id2)
+        self.widget_registry.add(widget1)
+        self.widget_registry.add(widget2)
+
+        self.assertEqual(len(self.widget_registry), 2)
+        self.assertEqual(set(self.widget_registry), {widget1, widget2})
+        self.assertEqual(self.widget_registry[id1], widget1)
+        self.assertEqual(self.widget_registry[id2], widget2)
+
+    def test_update_widgets(self):
+        id1, id2, id3 = 1234, 6789, 9821
+        widget1, widget2, widget3 = widget_mock(id1), widget_mock(id2), widget_mock(id3)
+        self.widget_registry.update({widget1, widget2, widget3})
+
+        self.assertEqual(len(self.widget_registry), 3)
+        self.assertEqual(set(self.widget_registry), {widget1, widget2, widget3})
+        self.assertEqual(self.widget_registry[id1], widget1)
+        self.assertEqual(self.widget_registry[id2], widget2)
+        self.assertEqual(self.widget_registry[id3], widget3)
+
+    def test_remove_widget(self):
+        id1, id2, id3 = 1234, 6789, 9821
+        widget1, widget2, widget3 = widget_mock(id1), widget_mock(id2), widget_mock(id3)
+        self.widget_registry.update({widget1, widget2, widget3})
+        self.widget_registry.remove(id2)
+
+        self.assertEqual(len(self.widget_registry), 2)
+        self.assertEqual(set(self.widget_registry), {widget1, widget3})
+        self.assertEqual(self.widget_registry[id1], widget1)
+        self.assertEqual(self.widget_registry[id3], widget3)
+
+    def test_add_same_widget_twice(self):
+        id1 = 1234
+        widget = widget_mock(id1)
+        self.widget_registry.add(widget)
+        self.assertRaises(KeyError, self.widget_registry.add, widget)
+
+        self.assertEqual(len(self.widget_registry), 1)
+        self.assertEqual(list(self.widget_registry), [widget])
+        self.assertEqual(str(self.widget_registry), "{1234: Widget(id=1234)}")
+        self.assertEqual(self.widget_registry[id1], widget)
+
+    def test_two_widgets_with_same_name(self):
+        id1 = 1234
+        widget1, widget2 = widget_mock(id1), widget_mock(id1)
+        self.widget_registry.add(widget1)
+        self.assertRaises(KeyError, self.widget_registry.add, widget2)
+
+        self.assertEqual(len(self.widget_registry), 1)
+        self.assertEqual(list(self.widget_registry), [widget1])
+        self.assertEqual(str(self.widget_registry), "{1234: Widget(id=1234)}")
+        self.assertEqual(self.widget_registry[id1], widget1)
+
+    def test_using_setitem_directly(self):
+        id1 = 1234
+        widget = widget_mock(id1)
+        self.assertRaises(RuntimeError, self.widget_registry.__setitem__, id1, widget)
diff --git a/src/core/tests/test_window.py b/src/core/tests/test_window.py
--- a/src/core/tests/test_window.py
+++ b/src/core/tests/test_window.py
@@ -1,9 +1,10 @@
 from pathlib import Path
-from unittest.mock import MagicMock, Mock, patch
+from unittest.mock import MagicMock, Mock, call, patch
 
 import toga
 import toga_dummy
 from toga.command import CommandSet
+from toga.widgets.base import WidgetRegistry
 from toga_dummy.utils import TestCase
 
 
@@ -13,11 +14,14 @@ def setUp(self):
         self.window = toga.Window(factory=toga_dummy.factory)
         self.app = toga.App("test_name", "id.app", factory=toga_dummy.factory)
 
+    def test_window_widgets_registry_on_constructor(self):
+        self.assertTrue(isinstance(self.window.widgets, WidgetRegistry))
+        self.assertEqual(len(self.window.widgets), 0)
+
     def test_show_is_not_called_in_constructor(self):
         self.assertActionNotPerformed(self.window, "show")
 
     def test_show_raises_error_when_app_not_set(self):
-        self.app = None
         with self.assertRaisesRegex(
             AttributeError, "^Can't show a window that doesn't have an associated app$"
         ):
@@ -31,7 +35,6 @@ def test_window_show_with_app_set(self):
         self.assertValueSet(self.window, "visible", True)
 
     def test_hide_raises_error_when_app_not_set(self):
-        self.app = None
         with self.assertRaisesRegex(
             AttributeError, "^Can't hide a window that doesn't have an associated app$"
         ):
@@ -58,7 +61,7 @@ def test_window_show_by_setting_visible_to_false(self):
         self.assertFalse(self.window.visible)
         self.assertValueSet(self.window, "visible", False)
 
-    def test_widget_created(self):
+    def test_set_window_application_twice(self):
         self.assertIsNotNone(self.window.id)
         new_app = toga.App("error_name", "id.error", factory=toga_dummy.factory)
         self.window.app = self.app
@@ -95,23 +98,73 @@ def test_toolbar(self):
         toolbar = self.window.toolbar
         self.assertIsInstance(toolbar, CommandSet)
 
+    def test_set_content_without_app(self):
+        content = MagicMock()
+
+        self.window.content = content
+        self.assertEqual(content.window, self.window)
+        self.assertIsNone(content.app)
+
+    def test_set_content_with_app(self):
+        content = MagicMock()
+
+        self.window.app = self.app
+        self.window.content = content
+
+        self.assertEqual(content.window, self.window)
+        self.assertEqual(content.app, self.app)
+
+    def test_set_app_after_content(self):
+        content = MagicMock()
+
+        self.window.content = content
+        self.window.app = self.app
+
+        self.assertEqual(content.window, self.window)
+        self.assertEqual(content.app, self.app)
+
+    def test_set_app_adds_window_widgets_to_app(self):
+
+        id1, id2, id3 = "id1", "id2", "id3"
+        widget1, widget2, widget3 = (
+            toga.Widget(factory=toga_dummy.factory, id=id1),
+            toga.Widget(factory=toga_dummy.factory, id=id2),
+            toga.Widget(factory=toga_dummy.factory, id=id3),
+        )
+        self.window.widgets.update({widget1, widget2, widget3})
+
+        self.assertEqual(len(self.app.widgets), 0)
+
+        self.window.app = self.app
+
+        self.assertEqual(len(self.app.widgets), 3)
+        self.assertEqual(self.app.widgets[id1], widget1)
+        self.assertEqual(self.app.widgets[id2], widget2)
+        self.assertEqual(self.app.widgets[id3], widget3)
+
     def test_size(self):
         # Add some content
-        mock_content = MagicMock(toga.Box(factory=toga_dummy.factory))
-        self.window.content = mock_content
+        content = MagicMock()
+        self.window.content = content
 
         # Confirm defaults
         self.assertEqual(self.window.size, (640, 480))
         self.assertValueGet(self.window, "size")
 
+        content.refresh.assert_called_once_with()
+
+    def test_set_size(self):
+        # Add some content
+        content = MagicMock()
+        self.window.content = content
+
         # A new size can be assigned
-        mock_content.reset_mock()
         new_size = (1200, 40)
         self.window.size = new_size
         self.assertValueSet(self.window, "size", new_size)
 
         # Side effect of setting window size is a refresh on window content
-        self.window.content.refresh.assert_called_once_with()
+        self.assertEqual(content.refresh.call_args_list, [call(), call()])
 
         # New size can be retrieved
         self.assertEqual(self.window.size, new_size)
diff --git a/src/core/tests/widgets/test_base.py b/src/core/tests/widgets/test_base.py
--- a/src/core/tests/widgets/test_base.py
+++ b/src/core/tests/widgets/test_base.py
@@ -1,3 +1,5 @@
+from unittest.mock import Mock
+
 import toga
 import toga_dummy
 from toga.style import Pack
@@ -40,6 +42,8 @@ def test_enabled_with_None(self):
         self.assertActionPerformedWith(box, 'set enabled', value=None)
 
     def test_adding_child(self):
+        self.assertIsNone(self.widget.app)
+        self.assertIsNone(self.widget.window)
         self.assertEqual(self.widget.children, [], 'No child was added, should return an empty list.')
         # Create a child widget to add to widget.
         child = toga.Widget(factory=toga_dummy.factory)
@@ -53,6 +57,49 @@ def test_adding_child(self):
         self.widget.add(child)
         self.assertEqual(self.widget.children, [child])
 
+    def test_adding_child_with_app(self):
+        app = toga.App("Test App", "org.beeware.test", factory=toga_dummy.factory)
+        self.widget.app = app
+        self.assertEqual(self.widget.children, [], 'No child was added, should return an empty list.')
+
+        # Create a child widget to add to widget.
+        child_id = "child-id"
+        child = Widget(factory=toga_dummy.factory, id=child_id)
+
+        # Deliberately set widget._children = [] to allow it to have children.
+        # Only for test purposes!
+        self.widget._children = []
+        self.widget.add(child)
+        self.assertEqual(self.widget.children, [child])
+        self.assertEqual(self.widget.app, app)
+        self.assertEqual(child.app, app)
+        self.assertEqual(len(app.widgets), 2)
+        self.assertEqual(app.widgets[self.id], self.widget)
+        self.assertEqual(app.widgets[child_id], child)
+
+    def test_adding_child_with_window(self):
+        window = toga.Window(factory=toga_dummy.factory)
+        window.content = Mock()
+        self.widget.window = window
+        self.assertEqual(
+            self.widget.children, [], 'No child was added, should return an empty list.'
+        )
+
+        # Create a child widget to add to widget.
+        child_id = "child-id"
+        child = Widget(factory=toga_dummy.factory, id=child_id)
+
+        # Deliberately set widget._children = [] to allow it to have children.
+        # Only for test purposes!
+        self.widget._children = []
+        self.widget.add(child)
+        self.assertEqual(self.widget.children, [child])
+        self.assertEqual(self.widget.window, window)
+        self.assertEqual(child.window, window)
+        self.assertEqual(len(window.widgets), 2)
+        self.assertEqual(window.widgets[self.id], self.widget)
+        self.assertEqual(window.widgets[child_id], child)
+
     def test_adding_children(self):
         self.assertEqual(self.widget.children, [], 'No children added, should return an empty list.')
         # Create 2 children to add to widget.
@@ -143,6 +190,41 @@ def test_removing_child(self):
         self.widget.remove(child)
         self.assertEqual(self.widget.children, [])
 
+    def test_removing_child_with_app(self):
+        app = toga.App("Test App", "org.beeware.test", factory=toga_dummy.factory)
+        self.widget.app = app
+        self.assertEqual(self.widget.children, [], 'No child was added, should return an empty list.')
+        # Create a child widget to add then remove from widget.
+        child = Widget(factory=toga_dummy.factory)
+
+        self.widget._children = []
+        self.widget.add(child)
+        self.assertEqual(self.widget.children, [child])
+        self.assertEqual(len(app.widgets), 2)
+
+        self.widget.remove(child)
+        self.assertEqual(self.widget.children, [])
+        self.assertEqual(len(app.widgets), 1)
+        self.assertEqual(app.widgets[self.id], self.widget)
+
+    def test_removing_child_with_window(self):
+        window = toga.Window(factory=toga_dummy.factory)
+        window.content = Mock()
+        self.widget.window = window
+        self.assertEqual(self.widget.children, [], 'No child was added, should return an empty list.')
+        # Create a child widget to add then remove from widget.
+        child = Widget(factory=toga_dummy.factory)
+
+        self.widget._children = []
+        self.widget.add(child)
+        self.assertEqual(self.widget.children, [child])
+        self.assertEqual(len(window.widgets), 2)
+
+        self.widget.remove(child)
+        self.assertEqual(self.widget.children, [])
+        self.assertEqual(len(window.widgets), 1)
+        self.assertEqual(window.widgets[self.id], self.widget)
+
     def test_removing_children(self):
         self.assertEqual(self.widget.children, [], 'No children added, should return an empty list.')
         # Create 2 children to add then remove from widget.
@@ -173,10 +255,14 @@ def test_removing_two_out_of_three_children(self):
     def test_set_app(self):
         "A widget can be assigned to an app"
         app = toga.App("Test App", "org.beeware.test", factory=toga_dummy.factory)
+        self.assertEqual(len(app.widgets), 0)
+
         self.widget.app = app
 
         # The app has been assigned
         self.assertEqual(self.widget.app, app)
+        self.assertEqual(len(app.widgets), 1)
+        self.assertEqual(app.widgets[self.id], self.widget)
 
         # The app has been assigned to the underlying impl
         self.assertValueSet(self.widget, 'app', app)
@@ -184,13 +270,15 @@ def test_set_app(self):
     def test_set_app_with_children(self):
         "A widget with children can be assigned to an app"
         # Create 2 children to add to widget, 2 of which will be removed.
-        child1 = Widget(factory=toga_dummy.factory)
-        child2 = Widget(factory=toga_dummy.factory)
+        child_id1, child_id2 = "child-id1", "child-id2"
+        child1 = Widget(factory=toga_dummy.factory, id=child_id1)
+        child2 = Widget(factory=toga_dummy.factory, id=child_id2)
 
         self.widget._children = []
         self.widget.add(child1, child2)
 
         app = toga.App("Test App", "org.beeware.test", factory=toga_dummy.factory)
+        self.assertEqual(len(app.widgets), 0)
 
         self.widget.app = app
 
@@ -198,6 +286,10 @@ def test_set_app_with_children(self):
         self.assertEqual(self.widget.app, app)
         self.assertEqual(child1.app, app)
         self.assertEqual(child2.app, app)
+        self.assertEqual(len(app.widgets), 3)
+        self.assertEqual(app.widgets[self.id], self.widget)
+        self.assertEqual(app.widgets[child_id1], child1)
+        self.assertEqual(app.widgets[child_id2], child2)
 
         # The app has been assigned to the underlying impls
         self.assertValueSet(self.widget, 'app', app)
@@ -220,6 +312,7 @@ def test_repeat_set_app(self):
 
         # The app is still assigned to the original app
         self.assertEqual(self.widget.app, app)
+        self.assertEqual(list(app.widgets), [self.widget])
 
     def test_repeat_same_set_app(self):
         "If a widget is already assigned to an app, re-assigning to the same app is OK"
@@ -234,3 +327,50 @@ def test_repeat_same_set_app(self):
 
         # The app is still assigned
         self.assertEqual(self.widget.app, app)
+        self.assertEqual(list(app.widgets), [self.widget])
+
+    def test_remove_app(self):
+        "A widget can be assigned to an app"
+        app = toga.App("Test App", "org.beeware.test", factory=toga_dummy.factory)
+        self.assertEqual(len(app.widgets), 0)
+
+        self.widget.app = app
+        self.widget.app = None
+
+        # The app has been unassigned
+        self.assertIsNone(self.widget.app)
+        self.assertEqual(len(app.widgets), 0)
+
+        # The app has been assigned to the underlying impl
+        self.assertValueSet(self.widget, 'app', None)
+
+    def test_set_window(self):
+        window = toga.Window(factory=toga_dummy.factory)
+        self.assertEqual(len(window.widgets), 0)
+        self.widget.window = window
+
+        self.assertEqual(len(window.widgets), 1)
+        self.assertEqual(window.widgets[self.id], self.widget)
+
+    def test_replace_window(self):
+        window1, window2 = (
+            toga.Window(factory=toga_dummy.factory),
+            toga.Window(factory=toga_dummy.factory)
+        )
+        self.widget.window = window1
+        self.assertEqual(len(window1.widgets), 1)
+        self.assertEqual(len(window2.widgets), 0)
+
+        self.widget.window = window2
+        self.assertEqual(len(window1.widgets), 0)
+        self.assertEqual(len(window2.widgets), 1)
+        self.assertEqual(window2.widgets[self.id], self.widget)
+
+    def test_remove_window(self):
+        window = toga.Window(factory=toga_dummy.factory)
+        self.assertEqual(len(window.widgets), 0)
+
+        self.widget.window = window
+        self.widget.window = None
+
+        self.assertEqual(len(window.widgets), 0)