diff --git a/.gitignore b/.gitignore index 740d7b7a3..1b9386c4d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ export/ # Sphinx documentation docs/html screenshots/ + +# User config (used to mark first-time users as existing) +.app-user-config diff --git a/qe.ipynb b/qe.ipynb index 058fd4e6e..6cd55b78f 100644 --- a/qe.ipynb +++ b/qe.ipynb @@ -32,7 +32,7 @@ "source": [ "from aiidalab_widgets_base.utils.loaders import load_css_stylesheet\n", "\n", - "load_css_stylesheet(package=\"aiidalab_qe.app.static.styles.css\")" + "load_css_stylesheet(package=\"aiidalab_qe.app.static.styles\")" ] }, { @@ -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 aiidalab_widgets_base.bug_report import (\n", - " install_create_github_issue_exception_handler,\n", - ")\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__" + "display(view)" ] }, { @@ -71,37 +73,39 @@ "metadata": {}, "outputs": [], "source": [ - "env = Environment()\n", + "import urllib.parse as urlparse\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", + "from aiidalab_widgets_base.bug_report import (\n", + " install_create_github_issue_exception_handler,\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", + "from aiidalab_qe.app.main import App\n", "\n", - "output = ipw.Output()\n", "install_create_github_issue_exception_handler(\n", - " output,\n", + " view.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", + "url = urlparse.urlsplit(jupyter_notebook_url) # noqa F821\n", + "query = urlparse.parse_qs(url.query)\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", - "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/styles/scss/custom.scss b/src/aiidalab_qe/app/static/styles/scss/custom.scss index 876d68a98..64b306d89 100644 --- a/src/aiidalab_qe/app/static/styles/scss/custom.scss +++ b/src/aiidalab_qe/app/static/styles/scss/custom.scss @@ -1,3 +1,47 @@ .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; + + button { + width: 100px; + margin: 1em 0.5em; + + &:focus { + outline: none !important; + } + } +} + +.guide { + ol { + list-style: none; + } + + 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: +

+ +
    +
  1. + 🔍 Step 1: Select the structure you want to run. +
  2. +
  3. + ⚙️ Step 2: Select the properties you are interested in. +
  4. +
  5. + 💻 Step 3: Choose the computational resources you want to run on. +
  6. +
  7. + 🚀 Step 4: Submit your workflow. +
  8. +
+ +

+ 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:

- -
    -
  1. 🔍 Step 1: Select the structure you want to run.
  2. -
  3. ⚙️ Step 2: Select the properties you are interested in.
  4. -
  5. 💻 Step 3: Choose the computational resources you want to run on.
  6. -
  7. 🚀 Step 4: Submit your workflow.
  8. -
- -

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..45def6c01 --- /dev/null +++ b/src/aiidalab_qe/app/wrapper.py @@ -0,0 +1,189 @@ +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 = getattr(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_widgets_base.infobox import InfoBox, FirstTimeUserMessage + + from aiidalab_qe.app.static import templates + 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.first_time_user_message = FirstTimeUserMessage(""" +

+ For new users, + click on the "Guide" button below for tutorials. +

+ """) + + 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, + self.first_time_user_message, + 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""" + + """) + + 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)