diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ccebf3a..20d02e9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -9,14 +9,21 @@ ## Overview -QuantUI is an interactive Jupyter/Voilà interface for running PySCF quantum -chemistry calculations locally — no cluster account, no SLURM, no queueing. Students -design molecules, launch RHF/UHF/DFT calculations in their own Python kernel, and -visualize results in minutes. It is a downstream port of the cluster-focused +QuantUI is an interactive Jupyter/Voilà platform for running PySCF quantum +chemistry workflows end-to-end inside one app: setup, execution, analysis, +visualization, and comparison. It is local-first today (no cluster account, no +SLURM required for normal use), and is designed to evolve toward optional +cluster-backed execution through interactive Jupyter/HPC environments. It is a +downstream port of the cluster-focused `QuantUI` repo with all SLURM infrastructure removed. -**Target audience:** Undergraduate chemistry students at North Carolina Central -University. The UI runs as a Voilà app — students never see code. +**Primary users:** Undergraduate chemistry students and researchers at North Carolina +Central University and collaborators. The UI runs as a Voilà app so users can run +serious quantum chemistry workflows without needing to work directly in code. + +**Strategic direction:** Build an open, powerful, researcher-grade alternative to +closed-source, high-cost GUI quantum chemistry workflows (GaussView-style usability, +with transparent and extensible open tooling). --- @@ -47,11 +54,14 @@ QuantUI/ │ ├── ir_plot.py ← IR spectrum Plotly figure builder │ ├── config.py ← All constants/defaults (methods, basis sets, etc.) │ ├── utils.py ← Session resource checks, sanitize_filename, etc. +│ ├── issue_tracker.py ← In-app issue/bug logging to issues.db +│ ├── log_utils.py ← Shared logging helpers +│ ├── benchmarks.py ← Performance benchmarking utilities │ └── security.py ← SecurityError exception class ├── notebooks/ │ ├── molecule_computations.ipynb ← Student-facing Voilà app (thin launcher) │ └── tutorials/ ← 01–05 step-by-step tutorial notebooks -├── tests/ ← pytest suite (~700 tests) +├── tests/ ← pytest suite (~875 tests) ├── .github/ │ └── copilot-instructions.md ← This file ├── apptainer/ @@ -76,19 +86,25 @@ notebooks/molecule_computations.ipynb │ ▼ quantui/app.py — QuantUIApp - ┌──────────────────────────────────────────────────────────┐ - │ _build_shared_widgets() → StepProgress, run_output │ - │ _build_molecule_section() → mol_input_container │ - │ _build_calc_setup() → method_dd, basis_dd, etc. │ - │ _build_run_section() → run_btn, run_panel │ - │ _build_results_section() → all panel accordions │ - │ _build_history_section() → history_panel │ - │ _build_compare_section() → compare_panel │ - │ _build_output_tab() → log viewer │ - │ _build_help_section() → help panel │ - │ _build_ana_switcher() → Analysis tab button strip │ - │ _assemble_tabs() → root_tab (Tab widget) │ - └──────────────────────────────────────────────────────────┘ + ┌─────────────────────────────────────────────────────────────────────┐ + │ __init__() │ + │ _build_widgets() │ + │ _build_theme_selector() │ + │ _build_status_panel() │ + │ _build_welcome_header() │ + │ _build_shared_widgets() │ + │ _build_molecule_section() │ + │ _build_calc_setup() │ + │ _build_run_section() │ + │ _build_results_section() → Analysis accordions + _build_ana_switcher() │ + │ _build_history_section() │ + │ _build_compare_section() │ + │ _build_output_tab() │ + │ _build_help_section() │ + │ _build_issue_widgets() │ + │ _wire_callbacks() │ + │ _assemble_tabs() → root_tab (7 tabs) │ + └─────────────────────────────────────────────────────────────────────┘ │ _do_run() dispatches by calc_type_dd.value: ▼ ┌──────────────────────────────────────────────────────────────┐ @@ -107,10 +123,12 @@ notebooks/molecule_computations.ipynb └──────────────────────────────────────────────────────────────┘ │ ▼ - results_storage.save_result() calc_log.log_perf_record() + results_storage.save_result() calc_log.log_calculation()/log_event() ``` -**Tab order in the app:** Calculate (0) → History (1) → Compare (2) → Output (3) → Help (4) +**Root-tab order in the app:** Calculate (0) → Results (1) → Analysis (2) → History (3) → Compare (4) → Log (5) → Status (6) + +**Help UI:** Help is a floating overlay panel toggled by `[?]`; it is not a root tab. --- @@ -124,7 +142,8 @@ must understand this pattern.** After a calculation completes (live or history), `_apply_analysis_context(ctx)` is the single entry point for all panel population. It: -1. Calls `_deactivate_all_ana_panels()` to reset all 8 panels to grey/hidden +1. Calls `_deactivate_all_ana_panels()` to reset all 8 panels — collapses accordions + and restores "Not available" placeholders (panels remain in the DOM — never hidden) 2. Looks up `_PANEL_REGISTRY[ctx.calc_type]` for an ordered list of `(panel_name, populate_method_name, auto_select)` tuples 3. Calls each populate method (e.g. `_pop_energies(ctx)`) @@ -155,7 +174,7 @@ class _AnalysisContext: _PANEL_REGISTRY = { "single_point": [("Energies", "_pop_energies", True), ("Isosurface", "_pop_isosurface", False)], "geometry_opt": [("Trajectory", "_pop_geo_trajectory", True), ("Energies", "_pop_energies", False), ...], - "frequency": [("Trajectory", "_pop_preopt_trajectory", False), ("Vibrational", "_pop_vibrational", True), ("IR Spectrum", "_pop_ir_spectrum", False)], + "frequency": [("Vibrational", "_pop_vibrational", True), ("IR Spectrum", "_pop_ir_spectrum", True), ("Trajectory", "_pop_preopt_trajectory", False), ("Energies", "_pop_energies", True)], "tddft": [("UV-Vis", "_pop_uv_vis", True)], "nmr": [("NMR", "_pop_nmr_shielding", True)], "pes_scan": [("PES Scan", "_pop_pes_plot", True), ("Trajectory", "_pop_pes_trajectory", False)], @@ -172,12 +191,13 @@ _PANEL_REGISTRY = { ### Adding a new panel — exactly these steps 1. Create the accordion widget in `_build_results_section()` (follow existing pattern) -2. Add it to the `analysis_tab_panel` VBox in `_build_analysis_section()` -3. Add `(panel_name, accordion, tooltip)` to `_PANEL_META` in `_build_ana_switcher()` -4. Add the matching tooltip to `_deactivate_all_ana_panels()` tooltip list (must mirror `_PANEL_META`) -5. Write `_pop_xxx(self, ctx: _AnalysisContext) -> bool` -6. Add the entry to `_PANEL_REGISTRY` -7. If history replay needs data: ensure `save_spectra` in `_do_run` saves it +2. Add it to the `analysis_tab_panel` VBox in `_build_results_section()` +3. Add `(panel_name, accordion_attr_name, "available after X")` to `_PANEL_META` + (class-level `ClassVar` — this is the single source of truth for placeholder text + and accordion attribute lookup; `_build_ana_switcher()` reads it at init time) +4. Write `_pop_xxx(self, ctx: _AnalysisContext) -> bool` +5. Add the entry to `_PANEL_REGISTRY` +6. If history replay needs data: ensure `save_spectra` in `_do_run` saves it ### Live run vs history replay — same code path @@ -190,7 +210,12 @@ to build the context from disk. ## Analysis Tab — 8 Panels -| Panel | Accordion | Activated by calc types | +All 8 panels are **always in the DOM** (`layout.display=""`, `selected_index=None`). +Unavailable panels show a "Not available — run a X calculation first" placeholder. +`_activate_ana_panel()` swaps the placeholder for real content and expands the accordion. +`_deactivate_all_ana_panels()` restores placeholders and collapses — never hides. + +| Panel | Accordion attr | Activated by calc types | |---|---|---| | Energies | `_orb_accordion` | Single Point, Geometry Opt | | Trajectory | `traj_accordion` | Geometry Opt, PES Scan, Frequency (pre-opt only) | @@ -240,9 +265,9 @@ to build the context from disk. logic to notebook cells. All logic belongs in `quantui/app.py`. 3. **Thread-safe widget updates only.** `_do_run()` runs in a background thread. - Widget updates from threads must use `.value =` assignment, - `.append_stdout()`, or `.append_display_data()`. Never call `display()` inside - `with output_widget:` from a background thread except via `with output_widget:` context. + Widget updates from threads must use `.value =`, `.append_stdout()`, or + `.append_display_data()`. For Plotly, prefer `plotly.io.to_html(...)` + HTML append + rather than direct `display(fig)` in a thread. 4. **No new top-level dependencies** without updating both `pyproject.toml` and the Apptainer container `apptainer/quantui.def`. @@ -260,6 +285,30 @@ to build the context from disk. (`_show_orbital_diagram`, `_show_vib_animation`, `_show_ir_spectrum`, `_show_pes_scan_result`) return `bool` and must not call `_activate_ana_panel()`. +8. **Never use `include_plotlyjs="cdn"` in widget HTML.** CDN requests fail + silently in offline classroom deployments — the figure div stays blank with no + error anywhere. Use `include_plotlyjs="require"` (Voilà/RequireJS path) or + `include_plotlyjs=True` (inline bundle, works everywhere). This rule is enforced + by `tests/test_code_quality.py::test_no_cdn_plotlyjs`. + +9. **All `.observe()` callbacks must be wrapped with `_safe_cb`.** Exceptions in + raw `.observe()` handlers disappear into the kernel console — invisible in Voilà. + Use `widget.observe(self._safe_cb(self._on_x), names="value")` so exceptions are + routed to the Log tab instead. See `_safe_cb()` in `app.py`. + +10. **After significant code changes, run the pre-commit sequence before handoff.** + For meaningful edits in `quantui/`, run: + - `pre-commit run --all-files` + - `python -m pytest tests/ -q --no-cov` + +11. **Proactively recommend PR checkpoints for QuantUI codebase work.** + This applies to `repos-PUBLIC/QuantUI` development (feature, bugfix, and refactor + work), especially when moving into a different roadmap theme/milestone family. + Also recommend a PR after completing each major extraction/refactor phase that + passes validation. + Do not proactively recommend PRs for planning-doc-only updates in + `repos-WRITING/Research-Project-Admin` unless the user explicitly asks. + --- ## Supported Calculations @@ -273,8 +322,8 @@ to build the context from disk. | NMR Shielding | `nmr_calc` | `run_nmr_calc()` | `NMRResult` | `"nmr"` | | PES Scan | `pes_scan` | `run_pes_scan()` | `PESScanResult` | `"pes_scan"` | -**Supported methods:** RHF, UHF, B3LYP, PBE, PBE0, M06-2X, MP2, CCSD, CAM-B3LYP, -M06-L, wB97X-D, PBE-D3 (defined in `config.SUPPORTED_METHODS`). +**Supported methods:** RHF, UHF, B3LYP, PBE, PBE0, M06-2X, wB97X-D, CAM-B3LYP, +M06-L, HSE06, PBE-D3, MP2 (defined in `config.SUPPORTED_METHODS`). **Supported basis sets:** STO-3G, 3-21G, 6-31G, 6-31G\*, 6-31G\*\*, cc-pVDZ, cc-pVTZ, def2-SVP, def2-TZVP (defined in `config.SUPPORTED_BASIS_SETS`). @@ -287,18 +336,22 @@ cc-pVTZ, def2-SVP, def2-TZVP (defined in `config.SUPPORTED_BASIS_SETS`). ``` __init__() - _build_shared_widgets() - _build_molecule_section() - _build_calc_setup() - _build_run_section() # uses self.calc_type_dd from _build_calc_setup - _build_results_section() # builds all panel accordions incl. tddft + nmr - _build_history_section() - _build_compare_section() - _build_output_tab() - _build_help_section() - _build_ana_switcher() # builds 8-panel switcher strip - _assemble_tabs() # builds self.root_tab - _wire_callbacks() # all .observe() and .on_click() wiring + _build_widgets() + _build_theme_selector() + _build_status_panel() + _build_welcome_header() + _build_shared_widgets() + _build_molecule_section() + _build_calc_setup() + _build_run_section() + _build_results_section() # also calls _build_ana_switcher() + _build_history_section() + _build_compare_section() + _build_output_tab() + _build_help_section() + _build_issue_widgets() + _wire_callbacks() # all .observe() and .on_click() wiring + _assemble_tabs() # builds self.root_tab (7 tabs) ``` ### Key instance state — Analysis tab @@ -306,10 +359,12 @@ __init__() | Attribute | Type | Purpose | | --- | --- | --- | | `self._ana_available` | `set[str]` | Names of panels with data; populated by `_activate_ana_panel()` | -| `self._ana_active` | `str` | Currently visible panel name | -| `self._ana_panel_names` | `list[str]` | Ordered panel names matching `_PANEL_META` | -| `self._ana_accordions` | `list[Accordion]` | Ordered accordions matching `_PANEL_META` | -| `self._ana_btns` | `list[Button]` | Ordered switcher buttons matching `_PANEL_META` | +| `self._ana_active` | `str` | Currently expanded panel name | +| `self._ana_panel_names` | `list[str]` | Ordered panel names derived from `_PANEL_META` | +| `self._ana_accordions` | `list[Accordion]` | Ordered accordions derived from `_PANEL_META` | +| `self._ana_unavail_html` | `widgets.HTML` | Shared unavailable-state message area (usually hidden) | +| `self._ana_unavail_msgs` | `dict[str, HTML]` | Panel name → "Not available" placeholder widget | +| `self._ana_content_boxes` | `dict[str, VBox]` | Panel name → real content VBox (hidden until activated) | | `self._pending_traj_result` | `Any` | Trajectory stub; rendered lazily on accordion expand | | `self._last_orb_info` | `Optional[...]` | Orbital info from last successful `_show_orbital_diagram` | | `self._last_ir_freqs` | `list[float]` | IR frequencies stored by `_show_ir_spectrum` | @@ -419,7 +474,7 @@ voila notebooks/molecule_computations.ipynb jupyter lab notebooks/molecule_computations.ipynb # Run tests -python -m pytest --tb=short -q +python -m pytest tests/ -q --no-cov # Install/update package pip install -e ".[dev]" @@ -445,38 +500,50 @@ Test files in `tests/`: | File | What it covers | | --- | --- | -| `test_app.py` | QuantUIApp — widgets, panel registry, callbacks, history replay | +| `test_app.py` | QuantUIApp — widgets, panel registry, callbacks, always-visible panels | +| `test_issue_tracker.py` | Issue logging DB integration and context capture | | `test_molecule.py` | Molecule parsing, validation, formula | | `test_session_calc.py` | `run_in_session()` — PySCF-gated | +| `test_notebook_interactions.py` | Notebook widget interaction flows | | `test_notebook_workflows.py` | End-to-end HF/DFT/preopt/thread-safety — PySCF-gated | | `test_optimizer.py` | `optimize_geometry()` — PySCF + ASE required | | `test_freq_calc.py` | `run_freq_calc()` — PySCF-gated | -| `test_tddft_calc.py` | `run_tddft_calc()` — PySCF-gated | | `test_nmr_calc.py` | `run_nmr_calc()` — PySCF + pyscf.prop required | -| `test_pes_scan.py` | `run_pes_scan()` — PySCF + ASE required | +| `test_pes_scan.py` | `run_pes_scan()` + PES analysis panel | +| `test_ir_plot.py` | `plot_ir_spectrum()` — stick and broadened modes | +| `test_visualization.py` / `test_visualization_integration.py` | 3D and animation rendering paths | +| `test_templates.py` | Export template/script integrity | | `test_comparison.py` | Result comparison tables | | `test_results_storage.py` | Save/load/list round-trip | | `test_security.py` | `SecurityError`, `sanitize_filename()` | +| `test_code_quality.py` | Static analysis — bans CDN plotlyjs and bare `except/pass` | +| `test_sp_analysis_history.py` | Single-point analysis history replay (end-to-end) | +| `test_geo_opt_analysis_history.py` | Geometry-opt analysis history replay | +| `test_tddft_analysis_history.py` | TD-DFT / UV-Vis analysis history replay | +| `test_nmr_analysis_history.py` | NMR analysis history replay | +| `test_freq_analysis_history.py` | Frequency analysis history replay (Vibrational + IR) | +| `test_pes_scan_analysis_history.py` | PES scan analysis history replay | **PySCF-gated tests** use `@pytest.mark.skipif(not _PYSCF_AVAILABLE, ...)`. On Windows, these become skips — not failures. -**Baseline (WSL, 2026-04-27):** 699 passed, 15 skipped, 10 failed (all pre-existing: -7 NMR pyscf.prop, 1 orbital viz, 1 PES scan, 1 freq thermo). +**Baseline (WSL, 2026-05-01; `python -m pytest tests/ -q --no-cov`):** +860 passed, 15 skipped (875 collected). --- -## Optional Dependencies +## Dependency Groups -| Extra | Packages | Gated by | +| Group | Packages | Notes | | --- | --- | --- | -| `pyscf` | `pyscf>=2.3.0` | `_PYSCF_AVAILABLE` flag | -| `ase` | `ase>=3.22.0` | `ASE_AVAILABLE` flag | -| `plotly` | `plotly` | checked inline at render time | -| `plotlymol` | `plotlymol3d` (local) | checked inline at render time | -| `app` | `voila, jupyterlab` | Always present in the conda env | - -Install all: `pip install -e ".[pyscf,ase,app,dev]"` +| Core (always installed) | `jupyter`, `ipywidgets`, `notebook`, `numpy`, `matplotlib`, `plotly`, `plotlymol`, `py3Dmol` | Base UI + visualization stack | +| `pyscf` extra | `pyscf>=2.13.0`, `pyscf-properties` | Enables SCF/frequency/TDDFT/NMR/PES backends | +| `ase` extra | `ase>=3.22.0` | Geometry optimization and structure I/O | +| `app` extra | `voila>=0.5.0`, `ipykernel>=6.0.0` | Student-facing Voilà deployment | +| `notebook` extra | `nbmake>=1.4.0`, `ipykernel>=6.0.0` | Notebook smoke-testing support | +| `dev` extra | `pytest`, `pytest-cov`, `pytest-mock`, `mypy`, `ruff`, `black`, `pre-commit` | Development tooling | + +Install all runtime + dev extras: `pip install -e ".[pyscf,ase,app,notebook,dev]"` --- diff --git a/quantui/app.py b/quantui/app.py index cbc57df..f6aafd6 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -214,6 +214,24 @@ def _load_last_calibration_label() -> str: } """ +_LAYOUT_TRAITS: frozenset[str] = frozenset(widgets.Layout.class_trait_names()) + + +def _layout(**kwargs: Any) -> widgets.Layout: + """Create a Layout while dropping unsupported kwargs to avoid traitlets noise.""" + normalized = dict(kwargs) + if "overflow_y" in normalized and "overflow" not in normalized: + normalized["overflow"] = normalized["overflow_y"] + normalized.pop("overflow_y", None) + if "gap" in normalized and "grid_gap" not in normalized: + normalized["grid_gap"] = normalized["gap"] + normalized.pop("gap", None) + if "flex_wrap" in normalized and "flex_flow" not in normalized: + normalized["flex_flow"] = f"row {normalized['flex_wrap']}" + normalized.pop("flex_wrap", None) + filtered = {k: v for k, v in normalized.items() if k in _LAYOUT_TRAITS} + return widgets.Layout(**filtered) + # ── SCF regex (module-level so _LogCapture can use them) ───────────────────── _RE_CYCLE = re.compile( @@ -347,9 +365,7 @@ def display(self) -> None: self._issue_btn, self._exit_btn, ], - layout=widgets.Layout( - justify_content="flex-end", margin="0 0 4px" - ), + layout=_layout(justify_content="flex-end", margin="0 0 4px"), ), self._issue_overlay, self._exit_output, @@ -386,16 +402,14 @@ def _build_widgets(self) -> None: def _build_theme_selector(self) -> None: self._theme_style = widgets.Output( - layout=widgets.Layout( - height="0px", overflow="hidden", margin="0", padding="0" - ) + layout=_layout(height="0px", overflow="hidden", margin="0", padding="0") ) self.theme_btn = widgets.ToggleButtons( options=["Light", "Dark"], value="Dark", description="Theme:", style={"description_width": "48px", "button_width": "90px"}, - layout=widgets.Layout(margin="0"), + layout=_layout(margin="0"), ) # Apply Dark theme immediately with self._theme_style: @@ -497,7 +511,7 @@ def _ok(flag: bool, extra: str = "") -> str: self._status_tab_panel = widgets.VBox( [self._status_html, _guide_html], - layout=widgets.Layout(padding="8px 0"), + layout=_layout(padding="8px 0"), ) # ── Welcome header ──────────────────────────────────────────────────── @@ -570,9 +584,9 @@ def _build_shared_widgets(self) -> None: value='No molecule loaded yet.' ) self.mol_summary_compact = widgets.HTML(value="") - self.viz_output = widgets.Output(layout=widgets.Layout(min_height="50px")) + self.viz_output = widgets.Output(layout=_layout(min_height="50px")) self.run_output = widgets.Output( - layout=widgets.Layout( + layout=_layout( border="1px solid #c0ccd8", min_height="80px", max_height="400px", @@ -601,7 +615,7 @@ def _build_shared_widgets(self) -> None: value=_DEFAULT_VIZ_BACKEND, tooltips=["Plotly-based interactive viewer", "WebGL viewer (py3Dmol)"], style={"button_width": "90px"}, - layout=widgets.Layout(margin="2px 0 0 0"), + layout=_layout(margin="2px 0 0 0"), ) else: self.viz_backend_toggle = None # type: ignore[assignment] @@ -614,7 +628,7 @@ def _build_shared_widgets(self) -> None: value=_DEFAULT_VIZ_STYLE, description="Style:", style={"description_width": "40px"}, - layout=widgets.Layout(width="180px"), + layout=_layout(width="180px"), disabled=not VISUALIZATION_AVAILABLE, ) # Lighting only applies to the PlotlyMol backend @@ -624,14 +638,14 @@ def _build_shared_widgets(self) -> None: value=_DEFAULT_LIGHTING, description="Lighting:", style={"description_width": "58px"}, - layout=widgets.Layout(width="170px"), + layout=_layout(width="170px"), disabled=not _lighting_available, ) if not _lighting_available: self.viz_lighting_dd.layout.visibility = "hidden" self.viz_controls_box = widgets.HBox( [self.viz_style_dd, self.viz_lighting_dd], - layout=widgets.Layout(gap="8px", margin="2px 0 0 0", align_items="center"), + layout=_layout(gap="8px", margin="2px 0 0 0", align_items="center"), ) self.notes_output = widgets.Output() self.perf_estimate_html = widgets.HTML() @@ -648,14 +662,14 @@ def _build_shared_widgets(self) -> None: value=DEFAULT_METHOD, description="Method:", style={"description_width": "100px"}, - layout=widgets.Layout(width="260px"), + layout=_layout(width="260px"), ) self.basis_dd = widgets.Dropdown( options=SUPPORTED_BASIS_SETS, value=DEFAULT_BASIS, description="Basis Set:", style={"description_width": "100px"}, - layout=widgets.Layout(width="260px"), + layout=_layout(width="260px"), ) self.charge_si = widgets.BoundedIntText( value=DEFAULT_CHARGE, @@ -663,7 +677,7 @@ def _build_shared_widgets(self) -> None: max=10, description="Charge:", style={"description_width": "100px"}, - layout=widgets.Layout(width="190px"), + layout=_layout(width="190px"), ) self.mult_si = widgets.BoundedIntText( value=DEFAULT_MULTIPLICITY, @@ -671,13 +685,13 @@ def _build_shared_widgets(self) -> None: max=10, description="Multiplicity:", style={"description_width": "100px"}, - layout=widgets.Layout(width="190px"), + layout=_layout(width="190px"), ) self.preopt_cb = widgets.Checkbox( value=False, - description="Pre-optimize geometry (fast LJ force-field)", + description="Pre-optimize geometry (for a crude starting point)", disabled=not _PREOPT_AVAILABLE, - layout=widgets.Layout(width="400px"), + layout=_layout(width="400px"), ) # Implicit solvent (PCM) @@ -686,14 +700,14 @@ def _build_shared_widgets(self) -> None: self.solvent_cb = widgets.Checkbox( value=False, description="Implicit solvent (PCM)", - layout=widgets.Layout(width="240px"), + layout=_layout(width="240px"), ) self.solvent_dd = widgets.Dropdown( options=list(_SOLVENT_OPTS.keys()), value="Water", description="Solvent:", style={"description_width": "70px"}, - layout=widgets.Layout(width="200px", display="none"), + layout=_layout(width="200px", display="none"), ) # Calculation type + extra options @@ -709,7 +723,7 @@ def _build_shared_widgets(self) -> None: value="Single Point", description="Calc. Type:", style={"description_width": "100px"}, - layout=widgets.Layout(width="310px"), + layout=_layout(width="310px"), ) self.fmax_fi = widgets.BoundedFloatText( value=DEFAULT_FMAX, @@ -718,7 +732,7 @@ def _build_shared_widgets(self) -> None: step=0.005, description="Force thr. (eV/Å):", style={"description_width": "130px"}, - layout=widgets.Layout(width="270px"), + layout=_layout(width="270px"), ) self.max_steps_si = widgets.BoundedIntText( value=DEFAULT_OPT_STEPS, @@ -726,7 +740,7 @@ def _build_shared_widgets(self) -> None: max=1000, description="Max steps:", style={"description_width": "100px"}, - layout=widgets.Layout(width="200px"), + layout=_layout(width="200px"), ) self.nstates_si = widgets.BoundedIntText( value=10, @@ -734,7 +748,7 @@ def _build_shared_widgets(self) -> None: max=50, description="# states:", style={"description_width": "100px"}, - layout=widgets.Layout(width="180px"), + layout=_layout(width="180px"), ) # ── Frequency calc extra widgets ────────────────────────────────────── @@ -742,20 +756,20 @@ def _build_shared_widgets(self) -> None: options=[("(use current molecule)", "")], description="Seed geometry:", style={"description_width": "110px"}, - layout=widgets.Layout(width="420px"), + layout=_layout(width="420px"), tooltip="Optionally load the final optimised geometry from a previous Geo Opt result", ) self._freq_seed_refresh_btn = widgets.Button( description="", icon="refresh", - layout=widgets.Layout(width="32px", height="32px"), + layout=_layout(width="32px", height="32px"), tooltip="Refresh the list of saved geometry optimisations", ) self._freq_preopt_cb = widgets.Checkbox( value=False, - description="Pre-optimize geometry first (recommended for unoptimised inputs)", + description="Geometry optimization (recommended for unoptimized inputs)", style={"description_width": "initial"}, - layout=widgets.Layout(width="100%"), + layout=_layout(width="100%"), ) self._freq_seed_note = widgets.HTML("") @@ -765,9 +779,9 @@ def _build_shared_widgets(self) -> None: value="Bond", description="Scan type:", style={"description_width": "80px"}, - layout=widgets.Layout(width="220px"), + layout=_layout(width="220px"), ) - _atom_idx_layout = widgets.Layout(width="95px") + _atom_idx_layout = _layout(width="95px") _atom_idx_style = {"description_width": "50px"} self._scan_atom1 = widgets.BoundedIntText( value=1, @@ -803,7 +817,7 @@ def _build_shared_widgets(self) -> None: ) self._scan_atom34_box = widgets.HBox( [self._scan_atom3, self._scan_atom4], - layout=widgets.Layout(gap="4px"), + layout=_layout(gap="4px"), ) self._scan_start = widgets.BoundedFloatText( value=0.5, @@ -812,7 +826,7 @@ def _build_shared_widgets(self) -> None: step=0.1, description="Start:", style={"description_width": "40px"}, - layout=widgets.Layout(width="140px"), + layout=_layout(width="140px"), ) self._scan_stop = widgets.BoundedFloatText( value=2.0, @@ -821,7 +835,7 @@ def _build_shared_widgets(self) -> None: step=0.1, description="Stop:", style={"description_width": "40px"}, - layout=widgets.Layout(width="140px"), + layout=_layout(width="140px"), ) self._scan_steps = widgets.BoundedIntText( value=10, @@ -829,7 +843,7 @@ def _build_shared_widgets(self) -> None: max=100, description="Points:", style={"description_width": "50px"}, - layout=widgets.Layout(width="120px"), + layout=_layout(width="120px"), ) self._scan_unit_lbl = widgets.HTML( 'Å' @@ -841,13 +855,13 @@ def _build_shared_widgets(self) -> None: self.method_help_btn = widgets.Button( description="?", button_style="", - layout=widgets.Layout(width="28px", height="28px"), + layout=_layout(width="28px", height="28px"), tooltip="RHF vs UHF — opens Help tab", ) self.basis_help_btn = widgets.Button( description="?", button_style="", - layout=widgets.Layout(width="28px", height="28px"), + layout=_layout(width="28px", height="28px"), tooltip="Choosing a basis set — opens Help tab", ) @@ -857,7 +871,7 @@ def _build_shared_widgets(self) -> None: button_style="success", icon="play", disabled=True, - layout=widgets.Layout(width="200px", height="36px"), + layout=_layout(width="200px", height="36px"), ) self.run_status = widgets.Label() @@ -866,7 +880,7 @@ def _build_shared_widgets(self) -> None: description="Clear", button_style="", icon="times", - layout=widgets.Layout(width="90px", height="26px"), + layout=_layout(width="90px", height="26px"), tooltip="Clear calculation output", ) @@ -876,20 +890,20 @@ def _build_shared_widgets(self) -> None: button_style="info", icon="plus", disabled=True, - layout=widgets.Layout(width="190px"), + layout=_layout(width="190px"), ) self.clear_btn = widgets.Button( description="Clear", button_style="warning", icon="trash", - layout=widgets.Layout(width="100px"), + layout=_layout(width="100px"), ) self.export_btn = widgets.Button( description="Export Script", button_style="", icon="download", disabled=True, - layout=widgets.Layout(width="160px"), + layout=_layout(width="160px"), ) self.export_status = widgets.Label() _rdkit_tip = ( @@ -901,21 +915,21 @@ def _build_shared_widgets(self) -> None: description="Export XYZ", icon="download", disabled=True, - layout=widgets.Layout(width="130px"), + layout=_layout(width="130px"), ) self.export_mol_btn = widgets.Button( description="Export MOL", icon="download", disabled=True, tooltip=_rdkit_tip, - layout=widgets.Layout(width="130px"), + layout=_layout(width="130px"), ) self.export_pdb_btn = widgets.Button( description="Export PDB", icon="download", disabled=True, tooltip=_rdkit_tip, - layout=widgets.Layout(width="130px"), + layout=_layout(width="130px"), ) self.struct_export_status = widgets.Label() @@ -929,7 +943,7 @@ def _build_molecule_section(self) -> None: value="(select a molecule)", description="Molecule:", style={"description_width": "90px"}, - layout=widgets.Layout(width="320px"), + layout=_layout(width="320px"), ) # XYZ input @@ -940,7 +954,7 @@ def _build_molecule_section(self) -> None: "H 0.757 0.587 0.000\n" "H -0.757 0.587 0.000" ), - layout=widgets.Layout(width="440px", height="130px"), + layout=_layout(width="440px", height="130px"), ) self.xyz_btn = widgets.Button( description="Load XYZ", button_style="info", icon="upload" @@ -950,14 +964,14 @@ def _build_molecule_section(self) -> None: # PubChem search self.pubchem_txt = widgets.Text( placeholder="name or SMILES (e.g. aspirin, caffeine, CC(=O)O)", - layout=widgets.Layout(width="380px"), + layout=_layout(width="380px"), ) self.pubchem_btn = widgets.Button( description="Search", button_style="info", icon="search", disabled=not PUBCHEM_AVAILABLE, - layout=widgets.Layout(width="100px"), + layout=_layout(width="100px"), ) self.pubchem_msg = widgets.Label( value=( @@ -1012,12 +1026,12 @@ def _build_molecule_section(self) -> None: description="Change", button_style="", icon="pencil", - layout=widgets.Layout(width="100px", height="32px"), + layout=_layout(width="100px", height="32px"), tooltip="Re-expand the molecule input panel", ) self.mol_input_collapsed = widgets.HBox( [self.mol_summary_compact, self.change_mol_btn], - layout=widgets.Layout(align_items="center", gap="12px", padding="6px 0"), + layout=_layout(align_items="center", gap="12px", padding="6px 0"), ) _mol_container_children = [ self.mol_input_expanded, @@ -1030,7 +1044,7 @@ def _build_molecule_section(self) -> None: _mol_container_children.append(self.viz_controls_box) self.mol_input_container = widgets.VBox( _mol_container_children, - layout=widgets.Layout(margin="0 0 4px 0"), + layout=_layout(margin="0 0 4px 0"), ) # ── Calculation setup panel (Cell 5) ────────────────────────────────── @@ -1045,15 +1059,11 @@ def _build_calc_setup(self) -> None: [ widgets.HBox( [self.method_dd, self.method_help_btn], - layout=widgets.Layout( - align_items="center", gap="4px" - ), + layout=_layout(align_items="center", gap="4px"), ), widgets.HBox( [self.basis_dd, self.basis_help_btn], - layout=widgets.Layout( - align_items="center", gap="4px" - ), + layout=_layout(align_items="center", gap="4px"), ), ] ), @@ -1066,7 +1076,7 @@ def _build_calc_setup(self) -> None: self.preopt_cb, widgets.HBox( [self.solvent_cb, self.solvent_dd], - layout=widgets.Layout(align_items="center", gap="4px"), + layout=_layout(align_items="center", gap="4px"), ), self.notes_output, ] @@ -1093,7 +1103,7 @@ def _build_run_section(self) -> None: ), self.log_clear_btn, ], - layout=widgets.Layout( + layout=_layout( align_items="center", justify_content="space-between", margin="10px 0 4px", @@ -1108,17 +1118,15 @@ def _build_run_section(self) -> None: def _build_results_section(self) -> None: # PES scan energy plot accordion (hidden until a PES Scan completes) - self._pes_plot_html = widgets.HTML( - value="", layout=widgets.Layout(width="100%") - ) + self._pes_plot_html = widgets.Output(layout=_layout(width="100%")) self._pes_scan_accordion = widgets.Accordion( children=[ widgets.VBox( [self._pes_plot_html], - layout=widgets.Layout(padding="8px"), + layout=_layout(padding="8px"), ) ], - layout=widgets.Layout(display="none", margin="8px 0"), + layout=_layout(display="none", margin="8px 0"), ) self._pes_scan_accordion.set_title(0, "PES Energy Profile") self._pes_scan_accordion.selected_index = None @@ -1127,28 +1135,30 @@ def _build_results_section(self) -> None: self.traj_output = widgets.Output() self.traj_accordion = widgets.Accordion( children=[self.traj_output], - layout=widgets.Layout(display="none", margin="8px 0"), + layout=_layout(display="none", margin="8px 0"), ) self.traj_accordion.set_title(0, "Trajectory Viewer") self.traj_accordion.selected_index = None # collapsed by default - self.traj_accordion.observe(self._on_traj_expand, names=["selected_index"]) + self.traj_accordion.observe( + self._safe_cb(self._on_traj_expand), names=["selected_index"] + ) # Vibrational animation accordion (Frequency only — hidden until Freq completes) self.vib_mode_dd = widgets.Dropdown( description="Mode:", options=[], style={"description_width": "50px"}, - layout=widgets.Layout(width="360px"), + layout=_layout(width="360px"), ) self.vib_output = widgets.Output() self.vib_accordion = widgets.Accordion( children=[ widgets.VBox( [self.vib_mode_dd, self.vib_output], - layout=widgets.Layout(padding="8px"), + layout=_layout(padding="8px"), ) ], - layout=widgets.Layout(display="none", margin="8px 0"), + layout=_layout(display="none", margin="8px 0"), ) self.vib_accordion.set_title(0, "Vibrational Mode Viewer") self.vib_accordion.selected_index = None # collapsed by default @@ -1157,8 +1167,8 @@ def _build_results_section(self) -> None: self._ir_mode_toggle = widgets.ToggleButtons( options=["Stick", "Broadened"], value="Stick", - style={"button_width": "80px", "font_size": "12px"}, - layout=widgets.Layout(margin="0 8px 0 0"), + style={"button_width": "80px"}, + layout=_layout(margin="0 8px 0 0"), ) self._ir_fwhm_slider = widgets.FloatSlider( value=20.0, @@ -1167,23 +1177,23 @@ def _build_results_section(self) -> None: step=5.0, description="Line width:", style={"description_width": "80px"}, - layout=widgets.Layout(width="260px", display="none"), + layout=_layout(width="260px", display="none"), ) - self._ir_fig = widgets.HTML(value="", layout=widgets.Layout(width="100%")) + self._ir_fig = widgets.Output(layout=_layout(width="100%")) _ir_controls = widgets.HBox( [self._ir_mode_toggle, self._ir_fwhm_slider], - layout=widgets.Layout(align_items="center", margin="0 0 6px 0"), + layout=_layout(align_items="center", margin="0 0 6px 0"), ) _ir_body_children = [_ir_controls, self._ir_fig] self._ir_accordion = widgets.Accordion( children=[ widgets.VBox( _ir_body_children, - layout=widgets.Layout(padding="8px"), + layout=_layout(padding="8px"), ) ], - layout=widgets.Layout(display="none", margin="8px 0"), + layout=_layout(display="none", margin="8px 0"), ) self._ir_accordion.set_title(0, "IR Spectrum") self._ir_accordion.selected_index = None @@ -1197,7 +1207,7 @@ def _build_results_section(self) -> None: max=200.0, step=1.0, description="Y min:", - layout=widgets.Layout(width="140px"), + layout=_layout(width="140px"), style={"description_width": "45px"}, ) self._orb_ymax_input = widgets.BoundedFloatText( @@ -1206,7 +1216,7 @@ def _build_results_section(self) -> None: max=500.0, step=1.0, description="Y max:", - layout=widgets.Layout(width="140px"), + layout=_layout(width="140px"), style={"description_width": "45px"}, ) self._orb_n_orb_input = widgets.BoundedIntText( @@ -1215,7 +1225,7 @@ def _build_results_section(self) -> None: max=200, step=2, description="Show N:", - layout=widgets.Layout(width="120px"), + layout=_layout(width="120px"), style={"description_width": "50px"}, ) _orb_controls_row = widgets.HBox( @@ -1231,26 +1241,24 @@ def _build_results_section(self) -> None: ), self._orb_n_orb_input, ], - layout=widgets.Layout( + layout=_layout( align_items="center", flex_wrap="wrap", gap="4px", margin="0 0 6px 0", ), ) - self._orb_diagram_html = widgets.HTML( - value="", layout=widgets.Layout(width="100%") - ) + self._orb_diagram_html = widgets.Output(layout=_layout(width="100%")) _orb_diagram_content: list = [_orb_controls_row, self._orb_diagram_html] self._orb_diagram_box = widgets.VBox( _orb_diagram_content, - layout=widgets.Layout(width="100%"), + layout=_layout(width="100%"), ) self._orb_toggle = widgets.ToggleButtons( options=["HOMO-1", "HOMO", "LUMO", "LUMO+1"], value="HOMO", - style={"button_width": "70px", "font_size": "12px"}, - layout=widgets.Layout(margin="8px 0 4px 0"), + style={"button_width": "70px"}, + layout=_layout(margin="8px 0 4px 0"), ) self._orb_iso_output = widgets.Output() self._orb_iso_controls = widgets.VBox( @@ -1262,16 +1270,16 @@ def _build_results_section(self) -> None: self._orb_toggle, self._orb_iso_output, ], - layout=widgets.Layout(display="none", margin="8px 0 0 0"), + layout=_layout(display="none", margin="8px 0 0 0"), ) self._orb_accordion = widgets.Accordion( children=[ widgets.VBox( [self._orb_diagram_box], - layout=widgets.Layout(padding="8px"), + layout=_layout(padding="8px"), ) ], - layout=widgets.Layout(display="none", margin="8px 0"), + layout=_layout(display="none", margin="8px 0"), ) self._orb_accordion.set_title(0, "Orbital Diagram") self._orb_accordion.selected_index = None @@ -1286,7 +1294,7 @@ def _build_results_section(self) -> None: "Generate a 3D orbital isosurface. " "Available after running or loading a Single Point or Geometry Optimization." ), - layout=widgets.Layout(width="200px", margin="8px 0 4px 0"), + layout=_layout(width="200px", margin="8px 0 4px 0"), ) _iso_body = widgets.VBox( [ @@ -1299,39 +1307,39 @@ def _build_results_section(self) -> None: self._orb_iso_controls, self._iso_generate_btn, ], - layout=widgets.Layout(padding="8px"), + layout=_layout(padding="8px"), ) self._iso_accordion = widgets.Accordion( children=[_iso_body], - layout=widgets.Layout(display="none", margin="8px 0"), + layout=_layout(display="none", margin="8px 0"), ) self._iso_accordion.set_title(0, "Orbital Isosurface") self._iso_accordion.selected_index = None # ── UV-Vis spectrum accordion (TD-DFT only — hidden until result) ── - self._tddft_fig = widgets.HTML(value="", layout=widgets.Layout(width="100%")) + self._tddft_fig = widgets.Output(layout=_layout(width="100%")) self._tddft_accordion = widgets.Accordion( children=[ widgets.VBox( [self._tddft_fig], - layout=widgets.Layout(padding="8px"), + layout=_layout(padding="8px"), ) ], - layout=widgets.Layout(display="none", margin="8px 0"), + layout=_layout(display="none", margin="8px 0"), ) self._tddft_accordion.set_title(0, "UV-Vis Absorption Spectrum") self._tddft_accordion.selected_index = None # ── NMR shielding accordion (NMR only — hidden until result) ──────── - self._nmr_output = widgets.HTML(value="", layout=widgets.Layout(width="100%")) + self._nmr_output = widgets.HTML(value="", layout=_layout(width="100%")) self._nmr_accordion = widgets.Accordion( children=[ widgets.VBox( [self._nmr_output], - layout=widgets.Layout(padding="8px"), + layout=_layout(padding="8px"), ) ], - layout=widgets.Layout(display="none", margin="8px 0"), + layout=_layout(display="none", margin="8px 0"), ) self._nmr_accordion.set_title(0, "NMR Chemical Shifts") self._nmr_accordion.selected_index = None @@ -1339,14 +1347,14 @@ def _build_results_section(self) -> None: # ── Result directory path label (hidden until a calculation saves) ── self._result_dir_label = widgets.HTML( value="", - layout=widgets.Layout(display="none", margin="4px 0 0 0"), + layout=_layout(display="none", margin="4px 0 0 0"), ) # ── Full output log accordion (hidden until a calculation saves) ──── self._result_log_output = widgets.Output() self._result_log_accordion = widgets.Accordion( children=[self._result_log_output], - layout=widgets.Layout(display="none", margin="8px 0 0 0"), + layout=_layout(display="none", margin="8px 0 0 0"), ) self._result_log_accordion.set_title(0, "Full output log (pyscf.log)") self._result_log_accordion.selected_index = None @@ -1355,12 +1363,12 @@ def _build_results_section(self) -> None: self._go_results_btn = widgets.Button( description="→ View Results", button_style="success", - layout=widgets.Layout(width="130px"), + layout=_layout(width="130px"), ) self._go_analysis_btn = widgets.Button( description="→ View Analysis", button_style="info", - layout=widgets.Layout(width="140px"), + layout=_layout(width="140px"), ) self._completion_mol_lbl = widgets.HTML(value="") self._completion_banner = widgets.HBox( @@ -1373,7 +1381,7 @@ def _build_results_section(self) -> None: self._go_results_btn, self._go_analysis_btn, ], - layout=widgets.Layout( + layout=_layout( display="none", align_items="center", gap="8px", @@ -1390,13 +1398,13 @@ def _build_results_section(self) -> None: description="→ View Analysis", button_style="", icon="bar-chart", - layout=widgets.Layout(display="none", width="160px", margin="8px 0 0 0"), + layout=_layout(display="none", width="160px", margin="8px 0 0 0"), ) # Label above the 3D viewer — updated by _do_run to say "Optimized geometry" # for Geometry Opt, or hidden for other calc types that don't change geometry. self._viz_label = widgets.HTML( value="", - layout=widgets.Layout(display="none"), + layout=_layout(display="none"), ) self.results_tab_panel = widgets.VBox( [ @@ -1409,7 +1417,7 @@ def _build_results_section(self) -> None: # _build_compare_section — must run before it can be referenced here) self._to_analysis_btn, ], - layout=widgets.Layout(padding="8px 0"), + layout=_layout(padding="8px 0"), ) # Backward-compat alias — existing methods that reference results_panel still work self.results_panel = self.results_tab_panel @@ -1431,7 +1439,7 @@ def _build_results_section(self) -> None: "Run a Single Point, Geo Opt, or Frequency calculation to see " "orbital diagrams, trajectory animations, and spectra here.
" ), - layout=widgets.Layout(display="none"), + layout=_layout(display="none"), ) self._build_ana_switcher() self.analysis_tab_panel = widgets.VBox( @@ -1439,7 +1447,6 @@ def _build_results_section(self) -> None: self._analysis_context_lbl, self._analysis_mol_output, self._analysis_empty_html, - self._ana_switcher_box, self._ana_unavail_html, self._orb_accordion, self._pes_scan_accordion, @@ -1450,7 +1457,7 @@ def _build_results_section(self) -> None: self._tddft_accordion, self._nmr_accordion, ], - layout=widgets.Layout(padding="8px 0"), + layout=_layout(padding="8px 0"), ) # Backward-compat alias for post_calc_panel references in tests self.post_calc_panel = self.analysis_tab_panel @@ -1458,134 +1465,96 @@ def _build_results_section(self) -> None: # ── Analysis panel switcher ─────────────────────────────────────────── def _build_ana_switcher(self) -> None: - """Build the always-visible panel switcher strip for the Analysis tab.""" - _PANEL_META = [ - ("Energies", self._orb_accordion, "Single Point / Geometry Opt"), - ( - "Trajectory", - self.traj_accordion, - "Geometry Opt / PES Scan / Frequency pre-opt", - ), - ("Vibrational", self.vib_accordion, "Frequency"), - ("IR Spectrum", self._ir_accordion, "Frequency"), - ("PES Scan", self._pes_scan_accordion, "PES Scan"), - ("Isosurface", self._iso_accordion, "Single Point (Linux/WSL only)"), - ("UV-Vis", self._tddft_accordion, "UV-Vis (TD-DFT)"), - ("NMR", self._nmr_accordion, "NMR Shielding"), + """Initialise analysis panel state; wire accordion re-render observers.""" + panel_meta = [ + (name, getattr(self, attr), when) for name, attr, when in self._PANEL_META ] - self._ana_panel_names: list = [m[0] for m in _PANEL_META] - self._ana_accordions: list = [m[1] for m in _PANEL_META] + self._ana_panel_names: list = [m[0] for m in panel_meta] + self._ana_accordions: list = [m[1] for m in panel_meta] self._ana_available: set = set() self._ana_active: str = "" self._ana_unavail_html = widgets.HTML( value="", - layout=widgets.Layout(display="none", margin="4px 0 8px"), - ) - self._ana_btns: list = [] - for name, _acc, when in _PANEL_META: - btn = widgets.Button( - description=name, - button_style="", - tooltip=f"Available after: {when}", - layout=widgets.Layout(margin="0"), + layout=_layout(display="none", margin="4px 0 8px"), + ) + + # Wrap each accordion's child so it holds both an "unavailable" message + # and the real content. Real content starts hidden; the unavailable + # message is shown until _activate_ana_panel() is called. + self._ana_unavail_msgs: dict = {} + self._ana_content_boxes: dict = {} + for name, acc, when in panel_meta: + unavail = widgets.HTML( + value=( + f'' - f"{name} is not available for this calculation type.
" + def _on_ir_accordion_show(self, change) -> None: + if change["new"] == 0 and getattr(self, "_last_ir_freqs", None): + self._update_ir_figure( + self._ir_mode_toggle.value, self._ir_fwhm_slider.value ) - self._ana_unavail_html.layout.display = "" - self._ana_active = "" + + def _on_orb_accordion_show(self, change) -> None: + if change["new"] == 0 and getattr(self, "_last_orb_info", None) is not None: + self._on_orb_range_changed() def _select_ana_panel(self, name: str) -> None: - """Show the named panel; hide all others and update button styles.""" + """Expand the named panel and collapse all others.""" self._ana_active = name self._ana_unavail_html.layout.display = "none" - for pname, acc, btn in zip( - self._ana_panel_names, self._ana_accordions, self._ana_btns - ): - if pname == name: - acc.layout.display = "" - acc.selected_index = 0 - btn.button_style = "primary" - else: - acc.layout.display = "none" - btn.button_style = "" - # Plotly charts inside hidden accordions render with 0 dimensions and appear - # blank. Re-render the IR figure whenever its panel is brought into view so - # the chart always paints into a visible, correctly-sized container. - if name == "IR Spectrum" and getattr(self, "_last_ir_freqs", None): - self._update_ir_figure( - self._ir_mode_toggle.value, self._ir_fwhm_slider.value - ) + for pname, acc in zip(self._ana_panel_names, self._ana_accordions): + acc.selected_index = 0 if pname == name else None def _activate_ana_panel(self, name: str, auto_select: bool = True) -> None: - """Mark a panel as available (full opacity) and optionally select it.""" + """Mark a panel as available: reveal its content.""" self._ana_available.add(name) - for btn, pname in zip(self._ana_btns, self._ana_panel_names): - if pname == name: - btn.layout.opacity = "1.0" - btn.tooltip = name + # Swap unavailable placeholder for real content. + if name in self._ana_unavail_msgs: + self._ana_unavail_msgs[name].layout.display = "none" + self._ana_content_boxes[name].layout.display = "" if auto_select: self._select_ana_panel(name) def _deactivate_all_ana_panels(self) -> None: - """Reset all panels to hidden/unavailable; used at start of each new run.""" + """Reset all panels to collapsed/unavailable; used at start of each new run.""" self._ana_available.clear() self._ana_active = "" self._ana_unavail_html.layout.display = "none" - for acc, btn, _name, meta in zip( - self._ana_accordions, - self._ana_btns, - self._ana_panel_names, - # Re-read tooltips from scratch — must mirror _PANEL_META order - [ - "Single Point / Geometry Opt", - "Geometry Opt / PES Scan / Frequency pre-opt", - "Frequency", - "Frequency", - "PES Scan", - "Single Point (Linux/WSL only)", - "UV-Vis (TD-DFT)", - "NMR Shielding", - ], - ): - acc.layout.display = "none" + for name, acc in zip(self._ana_panel_names, self._ana_accordions): + # Show the "not available" placeholder; hide real content. + if name in self._ana_unavail_msgs: + self._ana_unavail_msgs[name].layout.display = "" + self._ana_content_boxes[name].layout.display = "none" acc.selected_index = None - btn.button_style = "" - btn.layout.opacity = "0.35" - btn.tooltip = f"Available after: {meta}" # ── Panel registry and unified applier ─────────────────────────────────── # - # _PANEL_REGISTRY maps calc_type string → ordered list of + # _PANEL_META: ordered list of (name, accordion_attr, when_str) for every + # analysis panel. Single source of truth for names, accordion references, + # and the "available after: …" tooltip text. + # + # _PANEL_REGISTRY maps calc_type → ordered list of # (panel_name, populate_method_name, auto_select) tuples. # # Rules: @@ -1596,6 +1565,17 @@ def _deactivate_all_ana_panels(self) -> None: # • If a populate method returns False / None the panel stays disabled. # • Populate methods must NOT call _activate_ana_panel themselves. + _PANEL_META: ClassVar[list] = [ + ("Energies", "_orb_accordion", "Single Point / Geometry Opt"), + ("Trajectory", "traj_accordion", "Geometry Opt / PES Scan / Frequency pre-opt"), + ("Vibrational", "vib_accordion", "Frequency"), + ("IR Spectrum", "_ir_accordion", "Frequency"), + ("PES Scan", "_pes_scan_accordion", "PES Scan"), + ("Isosurface", "_iso_accordion", "Single Point (Linux/WSL only)"), + ("UV-Vis", "_tddft_accordion", "UV-Vis (TD-DFT)"), + ("NMR", "_nmr_accordion", "NMR Shielding"), + ] + _PANEL_REGISTRY: ClassVar[dict] = { "single_point": [ ("Energies", "_pop_energies", True), @@ -1842,11 +1822,14 @@ def _pop_uv_vis(self, ctx: _AnalysisContext) -> bool: yaxis=dict(showgrid=True, gridcolor=tc["grid_color"]), ) self._apply_plotly_theme(_fig) - self._tddft_fig.value = _pio.to_html( - _fig, - include_plotlyjs="cdn", - full_html=False, - config={"responsive": True}, + self._set_html_output( + self._tddft_fig, + _pio.to_html( + _fig, + include_plotlyjs="require", + full_html=False, + config={"responsive": True}, + ), ) return True except Exception: @@ -1991,20 +1974,20 @@ def _build_history_section(self) -> None: description="Load:", options=[("(no saved results)", "")], style={"description_width": "50px"}, - layout=widgets.Layout(width="500px"), + layout=_layout(width="500px"), ) self.past_refresh_btn = widgets.Button( description="Refresh", button_style="", icon="refresh", - layout=widgets.Layout(width="100px"), + layout=_layout(width="100px"), tooltip="Rescan the results directory", ) self.copy_path_btn = widgets.Button( description="Copy path", button_style="", icon="clipboard", - layout=widgets.Layout(width="120px"), + layout=_layout(width="120px"), tooltip="Copy the results directory path to clipboard", ) self.results_path_lbl = widgets.HTML() @@ -2013,7 +1996,7 @@ def _build_history_section(self) -> None: description="View log", button_style="", icon="file-text-o", - layout=widgets.Layout(width="110px"), + layout=_layout(width="110px"), tooltip="Open the full PySCF output log in the Output tab", ) @@ -2024,7 +2007,7 @@ def _build_history_section(self) -> None: description="", button_style="", style={"description_width": "0px", "button_width": "140px"}, - layout=widgets.Layout(margin="0 0 8px"), + layout=_layout(margin="0 0 8px"), ) self._cal_run_btn = widgets.Button( description="Run Calibration", @@ -2036,13 +2019,13 @@ def _build_history_section(self) -> None: if _PYSCF_AVAILABLE else "PySCF required (Linux / macOS / WSL)" ), - layout=widgets.Layout(width="180px"), + layout=_layout(width="180px"), ) self._cal_stop_btn = widgets.Button( description="Stop", button_style="warning", icon="stop", - layout=widgets.Layout(width="90px", display="none"), + layout=_layout(width="90px", display="none"), ) self._cal_progress = widgets.IntProgress( min=0, @@ -2050,11 +2033,11 @@ def _build_history_section(self) -> None: value=0, description="", bar_style="info", - layout=widgets.Layout(width="300px", display="none"), + layout=_layout(width="300px", display="none"), ) self._cal_step_label = widgets.HTML( value="", - layout=widgets.Layout(display="none"), + layout=_layout(display="none"), ) self._cal_results_html = widgets.HTML(value="") @@ -2065,7 +2048,7 @@ def _build_history_section(self) -> None: description="Reset performance database", button_style="danger", icon="trash", - layout=widgets.Layout(width="230px"), + layout=_layout(width="230px"), ) self._reset_confirm_html = widgets.HTML( '' @@ -2076,23 +2059,23 @@ def _build_history_section(self) -> None: description="Yes, delete all records", button_style="danger", icon="check", - layout=widgets.Layout(width="190px"), + layout=_layout(width="190px"), ) self._reset_confirm_no = widgets.Button( description="Cancel", button_style="", icon="times", - layout=widgets.Layout(width="90px"), + layout=_layout(width="90px"), ) self._reset_confirm_box = widgets.VBox( [ self._reset_confirm_html, widgets.HBox( [self._reset_confirm_yes, self._reset_confirm_no], - layout=widgets.Layout(gap="8px", margin="4px 0 0"), + layout=_layout(gap="8px", margin="4px 0 0"), ), ], - layout=widgets.Layout( + layout=_layout( display="none", border="1px solid #fca5a5", padding="8px 10px", @@ -2110,7 +2093,7 @@ def _build_history_section(self) -> None: self._perf_events_html, widgets.HBox( [self._reset_btn], - layout=widgets.Layout(margin="14px 0 4px"), + layout=_layout(margin="14px 0 4px"), ), self._reset_confirm_box, ] @@ -2141,13 +2124,13 @@ def _build_history_section(self) -> None: self._cal_mode_toggle, widgets.HBox( [self._cal_run_btn, self._cal_stop_btn], - layout=widgets.Layout(gap="6px", align_items="center"), + layout=_layout(gap="6px", align_items="center"), ), self._cal_progress, self._cal_step_label, self._cal_results_html, ], - layout=widgets.Layout(padding="4px 0"), + layout=_layout(padding="4px 0"), ) self._cal_accordion = widgets.Accordion( children=[_cal_panel], selected_index=None @@ -2167,7 +2150,7 @@ def _build_history_section(self) -> None: self.copy_path_btn, self.view_log_btn, ], - layout=widgets.Layout(align_items="center", gap="8px"), + layout=_layout(align_items="center", gap="8px"), ), self.results_path_lbl, self.past_output, @@ -2187,26 +2170,26 @@ def _build_compare_section(self) -> None: options=[("(no saved results)", "")], rows=8, description="", - layout=widgets.Layout(width="100%"), + layout=_layout(width="100%"), ) self.compare_refresh_btn = widgets.Button( description="Refresh", button_style="", icon="refresh", - layout=widgets.Layout(width="100px"), + layout=_layout(width="100px"), ) self.compare_btn = widgets.Button( description="Compare selected", button_style="primary", icon="bar-chart", disabled=True, - layout=widgets.Layout(width="180px"), + layout=_layout(width="180px"), ) self.compare_clear_btn = widgets.Button( description="Clear", button_style="warning", icon="times", - layout=widgets.Layout(width="90px"), + layout=_layout(width="90px"), ) self.compare_output = widgets.Output() @@ -2222,11 +2205,11 @@ def _build_compare_section(self) -> None: self.compare_select, widgets.HBox( [self.compare_btn, self.compare_clear_btn], - layout=widgets.Layout(gap="8px", margin="6px 0"), + layout=_layout(gap="8px", margin="6px 0"), ), self.compare_output, ], - layout=widgets.Layout(padding="8px 0"), + layout=_layout(padding="8px 0"), ) # Export accordion (Advanced) @@ -2251,7 +2234,7 @@ def _build_compare_section(self) -> None: ), widgets.HBox( [self.export_xyz_btn, self.export_mol_btn, self.export_pdb_btn], - layout=widgets.Layout(flex_flow="row wrap", gap="6px"), + layout=_layout(flex_flow="row wrap", gap="6px"), ), self.struct_export_status, ] @@ -2276,7 +2259,7 @@ def _build_output_tab(self) -> None: description="Clear", button_style="", icon="times", - layout=widgets.Layout(width="80px"), + layout=_layout(width="80px"), ) self._clear_log_cache_btn = widgets.Button( description="Clear Log Cache", @@ -2286,12 +2269,12 @@ def _build_output_tab(self) -> None: "Delete the session event log (event_log.jsonl). " "Calculation performance data is preserved." ), - layout=widgets.Layout(width="160px"), + layout=_layout(width="160px"), ) self._clear_log_cache_confirm_btn = widgets.Button( description="Confirm clear?", button_style="danger", - layout=widgets.Layout(width="140px", display="none"), + layout=_layout(width="140px", display="none"), ) self.log_tab_panel = widgets.VBox( [ @@ -2304,7 +2287,7 @@ def _build_output_tab(self) -> None: ), widgets.HBox( [self._log_clear_btn], - layout=widgets.Layout(margin="0 0 8px"), + layout=_layout(margin="0 0 8px"), ), self._log_source_lbl, self._log_output_html, @@ -2317,10 +2300,10 @@ def _build_output_tab(self) -> None: ), widgets.HBox( [self._clear_log_cache_btn, self._clear_log_cache_confirm_btn], - layout=widgets.Layout(align_items="center", gap="8px"), + layout=_layout(align_items="center", gap="8px"), ), ], - layout=widgets.Layout(padding="8px 0"), + layout=_layout(padding="8px 0"), ) # ── Help section (Cell 10) ──────────────────────────────────────────── @@ -2332,7 +2315,7 @@ def _build_help_section(self) -> None: options=list(zip(_help_labels, _help_keys)), description="Topic:", style={"description_width": "60px"}, - layout=widgets.Layout(width="460px"), + layout=_layout(width="460px"), ) self.help_content_html = widgets.HTML() self._render_help_topic() # render first topic immediately @@ -2342,7 +2325,7 @@ def _build_help_section(self) -> None: description="?", button_style="", tooltip="Help topics", - layout=widgets.Layout(width="34px", margin="0 0 0 8px"), + layout=_layout(width="34px", margin="0 0 0 8px"), ) # Exit button shown in the top bar @@ -2350,10 +2333,10 @@ def _build_help_section(self) -> None: description="Exit", button_style="danger", tooltip="Shut down the QuantUI server and close this session", - layout=widgets.Layout(width="64px", margin="0 0 0 8px"), + layout=_layout(width="64px", margin="0 0 0 8px"), ) self._exit_output = widgets.Output( - layout=widgets.Layout(height="0px", overflow="hidden") + layout=_layout(height="0px", overflow="hidden") ) self.help_tab_panel = widgets.VBox( @@ -2366,7 +2349,7 @@ def _build_help_section(self) -> None: self.help_topic_dd, self.help_content_html, ], - layout=widgets.Layout( + layout=_layout( display="none", padding="8px 0", border="1px solid #e2e8f0", @@ -2384,7 +2367,7 @@ def _build_issue_widgets(self) -> None: button_style="warning", icon="flag", tooltip="Report a bug or unexpected behaviour observed in this session", - layout=widgets.Layout(width="140px", margin="0 0 0 8px"), + layout=_layout(width="140px", margin="0 0 0 8px"), ) # ── Issue overlay (hidden until button is clicked) ──────────────── self._issue_textarea = widgets.Textarea( @@ -2392,17 +2375,17 @@ def _build_issue_widgets(self) -> None: "Describe what you observed — what you did, what you expected, " "and what actually happened." ), - layout=widgets.Layout(width="100%", height="90px"), + layout=_layout(width="100%", height="90px"), ) self._issue_submit_btn = widgets.Button( description="Submit", button_style="success", - layout=widgets.Layout(width="90px"), + layout=_layout(width="90px"), ) self._issue_cancel_btn = widgets.Button( description="Cancel", button_style="", - layout=widgets.Layout(width="80px"), + layout=_layout(width="80px"), ) self._issue_status_html = widgets.HTML() self._issue_overlay = widgets.VBox( @@ -2417,11 +2400,11 @@ def _build_issue_widgets(self) -> None: self._issue_textarea, widgets.HBox( [self._issue_submit_btn, self._issue_cancel_btn], - layout=widgets.Layout(margin="6px 0 0", gap="8px"), + layout=_layout(margin="6px 0 0", gap="8px"), ), self._issue_status_html, ], - layout=widgets.Layout( + layout=_layout( display="none", border="1px solid #f59e0b", border_radius="6px", @@ -2442,7 +2425,7 @@ def _assemble_tabs(self) -> None: self.run_panel, self._completion_banner, ], - layout=widgets.Layout(padding="8px 0"), + layout=_layout(padding="8px 0"), ) # Splice advanced_accordion into results_tab_panel before _to_analysis_btn. @@ -2476,30 +2459,42 @@ def _assemble_tabs(self) -> None: def _wire_callbacks(self) -> None: # 3D viewer backend toggle (only wired when both backends are available) if self.viz_backend_toggle is not None: - self.viz_backend_toggle.observe(self._on_viz_backend_changed, names="value") + self.viz_backend_toggle.observe( + self._safe_cb(self._on_viz_backend_changed), names="value" + ) # 3D viewer style and lighting controls if VISUALIZATION_AVAILABLE: - self.viz_style_dd.observe(self._on_viz_style_changed, names="value") - self.viz_lighting_dd.observe(self._on_viz_lighting_changed, names="value") + self.viz_style_dd.observe( + self._safe_cb(self._on_viz_style_changed), names="value" + ) + self.viz_lighting_dd.observe( + self._safe_cb(self._on_viz_lighting_changed), names="value" + ) # Theme - self.theme_btn.observe(self._on_theme_changed, names="value") + self.theme_btn.observe(self._safe_cb(self._on_theme_changed), names="value") # Molecule input - self.preset_dd.observe(self._on_load_preset, names="value") + self.preset_dd.observe(self._safe_cb(self._on_load_preset), names="value") self.xyz_btn.on_click(self._on_load_xyz) self.pubchem_btn.on_click(self._on_search_pubchem) self.change_mol_btn.on_click(self._on_expand_mol_input) # Calc type - self.calc_type_dd.observe(self._on_calc_type_changed, names="value") - self._freq_seed_dd.observe(self._on_freq_seed_changed, names="value") - self._scan_type_dd.observe(self._update_scan_widgets, names="value") + self.calc_type_dd.observe( + self._safe_cb(self._on_calc_type_changed), names="value" + ) + self._freq_seed_dd.observe( + self._safe_cb(self._on_freq_seed_changed), names="value" + ) + self._scan_type_dd.observe( + self._safe_cb(self._update_scan_widgets), names="value" + ) self._freq_seed_refresh_btn.on_click( lambda _btn: self._refresh_freq_seed_options() ) # Notes + estimate - self.method_dd.observe(self._update_notes, names="value") - self.basis_dd.observe(self._update_notes, names="value") - self.method_dd.observe(self._update_estimate, names="value") - self.basis_dd.observe(self._update_estimate, names="value") + self.method_dd.observe(self._safe_cb(self._update_notes), names="value") + self.basis_dd.observe(self._safe_cb(self._update_notes), names="value") + self.method_dd.observe(self._safe_cb(self._update_estimate), names="value") + self.basis_dd.observe(self._safe_cb(self._update_estimate), names="value") # Help buttons self.method_help_btn.on_click(self._on_method_help) self.basis_help_btn.on_click(self._on_basis_help) @@ -2509,7 +2504,9 @@ def _wire_callbacks(self) -> None: # Accumulate / export self.accumulate_btn.on_click(self._on_accumulate) self.clear_btn.on_click(self._on_clear) - self.solvent_cb.observe(self._on_solvent_cb_changed, names="value") + self.solvent_cb.observe( + self._safe_cb(self._on_solvent_cb_changed), names="value" + ) self._cal_run_btn.on_click(self._on_cal_run) self._cal_stop_btn.on_click(self._on_cal_stop) self.export_btn.on_click(self._on_export) @@ -2517,7 +2514,7 @@ def _wire_callbacks(self) -> None: self.export_mol_btn.on_click(self._on_export_mol) self.export_pdb_btn.on_click(self._on_export_pdb) # History - self.past_dd.observe(self._on_past_dd_changed, names="value") + self.past_dd.observe(self._safe_cb(self._on_past_dd_changed), names="value") self.past_refresh_btn.on_click(self._on_past_refresh) self.copy_path_btn.on_click(self._on_copy_results_path) self.view_log_btn.on_click(self._on_view_log) @@ -2542,7 +2539,9 @@ def _wire_callbacks(self) -> None: self._help_btn.on_click(self._on_help_toggle) # Exit self._exit_btn.on_click(self._on_exit_clicked) - self.help_topic_dd.observe(self._on_help_topic_changed, names="value") + self.help_topic_dd.observe( + self._safe_cb(self._on_help_topic_changed), names="value" + ) # Tab navigation buttons self._go_results_btn.on_click( lambda _: setattr(self.root_tab, "selected_index", 1) @@ -2554,11 +2553,19 @@ def _wire_callbacks(self) -> None: lambda _: setattr(self.root_tab, "selected_index", 2) ) # Vibrational mode selector - self.vib_mode_dd.observe(self._on_vib_mode_changed, names="value") + self.vib_mode_dd.observe( + self._safe_cb(self._on_vib_mode_changed), names="value" + ) # Orbital diagram axis controls - self._orb_ymin_input.observe(self._on_orb_range_changed, names="value") - self._orb_ymax_input.observe(self._on_orb_range_changed, names="value") - self._orb_n_orb_input.observe(self._on_orb_range_changed, names="value") + self._orb_ymin_input.observe( + self._safe_cb(self._on_orb_range_changed), names="value" + ) + self._orb_ymax_input.observe( + self._safe_cb(self._on_orb_range_changed), names="value" + ) + self._orb_n_orb_input.observe( + self._safe_cb(self._on_orb_range_changed), names="value" + ) # Orbital isosurface generate button self._iso_generate_btn.on_click(self._on_iso_generate) @@ -2603,6 +2610,41 @@ def _apply_plotly_theme(self, fig) -> None: yaxis=dict(gridcolor=tc["grid_color"]), ) + def _set_html_output(self, out: widgets.Output, html: str) -> None: + """Render HTML into an Output widget. + + Plotly HTML contains