diff --git a/qe.ipynb b/qe.ipynb
index 7d8254bd7..58e173278 100644
--- a/qe.ipynb
+++ b/qe.ipynb
@@ -47,22 +47,24 @@
"\n",
" sys.modules[\"pybel\"] = __import__(\"openbabel\", globals(), locals(), [\"pybel\"]).pybel\n",
"except Exception:\n",
- " pass\n",
+ " pass"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from IPython.display import display\n",
"\n",
- "import urllib.parse as urlparse\n",
- "from datetime import datetime\n",
+ "from aiidalab_qe.app.wrapper import AppWrapperContoller, AppWrapperModel, AppWrapperView\n",
"\n",
- "import ipywidgets as ipw\n",
- "from importlib_resources import files\n",
- "from IPython.display import display\n",
- "from jinja2 import Environment\n",
+ "model = AppWrapperModel()\n",
+ "view = AppWrapperView()\n",
+ "controller = AppWrapperContoller(model, view)\n",
"\n",
- "from aiidalab_qe.app import App\n",
- "from aiidalab_qe.app.static import styles, templates\n",
- "from aiidalab_qe.version import __version__\n",
- "from aiidalab_widgets_base.bug_report import (\n",
- " install_create_github_issue_exception_handler,\n",
- ")"
+ "display(view)"
]
},
{
@@ -71,37 +73,38 @@
"metadata": {},
"outputs": [],
"source": [
- "env = Environment()\n",
+ "import urllib.parse as urlparse\n",
+ "\n",
+ "from aiidalab_qe.app.main import App\n",
+ "from aiidalab_widgets_base.bug_report import (\n",
+ " install_create_github_issue_exception_handler,\n",
+ ")\n",
"\n",
- "template = files(templates).joinpath(\"welcome.jinja\").read_text()\n",
- "style = files(styles).joinpath(\"style.css\").read_text()\n",
- "welcome_message = ipw.HTML(env.from_string(template).render(style=style))\n",
- "current_year = datetime.now().year\n",
- "footer = ipw.HTML(\n",
- " f'
Copyright (c) {current_year} AiiDAlab team Version: {__version__}
'\n",
+ "install_create_github_issue_exception_handler(\n",
+ " view.output,\n",
+ " url=\"https://github.com/aiidalab/aiidalab-qe/issues/new\",\n",
+ " labels=(\"bug\", \"automated-report\"),\n",
")\n",
"\n",
"url = urlparse.urlsplit(jupyter_notebook_url) # noqa F821\n",
"query = urlparse.parse_qs(url.query)\n",
"\n",
- "\n",
"app_with_work_chain_selector = App(qe_auto_setup=True)\n",
"# if a pk is provided in the query string, set it as the value of the work_chain_selector\n",
"if \"pk\" in query:\n",
" pk = int(query[\"pk\"][0])\n",
" app_with_work_chain_selector.work_chain_selector.value = pk\n",
"\n",
- "output = ipw.Output()\n",
- "install_create_github_issue_exception_handler(\n",
- " output,\n",
- " url=\"https://github.com/aiidalab/aiidalab-qe/issues/new\",\n",
- " labels=(\"bug\", \"automated-report\"),\n",
- ")\n",
- "\n",
- "with output:\n",
- " display(welcome_message, app_with_work_chain_selector, footer)\n",
- "\n",
- "display(output)"
+ "view.main.children = [app_with_work_chain_selector]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "controller.enable_toggles()"
]
}
],
diff --git a/src/aiidalab_qe/app/__init__.py b/src/aiidalab_qe/app/__init__.py
index ea2de96ed..8138fc07a 100644
--- a/src/aiidalab_qe/app/__init__.py
+++ b/src/aiidalab_qe/app/__init__.py
@@ -1,7 +1 @@
"""Package for the AiiDAlab QE app."""
-
-from .main import App
-
-__all__ = [
- "App",
-]
diff --git a/src/aiidalab_qe/app/static/styles/custom.css b/src/aiidalab_qe/app/static/styles/custom.css
index 876d68a98..d27c481e2 100644
--- a/src/aiidalab_qe/app/static/styles/custom.css
+++ b/src/aiidalab_qe/app/static/styles/custom.css
@@ -1,3 +1,43 @@
.output_subarea {
max-width: none !important;
}
+
+.app-header {
+ margin-bottom: 1em;
+}
+
+.logo {
+ text-align: center;
+}
+
+#subtitle {
+ text-align: center;
+ font-style: italic;
+}
+
+.info-toggles {
+ margin: 0 auto;
+}
+.info-toggles button {
+ width: 100px;
+ margin: 1em 0.5em;
+}
+.info-toggles button:focus {
+ outline: none !important;
+}
+
+.guide ol {
+ list-style: none;
+}
+.guide p:not(:last-of-type) {
+ margin-bottom: 0.5em;
+}
+
+#loading {
+ text-align: center;
+ font-size: large;
+}
+
+footer {
+ text-align: right;
+}
diff --git a/src/aiidalab_qe/app/static/templates/about.jinja b/src/aiidalab_qe/app/static/templates/about.jinja
new file mode 100644
index 000000000..a3b2e8377
--- /dev/null
+++ b/src/aiidalab_qe/app/static/templates/about.jinja
@@ -0,0 +1,10 @@
+
+
+ The Quantum ESPRESSO app
+ (or QE app for short) is a graphical front end for calculating materials properties using
+ Quantum ESPRESSO (QE). Each property is calculated by workflows powered by the
+ AiiDA engine , and maintained in the
+ Quantum ESPRESSO plugin
+ for AiiDA.
+
+
diff --git a/src/aiidalab_qe/app/static/templates/guide.jinja b/src/aiidalab_qe/app/static/templates/guide.jinja
new file mode 100644
index 000000000..14b10f914
--- /dev/null
+++ b/src/aiidalab_qe/app/static/templates/guide.jinja
@@ -0,0 +1,42 @@
+
+
+ The QE app allows you to calculate properties in a simple 4-step process:
+
+
+
+
+ 🔍 Step 1: Select the structure you want to run.
+
+
+ ⚙️ Step 2: Select the properties you are interested in.
+
+
+ 💻 Step 3: Choose the computational resources you want to run on.
+
+
+ 🚀 Step 4: Submit your workflow.
+
+
+
+
+ New users can go straight to the first step and select their structure.
+
+
+
+ Completed workflows can be selected at the top of the app.
+
+
+
+ You can also check out the
+ basic tutorial to get started
+ with the Quantum ESPRESSO app, or try out the
+ advanced tutorial to learn
+ additional features offered by the app.
+
+
+
+ For a more in-depth dive into the app's features, please refer to the
+ how-to guides .
+
+
+
diff --git a/src/aiidalab_qe/app/static/templates/welcome.jinja b/src/aiidalab_qe/app/static/templates/welcome.jinja
deleted file mode 100644
index cdf8830a3..000000000
--- a/src/aiidalab_qe/app/static/templates/welcome.jinja
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
-
-
-
-
-
Welcome to the AiiDAlab Quantum ESPRESSO app! 👋
-
- The
Quantum ESPRESSO app (or QE app for short) is a graphical front end for calculating materials properties using Quantum ESPRESSO (QE).
- Each property is calculated by workflows powered by the
AiiDA engine , and maintained in the
Quantum ESPRESSO plugin for AiiDA.
-
-
The QE app allows you to calculate properties in a simple 4-step process:
-
-
- 🔍 Step 1: Select the structure you want to run.
- ⚙️ Step 2: Select the properties you are interested in.
- 💻 Step 3: Choose the computational resources you want to run on.
- 🚀 Step 4: Submit your workflow.
-
-
-
New users can go straight to the first step and select their structure. Once you've already run some calculations, you can select the corresponding workflow using the dropdown below.
-
Happy computing! 🎉
-
-
-
diff --git a/src/aiidalab_qe/app/static/templates/workflow_failure.jinja b/src/aiidalab_qe/app/static/templates/workflow_failure.jinja
index fe4ea7e00..b7adb3a8a 100644
--- a/src/aiidalab_qe/app/static/templates/workflow_failure.jinja
+++ b/src/aiidalab_qe/app/static/templates/workflow_failure.jinja
@@ -1,9 +1,3 @@
-
-
-
-
diff --git a/src/aiidalab_qe/app/static/templates/workflow_summary.jinja b/src/aiidalab_qe/app/static/templates/workflow_summary.jinja
index 594e46150..01743dca4 100644
--- a/src/aiidalab_qe/app/static/templates/workflow_summary.jinja
+++ b/src/aiidalab_qe/app/static/templates/workflow_summary.jinja
@@ -1,9 +1,3 @@
-
-
-
-
diff --git a/src/aiidalab_qe/app/wrapper.py b/src/aiidalab_qe/app/wrapper.py
new file mode 100644
index 000000000..5c79ba36d
--- /dev/null
+++ b/src/aiidalab_qe/app/wrapper.py
@@ -0,0 +1,179 @@
+from __future__ import annotations
+
+import ipywidgets as ipw
+import traitlets
+
+
+def without_triggering(toggle: str):
+ """Decorator to prevent the other toggle from triggering its callback."""
+
+ def decorator(func):
+ def wrapper(self, change: dict):
+ """Toggle off other button without triggering its callback."""
+ view: AppWrapperView = self._view
+ button: ipw.ToggleButton = getattr(view, toggle)
+ callback = getattr(self, f"_on_{toggle}")
+ button.unobserve(callback, "value")
+ button.value = False
+ func(self, change)
+ button.observe(callback, "value")
+
+ return wrapper
+
+ return decorator
+
+
+class AppWrapperContoller:
+ """An MVC controller for `AppWrapper`."""
+
+ def __init__(
+ self,
+ model: AppWrapperModel,
+ view: AppWrapperView,
+ ) -> None:
+ """`AppWrapperController` constructor.
+
+ Parameters
+ ----------
+ `model` : `AppWrapperModel`
+ The associated model.
+ `view` : `AppWrapperView`
+ The associated view.
+ """
+ self._model = model
+ self._view = view
+ self._set_event_handlers()
+
+ def enable_toggles(self) -> None:
+ """Enable the toggle buttons."""
+ self._view.guide_toggle.disabled = False
+ self._view.about_toggle.disabled = False
+
+ @without_triggering("about_toggle")
+ def _on_guide_toggle(self, change: dict):
+ """Toggle the guide section."""
+ self._view.info_container.children = [self._view.guide] if change["new"] else []
+ self._view.info_container.layout.display = "flex" if change["new"] else "none"
+
+ @without_triggering("guide_toggle")
+ def _on_about_toggle(self, change: dict):
+ """Toggle the about section."""
+ self._view.info_container.children = [self._view.about] if change["new"] else []
+ self._view.info_container.layout.display = "flex" if change["new"] else "none"
+
+ def _set_event_handlers(self) -> None:
+ """Set up event handlers."""
+ self._view.guide_toggle.observe(self._on_guide_toggle, "value")
+ self._view.about_toggle.observe(self._on_about_toggle, "value")
+
+
+class AppWrapperModel(traitlets.HasTraits):
+ """An MVC model for `AppWrapper`."""
+
+ def __init__(self):
+ """`AppWrapperModel` constructor."""
+
+
+class AppWrapperView(ipw.VBox):
+ """An MVC view for `AppWrapper`."""
+
+ def __init__(self) -> None:
+ """`AppWrapperView` constructor."""
+
+ ################# LAZY LOADING #################
+
+ from datetime import datetime
+
+ from importlib_resources import files
+ from IPython.display import Image, display
+ from jinja2 import Environment
+
+ from aiidalab_qe.app.static import templates
+ from aiidalab_qe.common.infobox import InfoBox
+ from aiidalab_qe.version import __version__
+
+ #################################################
+
+ self.output = ipw.Output()
+
+ logo_img = Image(
+ filename="docs/source/_static/logo.png",
+ width="700",
+ )
+ logo = ipw.Output()
+ with logo:
+ display(logo_img)
+ logo.add_class("logo")
+
+ subtitle = ipw.HTML("
🎉 Happy computing 🎉 ")
+
+ self.guide_toggle = ipw.ToggleButton(
+ button_style="",
+ icon="question",
+ value=False,
+ description="Guide",
+ tooltip="Learn how to use the app",
+ disabled=True,
+ )
+
+ self.about_toggle = ipw.ToggleButton(
+ button_style="",
+ icon="info",
+ value=False,
+ description="About",
+ tooltip="Learn about the app",
+ disabled=True,
+ )
+
+ info_toggles = ipw.HBox(
+ children=[
+ self.guide_toggle,
+ self.about_toggle,
+ ]
+ )
+ info_toggles.add_class("info-toggles")
+
+ env = Environment()
+ guide_template = files(templates).joinpath("guide.jinja").read_text()
+ about_template = files(templates).joinpath("about.jinja").read_text()
+
+ self.guide = ipw.HTML(env.from_string(guide_template).render())
+ self.about = ipw.HTML(env.from_string(about_template).render())
+
+ self.info_container = InfoBox()
+
+ header = ipw.VBox(
+ children=[
+ logo,
+ subtitle,
+ info_toggles,
+ self.info_container,
+ ],
+ )
+ header.add_class("app-header")
+
+ loading = ipw.HTML("""
+
+ Loading the app
+
+ """)
+
+ self.main = ipw.VBox(children=[loading])
+
+ current_year = datetime.now().year
+ footer = ipw.HTML(f"""
+
+ Copyright (c) {current_year} AiiDAlab team
+ Version: {__version__}
+
+ """)
+
+ super().__init__(
+ layout={},
+ children=[
+ self.output,
+ header,
+ self.main,
+ footer,
+ ],
+ )
diff --git a/tests/test_wrapper.py b/tests/test_wrapper.py
new file mode 100644
index 000000000..609fa40d8
--- /dev/null
+++ b/tests/test_wrapper.py
@@ -0,0 +1,64 @@
+from aiidalab_qe.app.wrapper import AppWrapperContoller, AppWrapperModel, AppWrapperView
+
+
+class TestWrapper:
+ def test_enable_toggles(self):
+ """Test enable_toggles method."""
+ self._instansiate_mvc_components()
+ assert self.view.guide_toggle.disabled is True
+ assert self.view.about_toggle.disabled is True
+ self.controller.enable_toggles()
+ assert self.view.guide_toggle.disabled is False
+ assert self.view.about_toggle.disabled is False
+
+ def test_guide_toggle(self):
+ """Test guide_toggle method."""
+ self._instansiate_mvc_components()
+ self.controller.enable_toggles()
+ self.controller._on_guide_toggle({"new": True})
+ self._assert_guide_is_on()
+ self.controller._on_guide_toggle({"new": False})
+ self._assert_no_guide_info()
+
+ def test_about_toggle(self):
+ """Test about_toggle method."""
+ self._instansiate_mvc_components()
+ self.controller.enable_toggles()
+ self.controller._on_about_toggle({"new": True})
+ self._assert_about_is_on()
+ self.controller._on_about_toggle({"new": False})
+ self._assert_no_guide_info()
+
+ def test_toggle_switch(self):
+ """Test toggle_switch method."""
+ self._instansiate_mvc_components()
+ self.controller.enable_toggles()
+ self._assert_no_guide_info()
+ self.controller._on_guide_toggle({"new": True})
+ self._assert_guide_is_on()
+ self.controller._on_about_toggle({"new": True})
+ self._assert_about_is_on()
+ self.controller._on_guide_toggle({"new": True})
+ self._assert_guide_is_on()
+ self.controller._on_guide_toggle({"new": False})
+ self._assert_no_guide_info()
+
+ def _assert_guide_is_on(self):
+ """Assert guide is on."""
+ assert len(self.view.info_container.children) == 1
+ assert self.view.guide in self.view.info_container.children
+
+ def _assert_about_is_on(self):
+ """Assert about is on."""
+ assert len(self.view.info_container.children) == 1
+ assert self.view.about in self.view.info_container.children
+
+ def _assert_no_guide_info(self):
+ """Assert no info is shown."""
+ assert len(self.view.info_container.children) == 0
+
+ def _instansiate_mvc_components(self):
+ """Instansiate `AppWrapper` MVC components."""
+ self.model = AppWrapperModel()
+ self.view = AppWrapperView()
+ self.controller = AppWrapperContoller(self.model, self.view)