Commit a41ff102 authored by Duggan, John's avatar Duggan, John Committed by Ayres, Andrew
Browse files

Add threading example for Trame (and other cleanup)

parent 2dd9a67d
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
@@ -22,5 +22,5 @@ class MainModel(BaseModel):
        examples=["user"],
    )
    password: str = Field(default="test_password", title="User Password")
    fractal: Fractal = Field(default_factory=Fractal)
    file: str = Field(default="", title="Select a File")
    fractal: Fractal = Field(default_factory=Fractal)
+22 −0
Original line number Diff line number Diff line
"""Module for the main ViewModel."""

from asyncio import create_task, sleep
from threading import Thread
from typing import Any, Dict

from nova.mvvm.interface import BindingInterface
@@ -12,6 +14,7 @@ class MainViewModel:

    def __init__(self, model: MainModel, binding: BindingInterface):
        self.model = model
        self.running = False

        # here we create a bind that connects ViewModel with View. It returns a communicator object,
        # that allows to update View from ViewModel (by calling update_view).
@@ -19,6 +22,7 @@ class MainViewModel:
        # but one also can provide a callback function if they want to react to those events
        # and/or process errors.
        self.config_bind = binding.new_bind(self.model, callback_after_update=self.change_callback)
        self.running_bind = binding.new_bind()

    def change_callback(self, results: Dict[str, Any]) -> None:
        if results["error"]:
@@ -28,6 +32,24 @@ class MainViewModel:

    def update_view(self) -> None:
        self.config_bind.update_in_view(self.model)
        self.running_bind.update_in_view(self.running)

    def run_fractal(self) -> None:
        self.running = True
        self.update_view()

        # update_view won't take effect until this method returns a value, so we must offload this long-running task to
        # a background thread for our conditional rendering to work.
        fractal_tool_thread = Thread(target=self.run_fractal_in_background, daemon=True)
        fractal_tool_thread.start()

        create_task(self.monitor_fractal())

    def run_fractal_in_background(self) -> None:
        self.model.fractal.run_fractal_tool()
        self.running = False

    async def monitor_fractal(self) -> None:
        while self.running:
            await sleep(0.1)
        self.update_view()
+9 −14
Original line number Diff line number Diff line
@@ -2,24 +2,19 @@ from trame.widgets import vuetify3 as vuetify

from nova.trame.view.components import InputField
from nova_tutorial.app.view_models.main import MainViewModel
from nova.trame.view import layouts

class FractalTab:

    def __init__(self, view_model: MainViewModel) -> None:
        self.view_model = view_model
        self.view_model.running_bind.connect("running")
        self.create_ui()

    def create_ui(self) -> None:
        with layouts.VBoxLayout(classes="ma-4"):
            with vuetify.VCard(classes="pa-4"):
                InputField(
                    v_model=("config.fractal.fractal_type", "mandelbrot"),
                    label="Fractal Type",
                )
        InputField(v_model="config.fractal.fractal_type", classes="mb-2")
        vuetify.VProgressCircular(v_if="running", indeterminate=True)
        vuetify.VBtn(
            "Run Fractal",
                    click=self.view_model.run_fractal,
                    classes="mt-2",
            v_else=True,
            click=self.view_model.run_fractal,  # calls the run_fractal_tool method
        )
                vuetify.VCardText(v_text="config.status_message", classes="mt-2")
 No newline at end of file
        vuetify.VImg(src=("config.fractal.image_data",), height="400", width="400")
+100 −5
Original line number Diff line number Diff line
@@ -121,7 +121,7 @@ The `with` syntax is used by Trame to add content to a slot. This allows your vi

Here is a layout diagram showing all of the available slots in `ThemedApp`:

![The `nova-trame` slot diagram for its default layout](https://nova-application-development.readthedocs.io/projects/nova-trame/en/stable/_images/layout.png)
![The `nova-trame` slot diagram for its default layout](fig/layout.png)

::::::::::::::::::::::::::::::::::::::::: callout

@@ -222,7 +222,7 @@ We\'ll add an `InputField` and a `VBoxLayout` to this tab.
```python
"""Module for the Sample Tab 1."""

from nova.trame.view.components import InputField
from nova.trame.view.components import InputField, RemoteFileInput
from nova.trame.view import layouts
from trame.widgets import vuetify3 as vuetify

@@ -236,10 +236,27 @@ class SampleTab1:
        with layouts.VBoxLayout(classes="ma-2"):
            InputField(v_model="config.username", label="Username")
            vuetify.VCheckbox(label="Remember me")
            RemoteFileInput(v_model="config.file", base_paths=["/SNS"])
            RemoteFileInput(v_model="config.file", base_paths=["/HFIR", "/SNS"])
```

Since `config.file` doesn't exist yet, we\'ll need to add it to the model.

**3. `nova_tutorial/app/models/main_model.py` (Modify):**

```python
    username: str = Field(
        default="test_name",
        min_length=1,
        title="User Name",
        description="Please provide the name of the user",
        examples=["user"],
    )
    password: str = Field(default="test_password", title="User Password")
    file: str = Field(default="", title="Select a File")
    fractal: Fractal = Field(default_factory=Fractal)
```

**3. `nova_tutorial/app/views/sample_tab_2.py` (Modify):**
**4. `nova_tutorial/app/views/sample_tab_2.py` (Modify):**

We\'ll add a `GridLayout` and an `InputField` to this tab.

@@ -269,11 +286,89 @@ In `SampleTab1`, we\'ve used a `VBoxLayout` to vertically stack the `InputField`
To run the code, use the following command in the top level of your `nova_tutorial` project:

```bash
poetry run start
poetry run app
```

You should now see the simple UI. When you click the "Sample Tab 1" and "Sample Tab 2" tabs, you should now see the updated content with the new UI components.

## Advanced Topics (Asynchronicity & Conditional Rendering)

Now that we understand the basics of working with Trame, let\'s make the view for the fractal tab a bit more intuitive for the user by giving them a visual indicator that the job is running.

**5. `nova_tutorial/app/views/fractal_tab.py` (Modify):**

```python
    def __init__(self, view_model: MainViewModel) -> None:
        self.view_model = view_model
        self.view_model.running_bind.connect("running")
        self.create_ui()

    def create_ui(self) -> None:
        InputField(v_model="config.fractal.fractal_type", classes="mb-2")
        vuetify.VProgressCircular(v_if="running", indeterminate=True)
        vuetify.VBtn(
            "Run Fractal",
            v_else=True,
            click=self.view_model.run_fractal # calls the run_fractal_tool method
        )
        vuetify.VImg(src=("config.fractal.image_data",), height="400", width="400")
```

We will need to add a data binding for `running`, as well. We choose to place this directly in the view model as this is not relevant to running the fractal tool on NDIP.

**6. `nova_tutorial/app/view_models/main.py` (Modify):**

```python
    def __init__(self, model: MainModel, binding: BindingInterface):
        self.model = model
        self.running = False

        # here we create a bind that connects ViewModel with View. It returns a communicator object,
        # that allows to update View from ViewModel (by calling update_view).
        # self.model will be updated automatically on changes of connected fields in View,
        # but one also can provide a callback function if they want to react to those events
        # and/or process errors.
        self.config_bind = binding.new_bind(self.model, callback_after_update=self.change_callback)
        self.running_bind = binding.new_bind()

    def update_view(self) -> None:
        self.config_bind.update_in_view(self.model)
        self.running_bind.update_in_view(self.running)
```

Finally, we manipulate our new view state based on the current status of the tool. Because the fractal tool takes a long time to complete, we offload it to a background thread. If we do not do this, then Trame will not update the view until the tool has finished running, which defeats the purpose of this change.

```python
    def run_fractal(self) -> None:
        self.running = True
        self.update_view()

        # update_view won't take effect until this method returns a value, so we must offload this long-running task to
        # a background thread for our conditional rendering to work.
        fractal_tool_thread = Thread(target=self.run_fractal_in_background, daemon=True)
        fractal_tool_thread.start()

        # We also need to know when the tool is done running so that we can
        create_task(self.monitor_fractal())

    def run_fractal_in_background(self) -> None:
        self.model.fractal.run_fractal_tool()
        self.running = False

    async def monitor_fractal(self) -> None:
        while self.running:
            await sleep(0.1)
        self.update_view()
```

::::::::::::::::::::::::::::::::: callout
With any Trame or `nova-trame` component, you can use the `v_if`, `v_else_if`, and `v_else` arguments to only show the component in the interface when a condition is true. The condition can be a reference to your model, similar to the `v_model` argument, or it can be a full JavaScript expression for complex use cases.
:::::::::::::::::::::::::::::::::::::::::

::::::::::::::::::::::::::::::::: callout
One major caveat when working with Trame is that Trame itself runs in the main thread of your application. Since Trame is responsible for syncing state between the server and the user interface, if you run a long, CPU-bound task in the main thread then Trame will freeze and your user interface will likely crash. If you need to run a long job (for example, a Mantid command that takes several minutes), then it is your responsibility to ensure that the task is run in a separate thread.
:::::::::::::::::::::::::::::::::::::::::

:::::::::::::::::::::::::::::::::::::::  challenge
**Explore the `InputField` Component**
Modify the `InputField` component in `SampleTab1` to automatically retrieve the label, hint, and validation rules from a Pydantic model field. Create a simple Pydantic model with a `username` field with a `title`, `description`, and `min_length` constraint.
+50 −15
Original line number Diff line number Diff line
@@ -39,6 +39,41 @@ Let\'s start by setting up a new application from the template. When answering t

```bash
copier copy https://code.ornl.gov/ndip/project-templates/nova-application-template.git viz_tutorial
```

*   **What is your project name?**

    > Enter `Viz Examples`

*   **What is your Python package name (use Python naming conventions)?**

    > Press enter to accept the default.

*   **Do you want to install Mantid for your project?**

    > Enter `no`

*   ** Are you developing a GUI application using MVVM pattern?**

    > Enter `yes`

*   ** Which library will you use?**

    > Select `Trame`

*   **Do you want a template with multiple tabs?

    > Enter `yes`

*   **Publish to PyPI?**

    > Enter `no`

*   **Publish documentation to readthedocs.io?**

    > Enter `no`

```bash
cd viz_tutorial
poetry install
poetry run app
@@ -56,7 +91,7 @@ The pandas install is only necessary for loading example data from Plotly, which

Now, we can create a view that displays a Plotly figure.

**1. `PlotlyView` View Class (`src/nova_tutorial/views/plotly.py`):**
**1. `PlotlyView` View Class (`src/viz_examples/views/plotly.py`):**

*   **Imports**:  Pay special attention to the plotly import. This module contains a Trame widget that will allow us to quickly add a Plotly chart to our view.

@@ -116,7 +151,7 @@ class PlotlyView:

As with our previous examples, there is a corresponding model.

**2. `PlotlyConfig` Model Class (src/nova_tutorial/models/plotly.py):**
**2. `PlotlyConfig` Model Class (src/viz_examples/models/plotly.py):**

*   **Imports**:  The graph_objects module is how we will define the content for our chart. The iris module defines an example dataset.

@@ -183,7 +218,7 @@ class PlotlyConfig(BaseModel):

First, let's add replace the sample tabs from the template with the following:

**3. `src/nova_tutorial/views/tab_content_panel.py` (Modify):**
**3. `src/viz_examples/views/tab_content_panel.py` (Modify):**

*   **Import `PlotlyView`**

@@ -207,7 +242,7 @@ And add the corresponding import:

We also need to update the tabs to show an option for the Plotly view.

**4. `src/nova_tutorial/views/tabs_panel.py` (Modify):**
**4. `src/viz_examples/views/tabs_panel.py` (Modify):**

```python
    def create_ui(self) -> None:
@@ -217,7 +252,7 @@ We also need to update the tabs to show an option for the Plotly view.

Finally, we'll need to update the view model to bind our new classes.

**5. `src/nova_tutorial/view_models/main.py` (Modify):**
**5. `src/viz_examples/view_models/main.py` (Modify):**

*   **Import `PlotlyConfig`**

@@ -262,7 +297,7 @@ PyVista contains built-in Trame support, but we still need to install the Trame

Now we can set up our view.

**6. `PyVistaView` View Class (`src/nova_tutorial/views/pyvista.py`):**
**6. `PyVistaView` View Class (`src/viz_examples/views/pyvista.py`):**

*   **Imports:**  `plotter_ui` contains the Trame widget for PyVista.

@@ -320,7 +355,7 @@ class PyVistaView:
            self.view_model.update_pyvista_volume(self.plotter)
```

**7. `PyVistaConfig` Model Class (`src/nova_tutorial/models/pyvista.py`):**
**7. `PyVistaConfig` Model Class (`src/viz_examples/models/pyvista.py`):**

*   **Imports:**  `download_knee_full` yields a 3D dataset that is suitable for volume rendering. You can find more datasets in PyVista\'s [Dataset Gallery](https://docs.pyvista.org/api/examples/dataset_gallery).

@@ -368,7 +403,7 @@ PyVista\'s volume rendering engine isn\'t currently suitable for large data. If

This is very similar to the Plotly setup.

**8. `src/nova_tutorial/views/tab_content_panel.py` (Modify):**
**8. `src/viz_examples/views/tab_content_panel.py` (Modify):**

*   **Import `PyVistaView`**

@@ -390,7 +425,7 @@ from ..views.pyvista import PyVistaView
                            PyVistaView(self.view_model)
```

**9. `src/nova_tutorial/views/tabs_panel.py` (Modify):**
**9. `src/viz_examples/views/tabs_panel.py` (Modify):**

```python
    def create_ui(self) -> None:
@@ -399,7 +434,7 @@ from ..views.pyvista import PyVistaView
            vuetify.VTab("PyVista", value=2)
```

**10. `src/nova_tutorial/view_models/main.py` (Modify):**
**10. `src/viz_examples/view_models/main.py` (Modify):**

*   **Import `PyVistaConfig`**

@@ -448,7 +483,7 @@ PyVista isn't compatible with VTK 9.4, yet. If you are not using PyVista, there

Once more, let's setup a view and model.

**11. `VTKView` View Class (`src/nova_tutorial/views/vtk.py`):**
**11. `VTKView` View Class (`src/viz_examples/views/vtk.py`):**

*   **Imports:**  The `vtkRenderingVolumeOpenGL2` import is necessary despite being unreferenced.

@@ -509,7 +544,7 @@ class VTKView:
        self.render_window.Render()
```

**12. `VTKConfig` Model Class (`src/nova_tutorial/models/vtk.py`):**
**12. `VTKConfig` Model Class (`src/viz_examples/models/vtk.py`):**

*   **Imports:**  We are only using PyVista to get an example dataset. There are two references to it as we use `KNEE_DATA` to compute min/max bounds for the data and `KNEE_DATAFILE` to pass the data file into a VTK reader. The FixedPointVolumeRayCastMapper is CPU-based, but other mappers are available if you need GPU support.

@@ -641,7 +676,7 @@ class VTKConfig:

This is very similar to the Plotly and PyVista setup.

**13. `src/nova_tutorial/views/tab_content_panel.py` (Modify):**
**13. `src/viz_examples/views/tab_content_panel.py` (Modify):**

*   **Import `VTKView`**

@@ -665,7 +700,7 @@ from ..views.vtk import VTKView
                            VTKView(self.view_model)
```

**14. `src/nova_tutorial/views/tabs_panel.py` (Modify):**
**14. `src/viz_examples/views/tabs_panel.py` (Modify):**

```python
    def create_ui(self) -> None:
@@ -675,7 +710,7 @@ from ..views.vtk import VTKView
            vuetify.VTab("VTK", value=3)
```

**15. `src/nova_tutorial/view_models/main.py` (Modify):**
**15. `src/viz_examples/view_models/main.py` (Modify):**

*   **Import `VTKConfig`**

Loading