diff --git a/code/episode_5/src/nova_tutorial/app/models/main_model.py b/code/episode_5/src/nova_tutorial/app/models/main_model.py index 15e6567a7ae2829002cc3c7a9989f95c19c51b3f..1a08559c6cf70e3141be689fea0635f2ec276c75 100755 --- a/code/episode_5/src/nova_tutorial/app/models/main_model.py +++ b/code/episode_5/src/nova_tutorial/app/models/main_model.py @@ -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) diff --git a/code/episode_5/src/nova_tutorial/app/view_models/main.py b/code/episode_5/src/nova_tutorial/app/view_models/main.py index 1bceca836b7166a06d940c777c419abbaa16ffbb..c554a222ae7bcaedbeac384a9598c954c6d8b633 100755 --- a/code/episode_5/src/nova_tutorial/app/view_models/main.py +++ b/code/episode_5/src/nova_tutorial/app/view_models/main.py @@ -1,5 +1,7 @@ """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() diff --git a/code/episode_5/src/nova_tutorial/app/views/fractal_tab.py b/code/episode_5/src/nova_tutorial/app/views/fractal_tab.py index 465deb9b75eddc5c4b5b5fbc7befa85481aeac1e..b46f0f3b2980a78adaf74fd56a0d6f27708034d1 100755 --- a/code/episode_5/src/nova_tutorial/app/views/fractal_tab.py +++ b/code/episode_5/src/nova_tutorial/app/views/fractal_tab.py @@ -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", - ) - vuetify.VBtn( - "Run Fractal", - click=self.view_model.run_fractal, - classes="mt-2", - ) - vuetify.VCardText(v_text="config.status_message", classes="mt-2") \ No newline at end of file + 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") diff --git a/episodes/05-Working-with-Trame.md b/episodes/05-Working-with-Trame.md index b16fa674a707691f84230087aaeb67a2d8680fb8..063d910d2050dd1c32b4ff84d97686d7824a8bb0 100755 --- a/episodes/05-Working-with-Trame.md +++ b/episodes/05-Working-with-Trame.md @@ -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"]) ``` -**3. `nova_tutorial/app/views/sample_tab_2.py` (Modify):** +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) +``` + +**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. diff --git a/episodes/07-Advanced-Visualizations.md b/episodes/07-Advanced-Visualizations.md index 4baeda045df019a16b12fd4204d026634f1b3275..35a4e4e0ec40f2fc45f118f0ca467cb0f48a893e 100755 --- a/episodes/07-Advanced-Visualizations.md +++ b/episodes/07-Advanced-Visualizations.md @@ -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`** diff --git a/episodes/fig/layout.png b/episodes/fig/layout.png new file mode 100755 index 0000000000000000000000000000000000000000..f3464064ea39f29cb63f68031f685ced9dd50672 Binary files /dev/null and b/episodes/fig/layout.png differ