From 484a10a2ef5495a6cca6534949b31e1afc9601c9 Mon Sep 17 00:00:00 2001 From: Schultz Lab at NCCU Date: Sat, 12 Sep 2026 21:01:48 -0400 Subject: [PATCH 1/4] feat: add PyFock phase-two optimization and analysis --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 5 + README.md | 16 +- docs/installation.md | 9 +- docs/platforms.md | 8 +- pyproject.toml | 3 + quantui/app.py | 8699 ++++----------------------- quantui/app_analysis.py | 1 + quantui/app_visualization.py | 9 +- quantui/engines/pyfock_engine.py | 146 +- quantui/engines/pyscf_engine.py | 40 +- quantui/help_content.py | 12 +- quantui/optimizer.py | 94 +- quantui/orbital_visualization.py | 84 +- quantui/results_storage.py | 3 + tests/test_app.py | 62 +- tests/test_engines_registry.py | 2 +- tests/test_orbital_visualization.py | 59 + tests/test_pyfock_engine.py | 60 +- tests/test_results_storage.py | 4 + 20 files changed, 1806 insertions(+), 7512 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69d4c7a..c15a8fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,7 +72,7 @@ jobs: # Single line: PowerShell does not support backslash line continuation. run: pytest -m "not network" --ignore=tests/test_session_calc.py --ignore=tests/test_optimizer.py --ignore=tests/test_preopt.py --no-cov --no-header -q - # ── Windows: real PyFock Phase-1 calculation ─────────────────────────────── + # ── Windows: real PyFock calculation ─────────────────────────────────────── test-windows-pyfock: name: PyFock integration (windows-latest, Python 3.11) runs-on: windows-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc7307..84fb7db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ### Added +- **PyFock geometry and analysis phase** — native-Windows PBE/def2 geometry + optimization now uses PyFock's ASE calculator with analytical density-fitted + gradients. Single-point and final optimized-geometry results retain orbital + arrays, Mulliken charges, dipoles, and engine-native cube generation. + - **PyFock Phase-1 backend (M-PYFOCK PYF.2–PYF.6)** — normalized engine dispatch, native-Windows PBE/def2 single points for neutral closed-shell molecules, capability-driven calculation menus, engine provenance in logs diff --git a/README.md b/README.md index 76cdde0..6782cb3 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,8 @@ Real output from QuantUI, straight from the app: - **In-session calculations** — RHF, UHF, 9 DFT functionals, MP2, CCSD, CCSD(T), NMR shielding, TD-DFT UV-Vis, and 1D PES scans via PySCF, running in your Python kernel. Optional PyFock 0.1.x adds native-Windows PBE - single points for neutral, closed-shell molecules with def2-SVP/def2-TZVP; + single points and geometry optimizations for neutral, closed-shell molecules + with def2-SVP/def2-TZVP, including orbital and population analysis; engine capabilities automatically gate the setup menus - **Implicit solvent** — PCM solvation (Water, Ethanol, THF, DMSO, Acetonitrile) via a single checkbox @@ -127,7 +128,7 @@ Real output from QuantUI, straight from the app: | --- | --- | --- | | Linux / macOS | Full | PySCF installs natively | | WSL (Windows) | Full | Use an Ubuntu WSL environment | -| Windows (native) | Partial | PyFock runs guarded PBE single points; use WSL/Apptainer for the full PySCF feature set | +| Windows (native) | Partial | PyFock runs guarded PBE single points and geometry optimizations; use WSL/Apptainer for the full PySCF feature set | ### Windows users: native PyFock or the full container @@ -140,11 +141,12 @@ python -m pip install "quantui[pyfock,ase,app]" ``` In **System Settings → Quantum engine**, select PyFock (or leave Automatic -selected when PySCF is absent). Phase 1 intentionally supports only neutral, -closed-shell PBE single points with def2-SVP or def2-TZVP. Density fitting is -always enabled. Hybrids, charged/open-shell systems, geometry optimization, -solvent, checkpoint warm starts, GPU, Mulliken/dipole analysis, and orbital -export remain PySCF-only. +selected when PySCF is absent). The validated subset supports neutral, +closed-shell PBE single points and geometry optimizations with def2-SVP or +def2-TZVP. Density fitting is always enabled, analytical gradients drive +optimization, and orbital, Mulliken, dipole, and cube analysis are retained. +Hybrids, charged/open-shell systems, solvent, checkpoint warm starts, and GPU +remain PySCF-only. PySCF does not install on Windows natively. For the complete feature set, the [`apptainer/quantui.def`](https://github.com/The-Schultz-Lab/QuantUI/blob/main/apptainer/quantui.def) container bundles diff --git a/docs/installation.md b/docs/installation.md index deaf7a6..acf3d0b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -27,7 +27,7 @@ pip install "quantui[pyscf,ase,app]" ## Windows -For native Windows and the guarded PyFock single-point subset, use Python 3.11: +For native Windows and the guarded PyFock subset, use Python 3.11: ```powershell py -3.11 -m venv .venv @@ -35,8 +35,9 @@ py -3.11 -m venv .venv python -m pip install "quantui[pyfock,ase,app]" ``` -Automatic engine selection uses PyFock when PySCF is absent. Phase 1 supports -neutral, closed-shell PBE/def2-SVP or PBE/def2-TZVP single points. For all +Automatic engine selection uses PyFock when PySCF is absent. The validated +subset supports neutral, closed-shell PBE/def2-SVP or PBE/def2-TZVP single +points and geometry optimizations. For all other methods and workflows, use WSL or the Apptainer container described in [Platform Support](platforms.md). @@ -45,7 +46,7 @@ other methods and workflows, use WSL or the Apptainer container described in | Extra | What it adds | | --- | --- | | `pyscf` | Canonical PySCF backend (full calculation and analysis feature set) | -| `pyfock` | PyFock Phase-1 backend (native-Windows PBE single points) | +| `pyfock` | PyFock backend (native-Windows PBE single points and geometry optimization) | | `ase` | ASE bridge for trajectory export and structure I/O | | `app` | Voilà, JupyterLab, and notebook launcher dependencies | | `xtb` | GFN-FF metal pre-optimization via xtb | diff --git a/docs/platforms.md b/docs/platforms.md index 8a577af..f743f5e 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -7,13 +7,13 @@ PyFock subset or move to WSL/Apptainer for the full feature set. | --- | --- | --- | | **Linux / macOS** | :material-check-circle:{ .green } Full | PySCF installs natively via conda or pip | | **WSL (Windows)** | :material-check-circle:{ .green } Full | Ubuntu WSL environment — follows the Linux path exactly | -| **Windows native** | :material-alert-circle:{ .yellow } Partial | PyFock: neutral closed-shell PBE/def2 single points; WSL/Apptainer: full PySCF feature set | +| **Windows native** | :material-alert-circle:{ .yellow } Partial | PyFock: neutral closed-shell PBE/def2 single points and geometry optimizations; WSL/Apptainer: full PySCF feature set | !!! info "Windows users" Install `quantui[pyfock,ase,app]` under Python 3.11 for native PBE single - points. QuantUI's engine picker hides unsupported controls. For hybrids, - ions, radicals, optimizations, spectra, solvent, checkpointing, GPU, and - orbital analysis, use [WSL 2](https://learn.microsoft.com/en-us/windows/wsl/install) + points and geometry optimizations. QuantUI's engine picker hides unsupported + controls. For hybrids, ions, radicals, spectra, solvent, checkpointing, and + GPU, use [WSL 2](https://learn.microsoft.com/en-us/windows/wsl/install) with Ubuntu or the [Apptainer container](https://github.com/The-Schultz-Lab/QuantUI/blob/main/apptainer/README.md) which bundles the complete environment in a single file. diff --git a/pyproject.toml b/pyproject.toml index ec7df6a..bb5d169 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -131,6 +131,9 @@ pyscf = [ # extra supplies and caps that runtime dependency explicitly. pyfock = [ "pyfock>=0.1.7,<0.2", + # PyFock's ASE calculator is the geometry-optimization path used by + # QuantUI; include ASE here so the documented backend extra is complete. + "ase>=3.22.0,<4", "scipy>=1.10,<2", # 0.68.0rc1 can be selected by PyFock's permissive floor and brought a # prerelease llvmlite that crashed at import on Linux/Python 3.12. diff --git a/quantui/app.py b/quantui/app.py index a6be266..ca75c68 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -1,7417 +1,1282 @@ -""" -QuantUI application class. - -All widget creation, state management, callbacks, and tab wiring live here. -The notebook is a thin launcher:: - - from quantui.app import QuantUIApp - QuantUIApp().display() - -CSS is injected inside ``display()`` — not on import — so importing this -module in tests or tutorials does not pollute the IPython display. -""" - -from __future__ import annotations - -import asyncio -import html as _html -import io -import re -import threading -import time -import uuid as _uuid -from dataclasses import dataclass, field -from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, ClassVar, List, Literal, Optional, cast - -import ipywidgets as widgets -from IPython import get_ipython -from IPython.display import HTML, Javascript, display - -import quantui -import quantui.calc_log as _calc_log -import quantui.issue_tracker as _issue_tracker -from quantui import molecule_library as _ml -from quantui import theme as _theme -from quantui.app_analysis import ( - activate_ana_panel as _ana_activate_ana_panel, -) -from quantui.app_analysis import ( - apply_analysis_context as _ana_apply_analysis_context, -) -from quantui.app_analysis import ( - build_ana_switcher as _ana_build_ana_switcher, -) -from quantui.app_analysis import ( - deactivate_all_ana_panels as _ana_deactivate_all_ana_panels, -) -from quantui.app_analysis import ( - on_reorg_view_changed as _ana_on_reorg_view_changed, -) -from quantui.app_analysis import ( - pop_energies as _ana_pop_energies, -) -from quantui.app_analysis import ( - pop_geo_trajectory as _ana_pop_geo_trajectory, -) -from quantui.app_analysis import ( - pop_ir_spectrum as _ana_pop_ir_spectrum, -) -from quantui.app_analysis import ( - pop_isosurface as _ana_pop_isosurface, -) -from quantui.app_analysis import ( - pop_mulliken as _ana_pop_mulliken, -) -from quantui.app_analysis import ( - pop_nmr_shielding as _ana_pop_nmr_shielding, -) -from quantui.app_analysis import ( - pop_pes_plot as _ana_pop_pes_plot, -) -from quantui.app_analysis import ( - pop_pes_trajectory as _ana_pop_pes_trajectory, -) -from quantui.app_analysis import ( - pop_preopt_trajectory as _ana_pop_preopt_trajectory, -) -from quantui.app_analysis import ( - pop_raman_spectrum as _ana_pop_raman_spectrum, -) -from quantui.app_analysis import ( - pop_reorg_geometries as _ana_pop_reorg_geometries, -) -from quantui.app_analysis import ( - pop_uv_vis as _ana_pop_uv_vis, -) -from quantui.app_analysis import ( - pop_vibrational as _ana_pop_vibrational, -) -from quantui.app_analysis import ( - scroll_analysis_tab_to_top as _ana_scroll_analysis_tab_to_top, -) -from quantui.app_analysis import ( - select_ana_panel as _ana_select_ana_panel, -) -from quantui.app_analysis import ( - update_mulliken_figure as _ana_update_mulliken_figure, -) -from quantui.app_builders import ( - _MOL_ANALYSIS_PNG_INBOX_CLASS, - _MOL_CALC_PNG_INBOX_CLASS, -) -from quantui.app_builders import ( - build_calc_setup as _bld_build_calc_setup, -) -from quantui.app_builders import ( - build_compare_section as _bld_build_compare_section, -) -from quantui.app_builders import ( - build_files_tab as _bld_build_files_tab, -) -from quantui.app_builders import ( - build_help_section as _bld_build_help_section, -) -from quantui.app_builders import ( - build_history_section as _bld_build_history_section, -) -from quantui.app_builders import ( - build_issue_widgets as _bld_build_issue_widgets, -) -from quantui.app_builders import ( - build_molecule_section as _bld_build_molecule_section, -) -from quantui.app_builders import ( - build_output_tab as _bld_build_output_tab, -) -from quantui.app_builders import ( - build_results_section as _bld_build_results_section, -) -from quantui.app_builders import ( - build_run_section as _bld_build_run_section, -) -from quantui.app_builders import ( - build_shared_widgets as _bld_build_shared_widgets, -) -from quantui.app_builders import ( - build_slurm_jobs_tab as _bld_build_slurm_jobs_tab, -) -from quantui.app_builders import ( - build_status_panel as _bld_build_status_panel, -) -from quantui.app_builders import ( - build_theme_selector as _bld_build_theme_selector, -) -from quantui.app_builders import ( - build_welcome_header as _bld_build_welcome_header, -) -from quantui.app_builders import ( - library_result_options as _bld_library_result_options, -) -from quantui.app_exports import ( - export_molecule_and_label as _exp_export_molecule_and_label, -) -from quantui.app_exports import ( - molecule_to_rdkit as _exp_molecule_to_rdkit, -) -from quantui.app_exports import ( - on_export as _exp_on_export, -) -from quantui.app_exports import ( - on_export_bundle as _exp_on_export_bundle, -) -from quantui.app_exports import ( - on_export_mol as _exp_on_export_mol, -) -from quantui.app_exports import ( - on_export_pdb as _exp_on_export_pdb, -) -from quantui.app_exports import ( - on_export_reorg_geometries as _exp_on_export_reorg_geometries, -) -from quantui.app_exports import ( - on_export_xyz as _exp_on_export_xyz, -) -from quantui.app_exports import ( - on_iso_export_cube as _exp_on_iso_export_cube, -) -from quantui.app_exports import ( - on_mol_analysis_png_captured as _exp_on_mol_analysis_png_captured, -) -from quantui.app_exports import ( - on_mol_calc_png_captured as _exp_on_mol_calc_png_captured, -) -from quantui.app_exports import ( - on_mol_results_png_captured as _exp_on_mol_results_png_captured, -) -from quantui.app_exports import ( - on_orb_png_captured as _exp_on_orb_png_captured, -) -from quantui.app_exports import ( - on_reorg_png_captured as _exp_on_reorg_png_captured, -) -from quantui.app_exports import ( - on_vib_png_captured as _exp_on_vib_png_captured, -) -from quantui.app_formatters import ( - format_freq_result as _fmt_freq_result, -) -from quantui.app_formatters import ( - format_nmr_result as _fmt_nmr_result, -) -from quantui.app_formatters import ( - format_opt_result as _fmt_opt_result, -) -from quantui.app_formatters import ( - format_past_result as _fmt_past_result, -) -from quantui.app_formatters import ( - format_pes_scan_result as _fmt_pes_scan_result, -) -from quantui.app_formatters import ( - format_reorg_result as _fmt_reorg_result, -) -from quantui.app_formatters import ( - format_result as _fmt_result, -) -from quantui.app_formatters import ( - format_tddft_result as _fmt_tddft_result, -) -from quantui.app_history import ( - build_history_context as _hist_build_history_context, -) -from quantui.app_history import ( - history_load_analysis as _hist_history_load_analysis, -) -from quantui.app_history import ( - history_load_results as _hist_history_load_results, -) -from quantui.app_history import ( - mol_from_result_dir as _hist_mol_from_result_dir, -) -from quantui.app_history import ( - on_past_dd_changed as _hist_on_past_dd_changed, -) -from quantui.app_history import ( - on_view_log as _hist_on_view_log, -) -from quantui.app_measurement import ( - on_measure_clear as _measure_on_clear, -) -from quantui.app_measurement import ( - on_measure_inbox_changed as _measure_on_inbox_changed, -) -from quantui.app_runflow import ( - apply_scan_range_around_current as _run_apply_scan_range_around_current, -) -from quantui.app_runflow import ( - apply_suggested_scan_range as _run_apply_suggested_scan_range, -) -from quantui.app_runflow import ( - apply_vib_mode_for_frequency as _run_apply_vib_mode_for_frequency, -) -from quantui.app_runflow import ( - calc_type_key as _run_calc_type_key, -) -from quantui.app_runflow import ( - do_calibration as _run_do_calibration, -) -from quantui.app_runflow import ( - on_accumulate as _run_on_accumulate, -) -from quantui.app_runflow import ( - on_basis_fix as _run_on_basis_fix, -) -from quantui.app_runflow import ( - on_basis_help as _run_on_basis_help, -) -from quantui.app_runflow import ( - on_cal_run as _run_on_cal_run, -) -from quantui.app_runflow import ( - on_cal_skip as _run_on_cal_skip, -) -from quantui.app_runflow import ( - on_cal_stop as _run_on_cal_stop, -) -from quantui.app_runflow import ( - on_calc_type_changed as _run_on_calc_type_changed, -) -from quantui.app_runflow import ( - on_calc_type_help as _run_on_calc_type_help, -) -from quantui.app_runflow import ( - on_charge_mult_apply as _run_on_charge_mult_apply, -) -from quantui.app_runflow import ( - on_charge_mult_suggest as _run_on_charge_mult_suggest, -) -from quantui.app_runflow import ( - on_clear as _run_on_clear, -) -from quantui.app_runflow import ( - on_clear_log as _run_on_clear_log, -) -from quantui.app_runflow import ( - on_clear_log_cache as _run_on_clear_log_cache, -) -from quantui.app_runflow import ( - on_clear_log_cache_confirm as _run_on_clear_log_cache_confirm, -) -from quantui.app_runflow import ( - on_compare as _run_on_compare, -) -from quantui.app_runflow import ( - on_compare_clear as _run_on_compare_clear, -) -from quantui.app_runflow import ( - on_compare_refresh as _run_on_compare_refresh, -) -from quantui.app_runflow import ( - on_confirm_no as _run_on_confirm_no, -) -from quantui.app_runflow import ( - on_confirm_yes as _run_on_confirm_yes, -) -from quantui.app_runflow import ( - on_copy_results_path as _run_on_copy_results_path, -) -from quantui.app_runflow import ( - on_exit_cancel as _run_on_exit_cancel, -) -from quantui.app_runflow import ( - on_exit_clicked as _run_on_exit_clicked, -) -from quantui.app_runflow import ( - on_expand_mol_input as _run_on_expand_mol_input, -) -from quantui.app_runflow import ( - on_help_toggle as _run_on_help_toggle, -) -from quantui.app_runflow import ( - on_help_topic_changed as _run_on_help_topic_changed, -) -from quantui.app_runflow import ( - on_issue_btn as _run_on_issue_btn, -) -from quantui.app_runflow import ( - on_issue_cancel as _run_on_issue_cancel, -) -from quantui.app_runflow import ( - on_issue_submit as _run_on_issue_submit, -) -from quantui.app_runflow import ( - on_log_clear as _run_on_log_clear, -) -from quantui.app_runflow import ( - on_method_help as _run_on_method_help, -) -from quantui.app_runflow import ( - on_past_refresh as _run_on_past_refresh, -) -from quantui.app_runflow import ( - on_preopt_accept as _run_on_preopt_accept, -) -from quantui.app_runflow import ( - on_preopt_preview as _run_on_preopt_preview, -) -from quantui.app_runflow import ( - on_preopt_reset as _run_on_preopt_reset, -) -from quantui.app_runflow import ( - on_reset_click as _run_on_reset_click, -) -from quantui.app_runflow import ( - on_run_clicked as _run_on_run_clicked, -) -from quantui.app_runflow import ( - on_seed_changed as _run_on_seed_changed, -) -from quantui.app_runflow import ( - on_solvent_cb_changed as _run_on_solvent_cb_changed, -) -from quantui.app_runflow import ( - on_spin_apply as _run_on_spin_apply, -) -from quantui.app_runflow import ( - on_spin_suggest as _run_on_spin_suggest, -) -from quantui.app_runflow import ( - populate_compare_list as _run_populate_compare_list, -) -from quantui.app_runflow import ( - refresh_comparison as _run_refresh_comparison, -) -from quantui.app_runflow import ( - refresh_pes_scan_widgets as _run_refresh_pes_scan_widgets, -) -from quantui.app_runflow import ( - refresh_pes_seed_options as _run_refresh_pes_seed_options, -) -from quantui.app_runflow import ( - refresh_results_browser as _run_refresh_results_browser, -) -from quantui.app_runflow import ( - refresh_seed_options as _run_refresh_seed_options, -) -from quantui.app_runflow import ( - resolve_seed_geometry as _run_resolve_seed_geometry, -) -from quantui.app_runflow import ( - update_estimate as _run_update_estimate, -) -from quantui.app_runflow import ( - update_notes as _run_update_notes, -) -from quantui.app_runflow import ( - update_scan_widgets as _run_update_scan_widgets, -) -from quantui.app_slurm import ( - on_slurm_jobs_cancel_clicked as _slurm_on_jobs_cancel_clicked, -) -from quantui.app_slurm import ( - on_slurm_jobs_refresh_clicked as _slurm_on_jobs_refresh_clicked, -) -from quantui.app_slurm import ( - on_slurm_jobs_remove_clicked as _slurm_on_jobs_remove_clicked, -) -from quantui.app_slurm import ( - on_slurm_jobs_view_clicked as _slurm_on_jobs_view_clicked, -) -from quantui.app_slurm import ( - on_slurm_reconnect_clicked as _slurm_on_reconnect_clicked, -) -from quantui.app_slurm import ( - refresh_slurm_jobs_tab as _slurm_refresh_jobs_tab, -) -from quantui.app_slurm import ( - slurm_jobs_tab_visible as _slurm_jobs_tab_visible, -) -from quantui.app_slurm import ( - startup_slurm_check as _slurm_startup_check, -) -from quantui.app_visualization import ( - build_vib_data_from_freq_result as _viz_build_vib_data_from_freq_result, -) -from quantui.app_visualization import ( - build_vib_data_inner as _viz_build_vib_data_inner, -) -from quantui.app_visualization import ( - build_vib_export_html as _viz_build_vib_export_html, -) -from quantui.app_visualization import ( - on_ir_fwhm_changed as _viz_on_ir_fwhm_changed, -) -from quantui.app_visualization import ( - on_ir_mode_changed as _viz_on_ir_mode_changed, -) -from quantui.app_visualization import ( - on_iso_appearance_changed as _viz_on_iso_appearance_changed, -) -from quantui.app_visualization import ( - on_iso_cancel as _viz_on_iso_cancel, -) -from quantui.app_visualization import ( - on_iso_generate as _viz_on_iso_generate, -) -from quantui.app_visualization import ( - on_nmr_nucleus_changed as _viz_on_nmr_nucleus_changed, -) -from quantui.app_visualization import ( - on_orb_range_changed as _viz_on_orb_range_changed, -) -from quantui.app_visualization import ( - on_raman_fwhm_changed as _viz_on_raman_fwhm_changed, -) -from quantui.app_visualization import ( - on_raman_mode_changed as _viz_on_raman_mode_changed, -) -from quantui.app_visualization import ( - on_traj_expand as _viz_on_traj_expand, -) -from quantui.app_visualization import ( - on_uv_fwhm_changed as _viz_on_uv_fwhm_changed, -) -from quantui.app_visualization import ( - on_uv_mode_changed as _viz_on_uv_mode_changed, -) -from quantui.app_visualization import ( - on_uv_range_changed as _viz_on_uv_range_changed, -) -from quantui.app_visualization import ( - on_vib_mode_changed as _viz_on_vib_mode_changed, -) -from quantui.app_visualization import ( - render_orbital_isosurface as _viz_render_orbital_isosurface, -) -from quantui.app_visualization import ( - render_traj_frame as _viz_render_traj_frame, -) -from quantui.app_visualization import ( - render_vib_mode as _viz_render_vib_mode, -) -from quantui.app_visualization import ( - rerender_3d_scenes_for_theme as _viz_rerender_3d_scenes_for_theme, -) -from quantui.app_visualization import ( - show_ir_spectrum as _viz_show_ir_spectrum, -) -from quantui.app_visualization import ( - show_nmr_spectrum as _viz_show_nmr_spectrum, -) -from quantui.app_visualization import ( - show_opt_trajectory as _viz_show_opt_trajectory, -) -from quantui.app_visualization import ( - show_orbital_diagram as _viz_show_orbital_diagram, -) -from quantui.app_visualization import ( - show_pes_scan_result as _viz_show_pes_scan_result, -) -from quantui.app_visualization import ( - show_raman_spectrum as _viz_show_raman_spectrum, -) -from quantui.app_visualization import ( - show_result_3d as _viz_show_result_3d, -) -from quantui.app_visualization import ( - show_uv_vis_spectrum as _viz_show_uv_vis_spectrum, -) -from quantui.app_visualization import ( - show_vib_animation as _viz_show_vib_animation, -) -from quantui.app_visualization import ( - traj_step_html as _viz_traj_step_html, -) -from quantui.app_visualization import ( - update_ir_figure as _viz_update_ir_figure, -) -from quantui.app_visualization import ( - update_nmr_figure as _viz_update_nmr_figure, -) -from quantui.app_visualization import ( - update_raman_figure as _viz_update_raman_figure, -) -from quantui.app_visualization import ( - update_uv_vis_figure as _viz_update_uv_vis_figure, -) -from quantui.app_visualization import ( - wire_ir_controls as _viz_wire_ir_controls, -) -from quantui.app_visualization import ( - wire_nmr_controls as _viz_wire_nmr_controls, -) -from quantui.app_visualization import ( - wire_raman_controls as _viz_wire_raman_controls, -) -from quantui.app_visualization import ( - wire_uv_controls as _viz_wire_uv_controls, -) -from quantui.app_xyz_input import ( - on_load_xyz as _xyz_on_load_xyz, -) -from quantui.app_xyz_input import ( - on_xyz_add_atom as _xyz_on_add_atom, -) -from quantui.app_xyz_input import ( - on_xyz_apply_table as _xyz_on_apply_table, -) -from quantui.app_xyz_input import ( - on_xyz_cleanup as _xyz_on_cleanup, -) -from quantui.app_xyz_input import ( - on_xyz_cleanup_accept as _xyz_on_cleanup_accept, -) -from quantui.app_xyz_input import ( - on_xyz_cleanup_reject as _xyz_on_cleanup_reject, -) -from quantui.app_xyz_input import ( - on_xyz_fill_table as _xyz_on_fill_table, -) -from quantui.backends.dispatch import is_slurm_available -from quantui.cancellation import CalcCancelled as _CalcCancelled - -# Import directly from submodules to avoid circular-import issues. -# quantui/__init__.py imports this module (app.py), so using -# `from quantui import X` at module load time would see a partially- -# initialised package namespace (symbols defined after the app import -# in __init__.py would not yet exist). -from quantui.config import ( - DEFAULT_BASIS, - DEFAULT_CHARGE, - DEFAULT_FMAX, - DEFAULT_METHOD, - DEFAULT_MULTIPLICITY, - DEFAULT_OPT_STEPS, - SUPPORTED_BASIS_SETS, - SUPPORTED_METHODS, -) -from quantui.engines import is_pyfock_available as _is_pyfock_available -from quantui.engines import is_pyscf_available as _is_pyscf_available -from quantui.freq_ir_workers import ( - freq_parallel_env_configured, - freq_parallel_opt_in, -) -from quantui.help_content import HELP_TOPICS -from quantui.molecule import Molecule, parse_xyz_input -from quantui.progress import StepProgress -from quantui.user_settings import UserSettings -from quantui.utils import get_session_resources -from quantui.viz_backend_router import ( - BackendAvailability, - VizBackend, - VizPreference, - VizTask, - select_backend, -) - -# ── Availability flags (computed once at import, not per-instantiation) ─────── -try: - from quantui.ase_bridge import ASE_AVAILABLE -except ImportError: - ASE_AVAILABLE = False - -try: - from quantui.visualization_py3dmol import ( - DEFAULT_LIGHTING as _DEFAULT_LIGHTING, - ) - from quantui.visualization_py3dmol import ( - DEFAULT_STYLE as _DEFAULT_VIZ_STYLE, - ) - from quantui.visualization_py3dmol import ( - LIGHTING_OPTIONS as _LIGHTING_OPTIONS, - ) - from quantui.visualization_py3dmol import ( - PLOTLYMOL_AVAILABLE as _PLOTLYMOL_VIZ, - ) - from quantui.visualization_py3dmol import ( - PY3DMOL_AVAILABLE as _PY3DMOL_VIZ, - ) - from quantui.visualization_py3dmol import ( - VIZ_STYLE_OPTIONS as _VIZ_STYLE_OPTIONS, - ) - from quantui.visualization_py3dmol import ( - render_molecule_html as _render_molecule_html, - ) - - VISUALIZATION_AVAILABLE = True -except ImportError: - VISUALIZATION_AVAILABLE = False - _render_molecule_html = None # type: ignore[assignment] - _PLOTLYMOL_VIZ = False - _PY3DMOL_VIZ = False - _DEFAULT_VIZ_STYLE = "ball+stick" - _DEFAULT_LIGHTING = "soft" - _VIZ_STYLE_OPTIONS = [ - ("Ball & Stick", "ball+stick"), - ("Stick", "stick"), - ("Sphere (VDW)", "sphere"), - ("Line", "line"), - ] - _LIGHTING_OPTIONS = [ - ("Soft", "soft"), - ("Default", "default"), - ("Bright", "bright"), - ("Metallic", "metallic"), - ("Dramatic", "dramatic"), - ] - -_VizBackend = Literal["auto", "py3dmol", "plotlymol"] -_BOTH_VIZ_AVAILABLE: bool = _PLOTLYMOL_VIZ and _PY3DMOL_VIZ -_DEFAULT_VIZ_BACKEND: _VizBackend = "plotlymol" if _PLOTLYMOL_VIZ else "py3dmol" - -try: - from quantui.pubchem import ( - RDKIT_AVAILABLE as _PUBCHEM_RDKIT_AVAILABLE, - ) - from quantui.structure_providers import ( - resolve_structure_with_message as _resolve_structure_with_message, - ) - from quantui.structure_providers import ( - search_candidates as _struct_search_candidates, - ) - from quantui.structure_providers import ( - student_friendly_resolve as _student_friendly_resolve, - ) - - PUBCHEM_AVAILABLE = _PUBCHEM_RDKIT_AVAILABLE -except ImportError: - PUBCHEM_AVAILABLE = False - _student_friendly_resolve = None # type: ignore[assignment] - _resolve_structure_with_message = None # type: ignore[assignment] - _struct_search_candidates = None # type: ignore[assignment] - - -# Provider key → short, accurate label for the loaded-molecule card. Replaces -# the old hard-coded "PubChem: " which mislabeled offline/library/SMILES -# hits (2026-06-15). -_STRUCT_SOURCE_PREFIX = { - "pubchem": "PubChem", - "cactus": "NCI CACTUS", - "rdkit-smiles": "SMILES", - "rdkit-inchi": "InChI", - "library": "Library", - "library-offline-fallback": "Library (offline)", -} - -_PYSCF_AVAILABLE = _is_pyscf_available() -_PYFOCK_AVAILABLE = _is_pyfock_available() - -try: - # Availability probe only — the classical pre-opt is invoked via the - # interactive Preview flow (app_runflow uses preoptimize_with_trajectory), - # not from app.py. ``_PREOPT_AVAILABLE`` gates the Preview button. - import quantui.preopt # noqa: F401 - - _PREOPT_AVAILABLE = True -except (ImportError, AttributeError): - _PREOPT_AVAILABLE = False - -_RDKIT_AVAILABLE: bool = bool(PUBCHEM_AVAILABLE) - -from quantui.benchmarks import ( # noqa: E402 - BENCHMARK_SUITE as _BENCHMARK_SUITE, -) -from quantui.benchmarks import ( # noqa: E402 - BENCHMARK_SUITE_LONG as _BENCHMARK_SUITE_LONG, -) -from quantui.benchmarks import ( # noqa: E402 - load_last_calibration as _load_last_calibration_raw, -) - - -def _load_last_calibration_label() -> str: - """Return a human-readable timestamp of the last calibration, or ''.""" - data = _load_last_calibration_raw() - if data is None: - return "" - ts = str(data.get("timestamp", "")) - try: - from datetime import datetime - - dt = datetime.fromisoformat(ts).astimezone() - return dt.strftime("%Y-%m-%d %H:%M %Z") - except Exception: - return ts[:19] if ts else "" - - -# ── Module-level constants ──────────────────────────────────────────────────── - -_APP_CSS: 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( - r"cycle=\s*(\d+)\s+E=\s*([\-\d\.]+)\s+delta_E=\s*([\-\d\.Ee+\-]+)" -) -_RE_CONV = re.compile(r"converged SCF energy\s*=\s*([\-\d\.]+)") -_RE_Q_STATUS = re.compile(r"\[QuantUI_STATUS\]\s*(.+)") -# TD-DFT root convergence. PySCF's Davidson solver prints -# "root %d converged |r|= ... e= max|de|= ..." at -# verbose=5 (DEBUG) — see tddft_calc.py's td.verbose. This is the only -# per-root progress signal the solve emits; without it the heartbeat's -# generic "still working" line is all a user sees during a multi-minute -# excited-state solve. -_RE_TD_ROOT = re.compile( - r"root\s+(\d+)\s+converged\s+\|r\|=\s*[\d.eE+\-]+\s+e=\s*([\d.eE+\-]+)" -) -# Per-phase progress for long silent kernels (NMR, Hessian, post-HF, TD-DFT). -_RE_CCSD_CYCLE = re.compile( - r"cycle\s*=\s*(\d+)\s+E_corr\(CCSD\)\s*=\s*[\-\d\.Ee+\-]+\s+dE\s*=\s*([\-\d\.Ee+\-]+)" -) -_RE_NMR_RANGE = re.compile(r"shielding for atoms range\(0,\s*(\d+)\)") -_RE_NMR_ATOM = re.compile(r"total shielding of atom\s+(\d+)") -_RE_HESS_ATOM = re.compile(r"contracting int2e_ip1ip2 for atom\s+(\d+)") -_RE_MP2_TRANSFORM = re.compile(r"transform \(ia\|jb\)") -_RE_MP2_KERNEL = re.compile(r"CPU time for kernel") -_HARTREE_TO_EV = 27.211386245988 -# Step/point/state counters inside a status message. Removed before the -# message is used as a per-stage timing key — see _LogCapture._stage_key. -_RE_STAGE_NUMBERS = re.compile(r"\d+(?:[./]\d+)*") -# ASE BFGS per-step table written to the optimizer logfile. -_RE_BFGS_STATUS = re.compile(r"^BFGS:\s+(\d+)\s+\S+\s+([-\d.]+)\s+([\d.eE+\-]+)") - -# ── Silent-phase heartbeat ─────────────────────────────────────────────────── -# -# Seconds of stream silence before the log says it is still alive. -# -# Sized from a real measurement, not a guess: a user timed an aspirin -# (21 atoms) B3LYP/6-31G* UV-Vis run and the log printed **nothing for 120 s** -# after "converged SCF energy" while the TD-DFT solve ran. The status label was -# advancing the whole time — Phase A covers that — but the log, which is what a -# user actually watches, looked frozen. -# -# 25 s yields ~4 lines across that gap: enough to prove liveness, few enough not -# to bloat the archived pyscf.log. Gaps grow steeply with system size, and -# aspirin is a *small* case, so err on the short side. -_HEARTBEAT_AFTER_S = 25.0 - -# How often the watchdog wakes to check. Well under _HEARTBEAT_AFTER_S so a beat -# lands close to its due time, but coarse enough to be free. -_HEARTBEAT_POLL_S = 2.0 - - -# ══ LOG CAPTURE ══════════════════════════════════════════════════════════════ - - -# ``_CalcCancelled`` is defined in quantui.cancellation (imported at the top of -# this module) so the calc modules (session_calc / optimizer / freq / tddft / -# nmr / pes) can raise the SAME class from their SCF callbacks + optimizer -# observers without importing the app layer. ``_do_run``'s -# ``except _CalcCancelled`` catches it whether it was raised by -# ``_LogCapture.write`` or by one of those hooks. - - -class _LogCapture: - """Write PySCF output to an Output widget and capture it to a buffer.""" - - def __init__( - self, - output_widget: widgets.Output, - status_label: Optional[widgets.Label] = None, - on_scf_converged: Optional[Callable[[], None]] = None, - cancel_check: Optional[Callable[[], bool]] = None, - ) -> None: - self._w = output_widget - self._buf = io.StringIO() - self._line_buf = "" - self._status = status_label - self._on_scf_converged = on_scf_converged - self._scf_converged_seen = False - self._cancel_check = cancel_check - # Public alias so calc modules can duck-type the predicate off the - # progress stream (see quantui.cancellation.cancel_check_from_stream). - self.cancel_check = cancel_check - # Completion fraction (0..1) reported by calc modules via - # log_utils.emit_progress; read by the elapsed ticker. None = unknown. - self._fraction: Optional[float] = None - # Silent-phase heartbeat. Long kernels — the TD-DFT excited-state solve - # most of all — print nothing for minutes, so the log looks hung even - # though the status label is advancing. A watchdog appends a "still - # working" line when the stream has gone quiet. - self._last_write_t = time.monotonic() - self._hb_started_t = self._last_write_t - self._hb_stop = threading.Event() - self._hb_thread: Optional[threading.Thread] = None - # Per-stage wall times. Every calc type announces its phases through - # log_utils.emit_status, and every announcement passes through this - # object — stage boundaries can be timed here without threading a timer - # through optimizer/freq/tddft/nmr one by one. - self._stage_times: dict[str, float] = {} - self._stage_name: Optional[str] = None - self._stage_started_t = self._last_write_t - # D3: atom totals parsed from PySCF's NMR header line. - self._nmr_atom_total: Optional[int] = None - # Geometry-opt live progress (set by _do_run before optimize_geometry). - self.opt_expected_steps: Optional[int] = None - self.opt_fmax_target: Optional[float] = None - self.opt_fmax0: Optional[float] = None - - # ── Per-stage timing ──────────────────────────────────────────────────── - - @staticmethod - def _stage_key(message: str) -> str: - """Collapse a live status message to a stable stage name. - - Status messages carry per-step detail — "Opt step 7 — SCF…", - "Solving TD-DFT excited states (10)…" — which is exactly right for - the user watching the run and exactly wrong as a dictionary key: it - would turn one stage into one entry per step. Stripping the numbers - leaves the phase itself, which is the unit a cost model reasons in. - """ - text = _RE_STAGE_NUMBERS.sub(" ", message) - text = text.replace("…", " ").replace("(", " ").replace(")", " ") - text = " ".join(text.split()) - return text.strip(" -—:·").lower() - - def _enter_stage(self, name: str) -> None: - """Close the stage in progress and start *name*. - - Repeated announcements of the same stage (the optimizer re-announces - every step) are treated as one continuous stage, so the recorded - breakdown stays at the granularity a cost model can use rather than - exploding into one entry per step. - """ - now = time.monotonic() - if self._stage_name is not None and name != self._stage_name: - prev = self._stage_times.get(self._stage_name, 0.0) - self._stage_times[self._stage_name] = prev + (now - self._stage_started_t) - if name != self._stage_name: - self._stage_name = name - self._stage_started_t = now - - def stage_timings(self) -> dict[str, float]: - """Return ``{stage: seconds}``, including the stage still running. - - Safe to call mid-run: the open stage is measured up to now rather - than omitted, so a caller that logs this at the end of a calc gets a - breakdown that actually sums to the run. - """ - out = dict(self._stage_times) - if self._stage_name is not None: - elapsed = time.monotonic() - self._stage_started_t - out[self._stage_name] = out.get(self._stage_name, 0.0) + elapsed - return out - - # ── Silent-phase heartbeat ────────────────────────────────────────────── - - def start_heartbeat(self) -> None: - """Begin watching for silent stretches. Idempotent.""" - if self._hb_thread is not None: - return - self._last_write_t = time.monotonic() - self._hb_started_t = self._last_write_t - self._hb_thread = threading.Thread( - target=self._heartbeat_loop, daemon=True, name="quantui-log-heartbeat" - ) - self._hb_thread.start() - - def stop_heartbeat(self) -> None: - """Stop the watchdog. Safe to call more than once, or if never started.""" - self._hb_stop.set() - self._hb_thread = None - - def _heartbeat_loop(self) -> None: - """Append a liveness line whenever the stream has been quiet too long. - - Deliberately writes **directly** to the widget and buffer rather than - going through :meth:`write`: that path checks cancellation (which would - raise ``_CalcCancelled`` on this thread, where nothing can catch it) and - would also reset the very timer being measured. - """ - while not self._hb_stop.wait(_HEARTBEAT_POLL_S): - quiet_for = time.monotonic() - self._last_write_t - if quiet_for < _HEARTBEAT_AFTER_S: - continue - stage = "" - if self._status is not None: - try: - stage = str(self._status.value).strip() - except Exception: # noqa: BLE001 — a label read must not kill it - stage = "" - from quantui.log_utils import format_elapsed - - elapsed = time.monotonic() - self._hb_started_t - line = " … still working" - if stage: - line += f" — {stage}" - line += f" · {format_elapsed(elapsed)} elapsed\n" - try: - # Widget only — deliberately NOT self._buf. The buffer becomes - # the result directory's pyscf.log, which should stay a faithful - # record of what PySCF emitted. Heartbeats are UI chrome for the - # live view; padding the archived log with them would make a - # long silent run look chatty after the fact. - self._w.append_stdout(line) - except Exception: # noqa: BLE001 — never let the log kill a run - pass - # Reset so the next beat is measured from this line, giving evenly - # spaced heartbeats instead of one per poll once the gap is open. - self._last_write_t = time.monotonic() - - def write(self, text: str) -> None: - if not text: - return - # Cooperative cancellation: the calc prints frequently (per SCF cycle / - # opt step), so raising here stops it at the next line. See _CalcCancelled. - if self._cancel_check is not None and self._cancel_check(): - raise _CalcCancelled() - # Any real output resets the silence timer, so a heartbeat only ever - # appears in a genuinely quiet stretch. - self._last_write_t = time.monotonic() - self._w.append_stdout(text) - self._buf.write(text) - self._line_buf += text - while "\n" in self._line_buf: - line, self._line_buf = self._line_buf.split("\n", 1) - m = _RE_BFGS_STATUS.match(line) - if m and self._status is not None: - import math as _math - - step_n = int(m.group(1)) + 1 # display 1-based - fmax_val = float(m.group(3)) - if self.opt_expected_steps: - step_of = f"{step_n}/~{int(self.opt_expected_steps)}" - step_frac = min(step_n / float(self.opt_expected_steps), 0.9) - else: - step_of = str(step_n) - step_frac = 0.0 - if self.opt_fmax0 is None and fmax_val > 0: - self.opt_fmax0 = fmax_val - fmax_frac = 0.0 - target = self.opt_fmax_target - if ( - self.opt_fmax0 is not None - and target is not None - and target > 0 - and self.opt_fmax0 > target - and fmax_val > 0 - ): - denom = _math.log(self.opt_fmax0 / target) - if denom > 0: - fmax_frac = ( - _math.log(self.opt_fmax0 / max(fmax_val, target)) / denom - ) - self.set_progress_fraction( - max(0.03, min(max(step_frac, fmax_frac), 0.99)) - ) - self._status.value = ( - f"Optimizing geometry — step {step_of} · " - f"fmax {fmax_val:.4f} eV/Å" - ) - continue - m = _RE_Q_STATUS.search(line) - if m: - message = m.group(1).strip() - self._enter_stage(self._stage_key(message)) - if self._status is not None: - self._status.value = message - continue - m = _RE_CYCLE.search(line) - if m and self._status is not None: - n, delta = m.group(1), m.group(3) - try: - self._status.value = f"SCF cycle {n} · ΔE = {float(delta):.4g} Ha" - except Exception: - self._status.value = f"SCF cycle {n}" - continue - m = _RE_TD_ROOT.search(line) - if m and self._status is not None: - root, e_ha = m.group(1), m.group(2) - try: - root_n = int(root) + 1 # PySCF's root index is 0-based - ev = float(e_ha) * _HARTREE_TO_EV - self._status.value = ( - f"TD-DFT root {root_n} converged · {ev:.3f} eV" - ) - except Exception: - self._status.value = f"TD-DFT root {root} converged" - continue - m = _RE_CCSD_CYCLE.search(line) - if m and self._status is not None: - cycle, d_e = m.group(1), m.group(2) - try: - self._status.value = ( - f"CCSD cycle {cycle} · ΔE = {float(d_e):.4g} Ha" - ) - except Exception: - self._status.value = f"CCSD cycle {cycle}" - continue - m = _RE_NMR_RANGE.search(line) - if m: - try: - self._nmr_atom_total = int(m.group(1)) - except Exception: - self._nmr_atom_total = None - continue - m = _RE_NMR_ATOM.search(line) - if m and self._status is not None: - atom_idx = m.group(1) - try: - atom_n = int(atom_idx) + 1 - if self._nmr_atom_total: - self._status.value = ( - f"NMR GIAO · atom {atom_n}/{self._nmr_atom_total}" - ) - else: - self._status.value = f"NMR GIAO · atom {atom_n}" - except Exception: - self._status.value = f"NMR GIAO · atom {atom_idx}" - continue - m = _RE_HESS_ATOM.search(line) - if m and self._status is not None: - atom_idx = m.group(1) - try: - self._status.value = f"Hessian build · atom {int(atom_idx) + 1}…" - except Exception: - self._status.value = f"Hessian build · atom {atom_idx}…" - continue - if _RE_MP2_TRANSFORM.search(line) and self._status is not None: - self._status.value = "MP2 · transforming integrals…" - continue - if _RE_MP2_KERNEL.search(line) and self._status is not None: - self._status.value = "MP2 · correlation kernel…" - continue - m = _RE_CONV.search(line) - if m: - if self._status is not None: - self._status.value = "SCF converged ✓" - if not self._scf_converged_seen and self._on_scf_converged is not None: - self._scf_converged_seen = True - try: - self._on_scf_converged() - except Exception: - pass - - def set_status(self, message: str) -> None: - """Update the live status label WITHOUT appending to the log. - - Calc modules (optimizer / pes / reorg) call this via - ``log_utils.emit_status`` to surface a stage label ("Optimizing — - step k…") during silent (``verbose=0``) phases, without cluttering the - output log the way a ``[QuantUI_STATUS]`` stream line would. - """ - self._enter_stage(self._stage_key(message)) - if self._status is not None: - try: - self._status.value = message - except Exception: - pass - - def set_progress_fraction(self, fraction: float) -> None: - """Record a completion fraction (0..1) for the live remaining-time chip. - - Calc modules call this via ``log_utils.emit_progress`` - when they know a real completion fraction (PES points, optimizer fmax - trend). The elapsed ticker reads it off the active log and prefers a - self-correcting ``elapsed·(1−f)/f`` estimate over the static total. - """ - try: - f = float(fraction) - except (TypeError, ValueError): - return - # Clamp below 1.0 so the chip never claims "0s left" while work remains. - self._fraction = max(0.0, min(f, 0.999)) - - def flush(self) -> None: - pass - - def close(self) -> None: - """No-op — required so ASE treats this as an already-open stream. - - ASE's ``IOContext.openfile()`` (used by ``BFGS(..., logfile=...)`` - in optimizer.py / pes_scan.py) checks ``hasattr(file, "close")`` to - decide whether *file* is an already-open, file-like object it - should leave alone, vs. a path string it should ``open()`` itself. - Without this method, ase>=3.22 (the floor this project pins) still - happened to work via a later refactor's more lenient check, but - ase==3.26.0 (the newest version pip resolves for Python 3.9) hits - the stricter ``openfile()`` and raises - ``TypeError: expected str, bytes or os.PathLike object`` — a real - Python-3.9-specific compatibility gap the CI matrix - expansion caught. - """ - - def getvalue(self) -> str: - return self._buf.getvalue() - - def seed_prior(self, text: str) -> None: - """Prepend output from an earlier interrupted chunk (resume / ISSUE.9). - - Seeds the capture buffer and the live Output widget so the archived - ``pyscf.log`` and the on-screen log both show the full story. - """ - if not text: - return - try: - self._w.append_stdout(text) - except Exception: # noqa: BLE001 — never let the log kill a run - pass - self._buf.write(text) - - -# ══ ANALYSIS CONTEXT ═════════════════════════════════════════════════════════ - - -@dataclass -class _AnalysisContext: - """All data needed to populate Analysis panels for one result. - - Created by ``_do_run()`` for live results and by the history loaders for - saved results. Passed to ``QuantUIApp._apply_analysis_context()``, which - uses ``_PANEL_REGISTRY`` to populate and activate the appropriate panels. - """ - - calc_type: str # "single_point" | "geometry_opt" | etc. - formula: str - method: str - basis: str - live_result: Any = None # result object from _do_run; None for history - result_dir: Optional[Any] = None # Path to saved dir; None before save_result - molecule: Optional[Any] = None # molecule used for the calculation - spectra_data: dict = field(default_factory=dict) # from save_spectra / disk - preopt_result: Optional[Any] = None # OptimizationResult from pre-opt step - timestamp: str = "" # result timestamp shown in history dropdown labels - source: str = "live" # "live" | "history" - - @property - def label(self) -> str: - if self.method: - return f"{self.formula} {self.method}/{self.basis}" - return self.formula - - -# ══ APP CLASS ════════════════════════════════════════════════════════════════ - - -class QuantUIApp: - """ - Self-contained QuantUI application widget. - - Instantiate once; call ``display()`` to inject CSS and show the UI:: - - app = QuantUIApp() - app.display() - """ - - if TYPE_CHECKING: - # Attributes initialized in companion builder modules. Keeping these - # declarations here avoids attr-defined churn during phased extraction. - _clear_log_cache_btn: Any - _clear_log_cache_confirm_btn: Any - _exit_btn: Any - _exit_output: Any - _exit_cancel_btn: Any - _exit_warn_html: Any - _help_btn: Any - _issue_btn: Any - _issue_cancel_btn: Any - _issue_overlay: Any - _issue_status_html: Any - _issue_submit_btn: Any - _issue_textarea: Any - _cal_accordion: Any - _cal_mode_toggle: Any - _cal_progress: Any - _cal_results_html: Any - _cal_run_btn: Any - _cal_step_label: Any - _cal_stop_btn: Any - _cal_skip_btn: Any - _log_clear_btn: Any - _log_output_html: Any - _log_source_lbl: Any - _perf_accordion: Any - _perf_events_html: Any - _perf_stats_html: Any - _reset_btn: Any - _reset_confirm_box: Any - _reset_confirm_html: Any - _reset_confirm_no: Any - _reset_confirm_yes: Any - _resume_cb: Any - _resume_discard_btn: Any - _resume_entries: Any - _resume_list_box: Any - _resume_list_dd: Any - _resume_list_html: Any - _resume_notice_html: Any - _resume_restore_btn: Any - _status_html: Any - _status_tab_panel: Any - _theme_style: Any - _welcome_html: Any - _welcome_header: Any - _activity_btn: Any - advanced_accordion: Any - calc_setup_panel: Any - change_mol_btn: Any - copy_path_btn: Any - compare_btn: Any - compare_clear_btn: Any - compare_output: Any - compare_panel: Any - compare_refresh_btn: Any - compare_select: Any - files_tab_panel: Any - _files_entries: Any - _files_open_btn: Any - _files_path_html: Any - _files_preview_output: Any - _files_refresh_btn: Any - _files_root_dd: Any - _files_status_html: Any - _files_up_btn: Any - gpu_enabled_cb: Any - density_fit_enabled_cb: Any - freq_parallel_enabled_cb: Any - execution_backend_dd: Any - quantum_engine_dd: Any - quantum_engine_note: Any - engine_capability_html: Any - _engine_run_intro: Any - help_content_html: Any - help_tab_panel: Any - help_topic_dd: Any - history_panel: Any - log_tab_panel: Any - mol_input_collapsed: Any - mol_input_container: Any - mol_input_expanded: Any - past_dd: Any - past_output: Any - past_refresh_btn: Any - history_search: Any - history_filter_clear_btn: Any - history_count_lbl: Any - history_method_dd: Any - history_basis_dd: Any - history_date_from: Any - history_date_to: Any - _history_calc_chips: Any - _history_status_chips: Any - lib_category_dd: Any - lib_search_txt: Any - lib_results_dd: Any - lib_count_lbl: Any - pubchem_btn: Any - pubchem_msg: Any - pubchem_txt: Any - pubchem_candidates_dd: Any - result_output: Any - result_viz_output: Any - results_path_lbl: Any - run_btn: Any - cancel_btn: Any - basis_fix_btn: Any - run_output: Any - run_panel: Any - run_status: Any - _run_elapsed_lbl: Any - _slurm_job_banner: Any - _slurm_reconnect_btn: Any - _slurm_jobs_summary_html: Any - _slurm_jobs_table_html: Any - _slurm_jobs_select: Any - _slurm_jobs_refresh_btn: Any - _slurm_jobs_view_btn: Any - _slurm_jobs_cancel_btn: Any - _slurm_jobs_remove_btn: Any - _slurm_jobs_status_html: Any - slurm_jobs_tab_panel: Any - _slurm_jobs_tab_index: int | None - _calculate_tab_panel: Any - _root_tab_order_cache: list[str] - solvent_cb: Any - solvent_dd: Any - step_progress: Any - theme_btn: Any - vib_framerate_si: Any - viz_backend_label_ana: Any - viz_backend_toggle: Any - viz_backend_toggle_ana: Any - viz_controls_box: Any - viz_default_backend_dd: Any - viz_lighting_dd: Any - viz_output: Any - viz_style_dd: Any - view_log_btn: Any - xyz_area: Any - xyz_btn: Any - xyz_msg: Any - xyz_add_atom_btn: Any - xyz_apply_table_btn: Any - xyz_cleanup_accept_btn: Any - xyz_cleanup_btn: Any - xyz_cleanup_preview: Any - xyz_cleanup_preview_box: Any - xyz_cleanup_reject_btn: Any - xyz_fill_table_btn: Any - xyz_table_box: Any - _freq_preopt_cb: Any - _freq_perturb_box: Any - _freq_perturb_fraction: Any - _freq_perturb_mode_dd: Any - _seed_dd: Any - _seed_note: Any - _seed_refresh_btn: Any - _geo_seed_dd: Any - _geo_seed_note: Any - _freq_seed_dd: Any - _freq_seed_note: Any - _freq_seed_refresh_btn: Any - _tddft_seed_dd: Any - _go_analysis_btn: Any - _go_results_btn: Any - _ir_export_btn: Any - _ir_export_fmt_dd: Any - _ir_export_status: Any - _ir_fig: Any - _ir_fwhm_slider: Any - _ir_mode_toggle: Any - _ir_accordion: Any - _ir_copy_data_btn: Any - _raman_export_btn: Any - _raman_export_fmt_dd: Any - _raman_export_status: Any - _raman_fig: Any - _raman_fwhm_slider: Any - _raman_mode_toggle: Any - _raman_accordion: Any - _raman_copy_data_btn: Any - _iso_accordion: Any - _iso_generate_btn: Any - _iso_cancel_btn: Any - _iso_colors_dd: Any - _iso_export_cube_btn: Any - _iso_isovalue_slider: Any - _iso_opacity_slider: Any - _iso_wireframe_cb: Any - _iso_resolution_dd: Any - _last_result_dir: Any - _measure_inbox: Any - _measure_js_bridge: Any - _measure_readout: Any - _measure_clear_btn: Any - _measure_help_btn: Any - _measure_controls: Any - _measure_fallback_msg: Any - _measure_panel: Any - _measure_picks: Any - _mulliken_accordion: Any - _mulliken_summary: Any - _mulliken_table: Any - _mulliken_fig: Any - _mulliken_help_btn: Any - _mulliken_color_cb: Any - _mulliken_dipole_cb: Any - _mulliken_vividness_slider: Any - _mulliken_mol_output: Any - _mulliken_overlay_note: Any - _populations_js_bridge: Any - _nmr_accordion: Any - _nmr_fig: Any - _nmr_nucleus_toggle: Any - _nmr_output: Any - _nmr_summary: Any - _nmr_export_btn: Any - _nmr_export_fmt_dd: Any - _nmr_export_status: Any - _nmr_copy_data_btn: Any - _orb_accordion: Any - _orb_diagram_box: Any - _orb_diagram_html: Any - _orb_export_btn: Any - _orb_export_fmt_dd: Any - _orb_export_status: Any - _orb_copy_data_btn: Any - _orb_iso_controls: Any - _orb_iso_output: Any - _orb_n_orb_input: Any - _orb_font_scale_slider: Any - _orb_index_input: Any - _orb_png_inbox: Any - _orb_toggle: Any - _orb_ymax_input: Any - _orb_ymin_input: Any - _pes_export_btn: Any - _pes_export_fmt_dd: Any - _pes_export_status: Any - _pes_copy_data_btn: Any - _pes_plot_html: Any - _pes_scan_accordion: Any - _reorg_view_toggle: Any - _reorg_overlay_pair: Any - _reorg_exaggerate: Any - _reorg_mode_dd: Any - _reorg_export_btn: Any - _reorg_export_status: Any - _reorg_png_inbox: Any - _result_dir_label: Any - _result_log_accordion: Any - _result_log_output: Any - _scan_atom1: Any - _scan_atom2: Any - _scan_atom3: Any - _scan_atom34_box: Any - _scan_atom4: Any - _scan_help_btn: Any - _scan_grid_dd: Any - _scan_pick_clear_btn: Any - _scan_pick_inbox: Any - _scan_seed_refresh_btn: Any - _scan_start: Any - _scan_steps: Any - _scan_stop: Any - _scan_suggest_around_btn: Any - _scan_suggest_btn: Any - _scan_type_dd: Any - _scan_unit_lbl: Any - _pes_export_min_btn: Any - _tddft_accordion: Any - _tddft_fig: Any - _uv_export_btn: Any - _uv_export_fmt_dd: Any - _uv_export_status: Any - _uv_copy_data_btn: Any - _uv_fwhm_slider: Any - _uv_mode_toggle: Any - _uv_xmin_input: Any - _uv_xmax_input: Any - _uv_range_hint: Any - _to_analysis_btn: Any - _viz_backend: Any - _viz_label: Any - _viz_lighting: Any - _viz_style: Any - _analysis_context_lbl: Any - _analysis_empty_html: Any - _analysis_mol_output: Any - _ana_unavail_html: Any - accumulate_btn: Any - analysis_tab_panel: Any - basis_dd: Any - basis_help_btn: Any - calc_extra_opts: Any - calc_type_dd: Any - calc_type_help_btn: Any - charge_si: Any - charge_mult_apply_btn: Any - charge_mult_suggest_btn: Any - charge_mult_suggest_output: Any - clear_btn: Any - _completion_banner: Any - _completion_mol_lbl: Any - comparison_output: Any - export_btn: Any - export_mol_btn: Any - export_pdb_btn: Any - export_status: Any - export_xyz_btn: Any - _export_bundle_btn: Any - fmax_fi: Any - log_clear_btn: Any - max_steps_si: Any - method_dd: Any - method_help_btn: Any - mol_info_html: Any - mol_summary_compact: Any - mult_si: Any - _method_card_html: Any - _basis_card_html: Any - _descriptor_cards_box: Any - _open_shell_hint: Any - spin_metal_dd: Any - spin_ox_si: Any - spin_geom_dd: Any - spin_suggest_btn: Any - spin_helper_output: Any - spin_apply_btns: Any - spin_helper_box: Any - nstates_si: Any - perf_estimate_html: Any - post_calc_panel: Any - preopt_preview_label: Any - preopt_preview_btn: Any - preopt_accept_btn: Any - preopt_reset_btn: Any - preopt_preview_status: Any - preopt_preview_output: Any - preopt_preview_box: Any - results_panel: Any - results_tab_panel: Any - struct_export_status: Any - traj_accordion: Any - traj_output: Any - vib_accordion: Any - vib_mode_dd: Any - vib_output: Any - vib_prev_btn: Any - vib_next_btn: Any - _vib_apply_mode_btn: Any - _vib_export_btn: Any - _vib_export_status: Any - _vib_png_inbox: Any - _vib_png_status: Any - _mol_calc_png_inbox: Any - _mol_calc_png_status: Any - _mol_results_png_inbox: Any - _mol_results_png_status: Any - _mol_analysis_png_inbox: Any - _mol_analysis_png_status: Any - _last_vib_molecule: Any - - def __init__(self) -> None: - # ── Instance state ──────────────────────────────────────────────── - self._molecule: Optional[Molecule] = None - self._last_result: Any = None - self._last_calc_type: Optional[str] = None # e.g. "frequency", "single_point" - self._results: List = [] - self._pending_traj_result: Any = None - # Cached for the fresh-path safety net in on_traj_expand: if the - # initial render's outputs go missing before the user views the - # Analysis tab, on_traj_expand re-renders from this cache. Cleared - # by apply_analysis_context at every context reset. - self._last_traj_result: Any = None - # Generation counter for vibrational-animation renders. Each - # mode-dropdown change bumps this so older worker-thread renders - # bail out before they overwrite the newer render's output. - self._vib_render_token: int = 0 - self._traj_render_token: int = 0 - self._iso_render_token: int = 0 - self._last_uv_wavelengths_nm: list[float] = [] - self._last_uv_oscillator_strengths: list[float] = [] - self._last_ir_fig: Any = None - self._last_uv_fig: Any = None - self._last_nmr_fig: Any = None - self._last_orb_fig: Any = None - self._last_orb_info: Any = None - # Orbital state consumed by the Isosurface panel populator. Always - # initialized to None so ``pop_isosurface`` can read the attributes - # via direct access without raising AttributeError on a fresh app - # or on a history-replay where ``orbitals.npz`` is missing. - # ``_apply_analysis_context`` resets these between contexts so stale - # state from a prior calc cannot leak into the next molecule. - self._last_orb_mo_coeff: Any = None - self._last_orb_mo_occ: Any = None - self._last_orb_mol_atom: Any = None - self._last_orb_mol_basis: Any = None - # Mulliken Populations panel state (table + Plotly bar chart). - self._last_mulliken_symbols: Any = None - self._last_mulliken_charges: Any = None - self._last_mulliken_dipole: Any = None - self._last_mulliken_dipole_vector: Any = None - self._last_mulliken_fig: Any = None - # Last-generated cube file path + orbital label. - # Set by the isosurface render path; consumed by the Export cube - # button. Initialized here so the button handler reads ``None`` - # cleanly when no isosurface has been generated yet. - self._last_cube_path: Optional[Path] = None - self._last_cube_orbital: Optional[str] = None - self._last_pes_fig: Any = None - self._run_output_scroll_guard_installed: bool = False - self._files_current_dir: Optional[Path] = None - self._files_selected_path: Optional[Path] = None - self._files_updating: bool = False - self._activity_count: int = 0 - self._activity_compute_count: int = 0 - self._activity_lock = threading.Lock() - # Cancellation + run-in-flight state. ``_cancel_event`` is checked by - # the run's _LogCapture each output line; ``_calc_running`` guards the - # Clear button from wiping output mid-run. - self._cancel_event = threading.Event() - self._calc_running: bool = False - # Stop signal for the live elapsed-time ticker thread. - self._elapsed_stop_event: Optional[threading.Event] = None - # Total run estimate the ticker turns into "time - # remaining"; set by _do_run once estimate_time() has run. - self._run_estimate_s: Optional[float] = None - self._run_estimate_conf: str = "unknown" - # The active run's _LogCapture, so the ticker can read the - # completion fraction calc modules report onto it. None between runs. - self._active_log: Optional[_LogCapture] = None - # Calc types this session has already completed once. The first run - # of a type pays import costs later ones don't (PySCF loads its - # Hessian module on the first Frequency, for instance), so the - # perf record carries a warm/cold flag rather than silently mixing - # the two populations. See calc_log.log_calculation. - self._warm_calc_types: set[str] = set() - # Relaxed molecule from a pending pre-opt preview, awaiting Keep/Revert. - self._preopt_relaxed_mol: Optional[Molecule] = None - self._scan_pick_buffer: list[int] = [] - # Cache kernel io_loop once on the main thread so worker threads can - # reliably schedule UI callbacks even when get_ipython() is thread-local. - self._kernel_io_loop: Any = getattr( - getattr(get_ipython(), "kernel", None), "io_loop", None - ) - self.root_tab: widgets.Tab - self._session_id: str = _uuid.uuid4().hex[:12] - - # Availability (copied from module-level flags) - self._pyscf_available: bool = _PYSCF_AVAILABLE - self._preopt_available: bool = _PREOPT_AVAILABLE - - # User settings (persisted in ~/.quantui/settings.json) + viz - # backend availability snapshot. The router consumes these; render - # call sites will be migrated to the router. - self._user_settings: UserSettings = UserSettings.load() - self._viz_availability: BackendAvailability = ( - BackendAvailability.from_environment() - ) - self._viz_backend_preference: str = self._user_settings.viz.default_backend - - # Synchronization state for Calculate/Analysis backend toggles. - # When _set_viz_backend updates one toggle, it sets this flag so the - # other toggle's observer can short-circuit and avoid an echo loop. - self._viz_sync_in_progress: bool = False - # Molecule currently rendered into _analysis_mol_output. Updated by - # show_result_3d; consumed by _set_viz_backend to re-render the - # Analysis-tab viewer when the toggle changes. - self._analysis_displayed_molecule: Any = None - self._mulliken_displayed_molecule: Any = None - self._mulliken_pending_molecule: Any = None - - # ── Build → wire → assemble ─────────────────────────────────────── - self._build_widgets() - - # Resolve the persisted preference through the router and align all - # three preference widgets + _viz_backend with the router decision. - # Observers are NOT yet wired so widget assignments don't trigger - # render side-effects — this is pure initial-state alignment. - self._initialize_viz_state_from_preference() - self._apply_quantum_engine_capabilities() - - self._wire_callbacks() - self._assemble_tabs() - - # Log startup, but never let optional logging I/O break app startup. - # Include the loaded viz backend preference so a "it reset" report - # can be confirmed against what was actually persisted. - try: - _calc_log.log_event( - "startup", - f"QuantUI {quantui.__version__} started " - f"(viz backend pref={self._viz_backend_preference})", - ) - except OSError: - pass - - # Kick off slow startup work (GPU detection, History/Compare loading) - # off the synchronous construction path so the UI paints fast. - self._start_deferred_startup_tasks() - - def _start_deferred_startup_tasks(self) -> None: - """Run slow startup work AFTER widget construction so it doesn't block - first paint. - - - **GPU detection** imports gpu4pyscf + cupy and queries CUDA (~7 s); it - runs on a daemon thread, then re-renders the Status badge on the kernel - loop. ``is_gpu_available`` is lru-cached, so the run dispatcher reuses - the result with no extra cost. - - **History + Compare** population loads every saved result; deferring it - onto the kernel io loop lets it run right after the cell returns (UI - already painted), and the summary sidecar keeps it fast. Falls back to - inline when there is no kernel loop (tests / plain scripts). - """ - - def _detect_gpu() -> None: - # Warm the run-header's system-info cache (lru_cache; may shell out - # to nvidia-smi) off the main thread so the synchronous header write - # in on_run_clicked stays instant on the first calc. - try: - from quantui.log_utils import get_system_info - - get_system_info() - except Exception: # noqa: BLE001 — warm-up is best-effort - pass - try: - from quantui.gpu_offload import probe_gpu - - # probe_gpu (not is_gpu_available) so the badge can show the - # actual reason offload isn't active instead of a generic - # "not installed or no CUDA device". - state = probe_gpu() - except Exception: # noqa: BLE001 — treat any failure as "no GPU" - state = (False, None, "") - render = getattr(self, "_render_status_html", None) - html_widget = getattr(self, "_status_html", None) - if render is None or html_widget is None: - return - - def _apply() -> None: - try: - html_widget.value = render(state) - except Exception: - pass - - loop = self._get_kernel_io_loop() - if loop is not None: - loop.add_callback(_apply) - else: - _apply() - - threading.Thread( - target=_detect_gpu, daemon=True, name="quantui-gpu-detect" - ).start() - - loop = self._get_kernel_io_loop() - if loop is not None: - loop.add_callback(self._refresh_results_browser) - loop.add_callback(self._populate_compare_list) - # Startup is the moment that matters for CHK.6: after a restart - # the targeted resume offer can't fire, because nothing is - # configured yet. - loop.add_callback(self._refresh_resume_list) - loop.add_callback(_slurm_startup_check, self) - else: - self._refresh_results_browser() - self._populate_compare_list() - self._refresh_resume_list() - _slurm_startup_check(self) - - def display(self) -> None: - """Inject global CSS and render the application widget.""" - app_css = _APP_CSS.removeprefix("") - display(HTML(_theme.theme_injection_html(app_css, style_id="quantui-app-css"))) - # NOTE: 3Dmol.js is loaded offline per-view via py3Dmol's own loader - # (``viz_assets.make_view`` passes ``js=``), NOT a - # one-time page bootstrap. A startup-time bootstrap ran py3Dmol's - # exports/module-juggling loader during Voilà's RequireJS bootstrap and - # broke widget startup offline — never reintroduce it here. - display( - widgets.VBox( - [ - self._welcome_header, - widgets.HBox( - [ - self._activity_btn, - self.theme_btn, - self._help_btn, - self._issue_btn, - self._exit_warn_html, - self._exit_cancel_btn, - self._exit_btn, - ], - layout=_layout(justify_content="flex-end", margin="0 0 4px"), - ), - self._issue_overlay, - self._exit_output, - self._theme_style, - self.help_tab_panel, - self.root_tab, - ] - ) - ) - self._install_run_output_scroll_guard() - - @property - def widget(self) -> widgets.Tab: - """The root tab widget (for callers that want the widget object).""" - return self.root_tab - - # ══ BUILD METHODS ════════════════════════════════════════════════════════ - - def _build_widgets(self) -> None: - self._build_theme_selector() - self._build_status_panel() - self._build_welcome_header() - self._build_shared_widgets() - self._build_molecule_section() - self._build_calc_setup() - self._build_run_section() - self._build_results_section() - self._build_history_section() - self._build_compare_section() - self._build_output_tab() - self._build_slurm_jobs_tab() - self._build_files_tab() - self._build_help_section() - self._build_issue_widgets() - - # ── Theme selector ──────────────────────────────────────────────────── - - def _build_theme_selector(self) -> None: - _bld_build_theme_selector(self, layout_fn=_layout) - - def _theme_css(self, palette_id: str) -> str: - """Return the CSS variable block for *palette_id*.""" - return _theme.theme_css_block(palette_id) - - def _set_activity_indicator(self, state: str = "idle", message: str = "") -> None: - """Update the toolbar activity light state and tooltip.""" - if state == "compute": - self._activity_btn.description = "Computing" - self._activity_btn.icon = "cog" - self._activity_btn.button_style = "warning" - self._activity_btn.tooltip = message or "Running compute operations..." - return - if state == "ui": - self._activity_btn.description = "UI Active" - self._activity_btn.icon = "bolt" - self._activity_btn.button_style = "info" - self._activity_btn.tooltip = message or "Running UI operations..." - return - - self._activity_btn.description = "Idle" - self._activity_btn.icon = "circle-o" - self._activity_btn.button_style = "success" - self._activity_btn.tooltip = "No active operations." - - def _refresh_activity_indicator(self, message: str = "") -> None: - """Recompute activity light state from active operation counters.""" - if self._activity_count <= 0: - self._set_activity_indicator("idle") - return - if self._activity_compute_count > 0: - self._set_activity_indicator("compute", message) - return - self._set_activity_indicator("ui", message) - - def _activity_begin(self, message: str = "", kind: str = "ui") -> None: - """Mark one operation as active.""" - with self._activity_lock: - self._activity_count += 1 - if kind == "compute": - self._activity_compute_count += 1 - self._refresh_activity_indicator(message) - - def _activity_end(self, kind: str = "ui") -> None: - """Mark one operation as finished.""" - with self._activity_lock: - if self._activity_count > 0: - self._activity_count -= 1 - if kind == "compute" and self._activity_compute_count > 0: - self._activity_compute_count -= 1 - self._refresh_activity_indicator() - - def _activity_pulse( - self, message: str, hold_s: float = 0.18, kind: str = "ui" - ) -> None: - """Briefly light the activity indicator for quick operations.""" - self._activity_begin(message, kind=kind) - timer = threading.Timer( - max(0.05, hold_s), - self._activity_end, - kwargs={"kind": kind}, - ) - timer.daemon = True - timer.start() - - def _on_root_tab_changed(self, change) -> None: - """Pulse the activity light on tab navigation actions.""" - self._activity_pulse("Switching tabs...", hold_s=0.16, kind="ui") - if change.get("new") == self._tab_index("analysis"): - _ana_scroll_analysis_tab_to_top(self) - if _slurm_jobs_tab_visible(self) and change.get("new") == self._tab_index( - "slurm_jobs" - ): - _slurm_refresh_jobs_tab(self) - - def _go_to_calculate_tab(self) -> None: - """Navigate to the Calculate tab.""" - self._activity_pulse("Navigating to Calculate tab...", hold_s=0.16, kind="ui") - self.root_tab.selected_index = self._tab_index("calculate") - - def _go_to_results_tab(self, _btn) -> None: - """Navigate to Results tab with a visible activity pulse.""" - self._activity_pulse("Navigating to Results tab...", hold_s=0.16, kind="ui") - self.root_tab.selected_index = self._tab_index("results") - - def _go_to_analysis_tab(self, _btn) -> None: - """Navigate to Analysis tab with a visible activity pulse.""" - self._activity_pulse("Navigating to Analysis tab...", hold_s=0.16, kind="ui") - self.root_tab.selected_index = self._tab_index("analysis") - _ana_scroll_analysis_tab_to_top(self) - - # ── Status panel ────────────────────────────────────────────────────── - - def _build_status_panel(self) -> None: - _bld_build_status_panel( - self, - layout_fn=_layout, - get_session_resources_fn=get_session_resources, - load_last_calibration_label_fn=_load_last_calibration_label, - pyscf_available=_PYSCF_AVAILABLE, - pyfock_available=_PYFOCK_AVAILABLE, - ase_available=ASE_AVAILABLE, - pubchem_available=PUBCHEM_AVAILABLE, - visualization_available=VISUALIZATION_AVAILABLE, - viz_default_backend=self._user_settings.viz.default_backend, - vib_framerate_fps=self._user_settings.viz.vib_framerate_fps, - gpu_enabled=self._user_settings.compute.gpu_enabled, - density_fit_enabled=self._user_settings.compute.density_fit, - freq_parallel_enabled=( - freq_parallel_opt_in() - if freq_parallel_env_configured() - else self._user_settings.compute.freq_parallel - ), - freq_parallel_env_locked=freq_parallel_env_configured(), - execution_backend=self._user_settings.compute.execution_backend, - slurm_available=is_slurm_available(), - quantum_engine=self._user_settings.compute.quantum_engine, - ) - - # ── Welcome header ──────────────────────────────────────────────────── - - def _build_welcome_header(self) -> None: - _bld_build_welcome_header(self, layout_fn=_layout) - - # ── Shared widgets (Cell 3) ─────────────────────────────────────────── - - def _build_shared_widgets(self) -> None: - _bld_build_shared_widgets( - self, - layout_fn=_layout, - step_progress_cls=StepProgress, - supported_methods=SUPPORTED_METHODS, - supported_basis_sets=SUPPORTED_BASIS_SETS, - default_method=DEFAULT_METHOD, - default_basis=DEFAULT_BASIS, - default_charge=DEFAULT_CHARGE, - default_multiplicity=DEFAULT_MULTIPLICITY, - default_fmax=DEFAULT_FMAX, - default_opt_steps=DEFAULT_OPT_STEPS, - preopt_available=_PREOPT_AVAILABLE, - visualization_available=VISUALIZATION_AVAILABLE, - both_viz_available=_BOTH_VIZ_AVAILABLE, - default_viz_backend=_DEFAULT_VIZ_BACKEND, - default_viz_style=_DEFAULT_VIZ_STYLE, - default_lighting=_DEFAULT_LIGHTING, - viz_style_options=_VIZ_STYLE_OPTIONS, - plotlymol_viz=_PLOTLYMOL_VIZ, - lighting_options=_LIGHTING_OPTIONS, - rdkit_available=_RDKIT_AVAILABLE, - ) - - # ── Molecule section (Cell 4) ───────────────────────────────────────── - - def _build_molecule_section(self) -> None: - _bld_build_molecule_section( - self, - layout_fn=_layout, - pubchem_available=PUBCHEM_AVAILABLE, - visualization_available=VISUALIZATION_AVAILABLE, - ) - - # ── Calculation setup panel (Cell 5) ────────────────────────────────── - - def _build_calc_setup(self) -> None: - _bld_build_calc_setup(self, layout_fn=_layout) - - # ── Run panel (Cell 6) ──────────────────────────────────────────────── - - def _build_run_section(self) -> None: - _bld_build_run_section(self, layout_fn=_layout) - - # ── Results panel (Cell 7) ──────────────────────────────────────────── - - def _build_results_section(self) -> None: - _bld_build_results_section(self, layout_fn=_layout) - - # ── Analysis panel switcher ─────────────────────────────────────────── - - def _build_ana_switcher(self) -> None: - _ana_build_ana_switcher(self, layout_fn=_layout) - - 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 - ) - - def _on_raman_accordion_show(self, change) -> None: - if change["new"] == 0 and getattr(self, "_last_raman_freqs", None): - self._update_raman_figure( - self._raman_mode_toggle.value, self._raman_fwhm_slider.value - ) - - def _on_tddft_accordion_show(self, change) -> None: - if change["new"] == 0 and getattr(self, "_last_uv_wavelengths_nm", None): - self._update_uv_vis_figure( - self._uv_mode_toggle.value, - self._uv_fwhm_slider.value, - ) - - def _on_nmr_accordion_show(self, change) -> None: - if change["new"] == 0 and getattr(self, "_last_nmr_atom_symbols", None): - self._update_nmr_figure(self._nmr_nucleus_toggle.value) - - 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 _on_mulliken_accordion_show(self, change) -> None: - if change["new"] == 0 and getattr(self, "_last_mulliken_charges", None): - _ana_update_mulliken_figure(self) - self._show_mulliken_viewer() - - def _select_ana_panel(self, name: str) -> None: - _ana_select_ana_panel(self, name) - - def _activate_ana_panel(self, name: str, auto_select: bool = True) -> None: - _ana_activate_ana_panel(self, name, auto_select=auto_select) - - def _deactivate_all_ana_panels(self) -> None: - _ana_deactivate_all_ana_panels(self) - - # ── Panel registry and unified applier ─────────────────────────────────── - # - # _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: - # • populate_method_name is a string — looked up via getattr at runtime. - # • auto_select=True on the FIRST entry that returns True marks that - # panel as the primary scroll target; every panel with data expands. - # • 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"), - ("Populations", "_mulliken_accordion", "Single Point / Geometry Opt"), - ("Trajectory", "traj_accordion", "Geometry Opt / PES Scan / Frequency pre-opt"), - ("Vibrational", "vib_accordion", "Frequency"), - ("IR Spectrum", "_ir_accordion", "Frequency"), - ("Raman Spectrum", "_raman_accordion", "Frequency"), - ("PES Scan", "_pes_scan_accordion", "PES Scan"), - ("Isosurface", "_iso_accordion", "Single Point (Linux/WSL only)"), - ("Geometries", "_reorg_geom_accordion", "Reorganization Energy"), - ("UV-Vis", "_tddft_accordion", "UV-Vis (TD-DFT)"), - ("NMR", "_nmr_accordion", "NMR Shielding"), - ] - - _PANEL_REGISTRY: ClassVar[dict] = { - "single_point": [ - ("Energies", "_pop_energies", True), - ("Isosurface", "_pop_isosurface", False), - ("Populations", "_pop_mulliken", False), - ], - "geometry_opt": [ - # ORDER MATTERS: the FIRST entry whose populator returns True and - # carries auto_select=True becomes the default panel (see the rules - # above). Isosurface therefore leads (requested 2026-08-04); an - # earlier attempt put it last, which did nothing because Trajectory - # had already claimed the selection. - # - # Trajectory keeps auto_select=True as the fallback: when a result - # has no orbital data, _pop_isosurface returns False, Isosurface - # never activates, and Trajectory becomes the default instead. - # ORDER IS LOAD-BEARING TWICE OVER. - # - # 1. Execution: _pop_energies calls show_orbital_diagram, which is - # what populates _last_orb_mo_coeff / _mol_atom / _mol_basis — - # the very state _pop_isosurface checks. Energies MUST run - # first, or Isosurface reports "required data is missing" on a - # result that has it. (Putting Isosurface first did exactly - # that, 2026-08-04.) - # 2. Selection: the FIRST entry with auto_select=True that returns - # True becomes the default panel. Energies is False, so - # Isosurface is the first candidate and opens by default — - # which is the request — while Trajectory keeps True as the - # fallback for results with no orbital data. - ("Energies", "_pop_energies", False), - ("Isosurface", "_pop_isosurface", True), - ("Trajectory", "_pop_geo_trajectory", True), - ("Populations", "_pop_mulliken", False), - ], - "frequency": [ - ("Vibrational", "_pop_vibrational", True), - ("IR Spectrum", "_pop_ir_spectrum", True), - ("Raman Spectrum", "_pop_raman_spectrum", False), - ("Trajectory", "_pop_preopt_trajectory", False), - ("Energies", "_pop_energies", True), - ], - "tddft": [ - ("UV-Vis", "_pop_uv_vis", True), - ], - "nmr": [ - ("NMR", "_pop_nmr_shielding", True), - ], - # reorganization_energy had NO entry at all until 2026-08-05, so the - # Analysis tab populated nothing for these runs — not a missing panel, - # no panels. Order matters twice over: the FIRST auto_select=True that - # returns True wins, AND _pop_energies loads the orbital state - # _pop_isosurface checks, so Energies must precede Isosurface. - "reorganization_energy": [ - ("Energies", "_pop_energies", False), - ("Geometries", "_pop_reorg_geometries", True), - ("Isosurface", "_pop_isosurface", False), - ], - "pes_scan": [ - ("PES Scan", "_pop_pes_plot", True), - ("Trajectory", "_pop_pes_trajectory", False), - ], - } - - def _apply_analysis_context(self, ctx: _AnalysisContext) -> None: - _ana_apply_analysis_context(self, ctx) - - # ── Panel populate methods ──────────────────────────────────────────────── - # Each receives an _AnalysisContext and returns True if data was rendered. - - def _pop_energies(self, ctx: _AnalysisContext) -> bool: - return _ana_pop_energies(self, ctx) - - def _pop_reorg_geometries(self, ctx: _AnalysisContext) -> bool: - return _ana_pop_reorg_geometries(self, ctx) - - def _pop_isosurface(self, ctx: _AnalysisContext) -> bool: - return _ana_pop_isosurface(self, ctx) - - def _pop_geo_trajectory(self, ctx: _AnalysisContext) -> bool: - return _ana_pop_geo_trajectory(self, ctx) - - def _pop_preopt_trajectory(self, ctx: _AnalysisContext) -> bool: - return _ana_pop_preopt_trajectory(self, ctx) - - def _pop_vibrational(self, ctx: _AnalysisContext) -> bool: - return _ana_pop_vibrational(self, ctx) - - def _pop_ir_spectrum(self, ctx: _AnalysisContext) -> bool: - return _ana_pop_ir_spectrum(self, ctx) - - def _pop_raman_spectrum(self, ctx: _AnalysisContext) -> bool: - return _ana_pop_raman_spectrum(self, ctx) - - def _pop_uv_vis(self, ctx: _AnalysisContext) -> bool: - return _ana_pop_uv_vis(self, ctx) - - def _pop_nmr_shielding(self, ctx: _AnalysisContext) -> bool: - return _ana_pop_nmr_shielding(self, ctx) - - def _pop_mulliken(self, ctx: _AnalysisContext) -> bool: - return _ana_pop_mulliken(self, ctx) - - def _pop_pes_plot(self, ctx: _AnalysisContext) -> bool: - return _ana_pop_pes_plot(self, ctx) - - def _pop_pes_trajectory(self, ctx: _AnalysisContext) -> bool: - return _ana_pop_pes_trajectory(self, ctx) - - # ── History panel (Cell 8) ──────────────────────────────────────────── - - def _build_history_section(self) -> None: - _bld_build_history_section( - self, - layout_fn=_layout, - pyscf_available=_PYSCF_AVAILABLE, - benchmark_suite=_BENCHMARK_SUITE, - benchmark_suite_long=_BENCHMARK_SUITE_LONG, - load_last_calibration_label_fn=_load_last_calibration_label, - ) - - # ── Compare panel (Cell 9) ──────────────────────────────────────────── - - def _build_compare_section(self) -> None: - _bld_build_compare_section( - self, - layout_fn=_layout, - rdkit_available=_RDKIT_AVAILABLE, - ) - - # ── Output log tab (Cell 10) ────────────────────────────────────────── - - def _build_output_tab(self) -> None: - _bld_build_output_tab(self, layout_fn=_layout) - - # ── Files tab (Cell 11) ─────────────────────────────────────────────── - - def _build_slurm_jobs_tab(self) -> None: - _bld_build_slurm_jobs_tab(self, layout_fn=_layout) - - def _build_files_tab(self) -> None: - _bld_build_files_tab(self, layout_fn=_layout) - self._refresh_file_browser() - - # ── Help section (Cell 12) ──────────────────────────────────────────── - - def _build_help_section(self) -> None: - _bld_build_help_section(self, layout_fn=_layout) - - def _build_issue_widgets(self) -> None: - _bld_build_issue_widgets(self, layout_fn=_layout) - - # ── Tab assembly (Cell 10) ──────────────────────────────────────────── - - def _root_tab_order(self) -> list[str]: - order = ["calculate"] - if _slurm_jobs_tab_visible(self): - order.append("slurm_jobs") - order.extend(["results", "analysis", "history", "compare", "files", "settings"]) - return order - - def _tab_index(self, name: str) -> int: - return self._root_tab_order().index(name) - - def _tab_panel_for(self, name: str) -> Any: - panels = { - "calculate": self._calculate_tab_panel, - "slurm_jobs": self.slurm_jobs_tab_panel, - "results": self.results_tab_panel, - "analysis": self.analysis_tab_panel, - "history": self.history_panel, - "compare": self.compare_panel, - "files": self.files_tab_panel, - "settings": self._status_tab_panel, - } - return panels[name] - - def _sync_root_tab_layout(self) -> None: - """Insert or remove the Cluster Jobs tab when execution backend changes.""" - order = self._root_tab_order() - prev_order = getattr(self, "_root_tab_order_cache", order) - preserve_name: str | None = None - try: - old_idx = self.root_tab.selected_index - if 0 <= old_idx < len(prev_order): - preserve_name = prev_order[old_idx] - except Exception: # noqa: BLE001 — tab index is cosmetic - preserve_name = None - - self.root_tab.children = tuple(self._tab_panel_for(name) for name in order) - title_map = { - "calculate": "Calculate", - "slurm_jobs": "Cluster Jobs", - "results": "Results", - "analysis": "Analysis", - "history": "History", - "compare": "Compare", - "files": "Files", - "settings": "System Settings", - } - for index, name in enumerate(order): - self.root_tab.set_title(index, title_map[name]) - - self._slurm_jobs_tab_index = ( - order.index("slurm_jobs") if "slurm_jobs" in order else None - ) - self._root_tab_order_cache = list(order) - - if "slurm_jobs" in order: - _slurm_refresh_jobs_tab(self) - from quantui.app_slurm import _update_slurm_jobs_tab_title - - _update_slurm_jobs_tab_title(self) - - if preserve_name in order: - self.root_tab.selected_index = order.index(preserve_name) - - def _assemble_tabs(self) -> None: - self._calculate_tab_panel = widgets.VBox( - [ - self.step_progress.widget, - self.mol_input_container, - self.calc_setup_panel, - self.run_panel, - self._completion_banner, - ], - layout=_layout(padding="8px 0"), - ) - - # Splice advanced_accordion into results_tab_panel before _to_analysis_btn. - # It cannot be referenced in _build_results_section because it is built later - # in _build_compare_section. - _rtp = list(self.results_tab_panel.children) - _rtp.insert(_rtp.index(self._to_analysis_btn), self.advanced_accordion) - self.results_tab_panel.children = tuple(_rtp) - - self._slurm_jobs_tab_index = None - self._root_tab_order_cache: list[str] = [] - self.root_tab = widgets.Tab(children=()) - self._sync_root_tab_layout() - self.root_tab.observe( - self._safe_cb(self._on_root_tab_changed), names="selected_index" - ) - - # ══ CALLBACK WIRING ══════════════════════════════════════════════════════ - - 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._safe_cb(self._on_viz_backend_changed), names="value" - ) - # Analysis-tab backend toggle (only wired when both backends available). - if self.viz_backend_toggle_ana is not None: - self.viz_backend_toggle_ana.observe( - self._safe_cb(self._on_viz_backend_changed_ana), names="value" - ) - # Settings → "Default 3D backend" preference (Status tab; persisted). - self.viz_default_backend_dd.observe( - self._safe_cb(self._on_viz_default_backend_changed), names="value" - ) - # Settings → Vibrational animation framerate (Status tab; persisted). - self.vib_framerate_si.observe( - self._safe_cb(self._on_vib_framerate_changed), names="value" - ) - # Settings → GPU offload on/off (Status tab; persisted). - self.gpu_enabled_cb.observe( - self._safe_cb(self._on_gpu_enabled_changed), names="value" - ) - # Settings → density fitting (RI) on/off (Status tab; persisted). - self.density_fit_enabled_cb.observe( - self._safe_cb(self._on_density_fit_enabled_changed), names="value" - ) - self.freq_parallel_enabled_cb.observe( - self._safe_cb(self._on_freq_parallel_enabled_changed), names="value" - ) - self.execution_backend_dd.observe( - self._safe_cb(self._on_execution_backend_changed), names="value" - ) - self.quantum_engine_dd.observe( - self._safe_cb(self._on_quantum_engine_changed), names="value" - ) - # 3D viewer style and lighting controls - if VISUALIZATION_AVAILABLE: - 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._safe_cb(self._on_theme_changed), names="value") - # Molecule input — library browse/search - self.lib_category_dd.observe( - self._safe_cb(self._on_lib_filter_changed), names="value" - ) - self.lib_search_txt.observe( - self._safe_cb(self._on_lib_filter_changed), names="value" - ) - self.lib_results_dd.observe(self._safe_cb(self._on_lib_select), names="value") - self.xyz_btn.on_click(self._on_load_xyz) - self.xyz_add_atom_btn.on_click(self._on_xyz_add_atom) - self.xyz_fill_table_btn.on_click(self._on_xyz_fill_table) - self.xyz_apply_table_btn.on_click(self._on_xyz_apply_table) - self.xyz_cleanup_btn.on_click(self._on_xyz_cleanup) - self.xyz_cleanup_accept_btn.on_click(self._on_xyz_cleanup_accept) - self.xyz_cleanup_reject_btn.on_click(self._on_xyz_cleanup_reject) - self.pubchem_btn.on_click(self._on_search_pubchem) - self.pubchem_candidates_dd.observe( - self._safe_cb(self._on_pubchem_candidate_selected), names="value" - ) - self.change_mol_btn.on_click(self._on_expand_mol_input) - # Calc type - self.calc_type_dd.observe( - self._safe_cb(self._on_calc_type_changed), names="value" - ) - # Geometry Opt / Frequency / UV-Vis (TD-DFT) share one seed-geometry - # dropdown + refresh button — only one observer/click binding is needed, - # not three, since `_geo_seed_dd`, `_freq_seed_dd` and `_tddft_seed_dd` - # are the same underlying widget. - self._seed_dd.observe(self._safe_cb(self._on_seed_changed), names="value") - self._seed_refresh_btn.on_click(lambda _btn: self._refresh_seed_options()) - self._scan_type_dd.observe( - self._safe_cb(self._update_scan_widgets), names="value" - ) - for _scan_atom_w in ( - self._scan_atom1, - self._scan_atom2, - self._scan_atom3, - self._scan_atom4, - ): - _scan_atom_w.observe( - self._safe_cb(self._on_scan_atom_changed), names="value" - ) - self._scan_suggest_btn.on_click( - self._safe_cb(lambda _btn: _run_apply_suggested_scan_range(self)) - ) - self._scan_suggest_around_btn.on_click( - self._safe_cb(lambda _btn: _run_apply_scan_range_around_current(self)) - ) - self._scan_seed_refresh_btn.on_click( - self._safe_cb(lambda _btn: _run_refresh_pes_seed_options(self)) - ) - self._scan_pick_inbox.observe( - self._safe_cb(self._on_pes_pick_inbox_changed), names="value" - ) - self._scan_pick_clear_btn.on_click(self._safe_cb(self._on_pes_pick_clear)) - self._scan_help_btn.on_click( - self._safe_cb(lambda _btn: self._show_help_topic("pes_scan")) - ) - # Notes + estimate - self.method_dd.observe(self._safe_cb(self._update_notes), names="value") - self.basis_dd.observe(self._safe_cb(self._update_notes), names="value") - # Multiplicity drives the open-shell hint (part of _update_notes). - self.mult_si.observe(self._safe_cb(self._update_notes), names="value") - # Keep the active molecule's charge/multiplicity in step with the fields, - # so an edit here (or the spin-state helper's Apply) actually reaches the - # run — the calc reads mol.charge/mol.multiplicity, and the pre-run guard - # reads the widgets, so the two must not drift apart. - self.charge_si.observe( - self._safe_cb(self._sync_charge_to_molecule), names="value" - ) - self.mult_si.observe(self._safe_cb(self._sync_mult_to_molecule), 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") - # Unfinished-calculations list (CHK.6) - self._resume_list_dd.observe( - self._safe_cb(self._on_resume_entry_changed), names="value" - ) - self._resume_restore_btn.on_click(self._safe_cb(self._on_resume_restore)) - self._resume_discard_btn.on_click(self._safe_cb(self._on_resume_discard)) - # Help buttons - self.method_help_btn.on_click(self._on_method_help) - self.basis_help_btn.on_click(self._on_basis_help) - self.calc_type_help_btn.on_click(self._on_calc_type_help) - # Run - self.run_btn.on_click(self._on_run_clicked) - self._slurm_reconnect_btn.on_click( - self._safe_cb(lambda _btn: _slurm_on_reconnect_clicked(self, _btn)) - ) - self._slurm_jobs_refresh_btn.on_click( - self._safe_cb(lambda _btn: _slurm_on_jobs_refresh_clicked(self, _btn)) - ) - self._slurm_jobs_view_btn.on_click( - self._safe_cb(lambda _btn: _slurm_on_jobs_view_clicked(self, _btn)) - ) - self._slurm_jobs_cancel_btn.on_click( - self._safe_cb(lambda _btn: _slurm_on_jobs_cancel_clicked(self, _btn)) - ) - self._slurm_jobs_remove_btn.on_click( - self._safe_cb(lambda _btn: _slurm_on_jobs_remove_clicked(self, _btn)) - ) - self.cancel_btn.on_click(self._safe_cb(self._on_cancel)) - self.basis_fix_btn.on_click(self._safe_cb(self._on_basis_fix)) - self.charge_mult_suggest_btn.on_click( - self._safe_cb(self._on_charge_mult_suggest) - ) - self.charge_mult_apply_btn.on_click(self._safe_cb(self._on_charge_mult_apply)) - self.spin_suggest_btn.on_click(self._safe_cb(self._on_spin_suggest)) - self.spin_apply_btns[0].on_click( - self._safe_cb(lambda _b: self._on_spin_apply(0)) - ) - self.spin_apply_btns[1].on_click( - self._safe_cb(lambda _b: self._on_spin_apply(1)) - ) - self.preopt_preview_btn.on_click(self._safe_cb(self._on_preopt_preview)) - self.preopt_accept_btn.on_click(self._safe_cb(self._on_preopt_accept)) - self.preopt_reset_btn.on_click(self._safe_cb(self._on_preopt_reset)) - self.log_clear_btn.on_click(self._on_clear_log) - self._ir_mode_toggle.observe( - self._safe_cb(self._on_ir_mode_changed), names="value" - ) - self._ir_fwhm_slider.observe( - self._safe_cb(self._on_ir_fwhm_changed), names="value" - ) - self._raman_mode_toggle.observe( - self._safe_cb(self._on_raman_mode_changed), names="value" - ) - self._raman_fwhm_slider.observe( - self._safe_cb(self._on_raman_fwhm_changed), names="value" - ) - self._uv_mode_toggle.observe( - self._safe_cb(self._on_uv_mode_changed), names="value" - ) - self._uv_fwhm_slider.observe( - self._safe_cb(self._on_uv_fwhm_changed), names="value" - ) - self._uv_xmin_input.observe( - self._safe_cb(self._on_uv_range_changed), names="value" - ) - self._uv_xmax_input.observe( - self._safe_cb(self._on_uv_range_changed), names="value" - ) - self._nmr_nucleus_toggle.observe( - self._safe_cb(self._on_nmr_nucleus_changed), names="value" - ) - self._ir_export_btn.on_click(self._on_ir_export_plot) - self._raman_export_btn.on_click(self._on_raman_export_plot) - self._uv_export_btn.on_click(self._on_uv_export_plot) - self._nmr_export_btn.on_click(self._on_nmr_export_plot) - self._orb_export_btn.on_click(self._on_orb_export_plot) - self._pes_export_btn.on_click(self._on_pes_export_plot) - self._pes_export_min_btn.on_click(self._on_pes_export_min_geometry) - self._vib_export_btn.on_click(self._on_vib_export_animation) - # Per-panel CSV-to-clipboard / file buttons. - self._ir_copy_data_btn.on_click(self._on_ir_copy_data) - self._raman_copy_data_btn.on_click(self._on_raman_copy_data) - self._uv_copy_data_btn.on_click(self._on_uv_copy_data) - self._nmr_copy_data_btn.on_click(self._on_nmr_copy_data) - self._orb_copy_data_btn.on_click(self._on_orb_copy_data) - self._pes_copy_data_btn.on_click(self._on_pes_copy_data) - # Accumulate / export - self.accumulate_btn.on_click(self._on_accumulate) - self.clear_btn.on_click(self._on_clear) - 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._cal_skip_btn.on_click(self._on_cal_skip) - self.export_btn.on_click(self._on_export) - self.export_xyz_btn.on_click(self._on_export_xyz) - self.export_mol_btn.on_click(self._on_export_mol) - self.export_pdb_btn.on_click(self._on_export_pdb) - self._reorg_export_btn.on_click(self._safe_cb(self._on_export_reorg_geometries)) - # History - 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) - # History search / faceted filters (HIST.7) - for _w in ( - self.history_search, - self.history_method_dd, - self.history_basis_dd, - self.history_date_from, - self.history_date_to, - ): - _w.observe(self._safe_cb(self._on_history_filter_changed), names="value") - for _chip in ( - *self._history_calc_chips.values(), - *self._history_status_chips.values(), - ): - _chip.observe(self._safe_cb(self._on_history_filter_changed), names="value") - self.history_filter_clear_btn.on_click(self._on_history_filter_clear) - # Perf stats reset - self._reset_btn.on_click(self._on_reset_click) - self._reset_confirm_yes.on_click(self._on_confirm_yes) - self._reset_confirm_no.on_click(self._on_confirm_no) - # Compare - self.compare_refresh_btn.on_click(self._on_compare_refresh) - self.compare_btn.on_click(self._on_compare) - self.compare_clear_btn.on_click(self._on_compare_clear) - # Output log - self._log_clear_btn.on_click(self._on_log_clear) - # Clear log cache (event_log.jsonl) - self._clear_log_cache_btn.on_click(self._on_clear_log_cache) - self._clear_log_cache_confirm_btn.on_click(self._on_clear_log_cache_confirm) - # Files tab - self._files_root_dd.observe( - self._safe_cb(self._on_files_root_changed), names="value" - ) - self._files_entries.observe( - self._safe_cb(self._on_files_entry_changed), names="value" - ) - self._files_open_btn.on_click(self._on_files_open) - self._files_up_btn.on_click(self._on_files_up) - self._files_refresh_btn.on_click(self._on_files_refresh) - # Issue reporting - self._issue_btn.on_click(self._on_issue_btn) - self._issue_submit_btn.on_click(self._on_issue_submit) - self._issue_cancel_btn.on_click(self._on_issue_cancel) - # Help [?] toggle - self._help_btn.on_click(self._on_help_toggle) - # Exit - self._exit_btn.on_click(self._on_exit_clicked) - self._exit_cancel_btn.on_click(self._on_exit_cancel) - self.help_topic_dd.observe( - self._safe_cb(self._on_help_topic_changed), names="value" - ) - # Tab navigation buttons - self._go_results_btn.on_click(self._go_to_results_tab) - self._go_analysis_btn.on_click(self._go_to_analysis_tab) - self._to_analysis_btn.on_click(self._go_to_analysis_tab) - # Vibrational mode selector - self.vib_mode_dd.observe( - self._safe_cb(self._on_vib_mode_changed), names="value" - ) - self.vib_mode_dd.observe( - self._safe_cb(self._update_vib_nav_buttons), names=["value", "options"] - ) - self.vib_prev_btn.on_click(self._on_vib_prev_clicked) - self.vib_next_btn.on_click(self._on_vib_next_clicked) - self._vib_apply_mode_btn.on_click( - self._safe_cb(lambda _btn: _run_apply_vib_mode_for_frequency(self)) - ) - # Orbital diagram axis controls - 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" - ) - self._orb_font_scale_slider.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) - # Reveal the free-entry MO-index input only in "By index" mode. - self._orb_toggle.observe( - self._safe_cb(self._on_orb_toggle_changed), names="value" - ) - # Cube + bundle exports - self._iso_export_cube_btn.on_click(self._on_iso_export_cube) - self._iso_cancel_btn.on_click(self._safe_cb(self._on_iso_cancel)) - # Reorg geometry views (REORG.3): both redraw from data already in - # memory, so they respond directly rather than behind an Apply button. - for _w in ( - self._reorg_view_toggle, - self._reorg_overlay_pair, - self._reorg_exaggerate, - ): - _w.observe(self._safe_cb(self._on_reorg_view_changed), names="value") - # PNG capture arrives from the browser, so there is no button to bind - # here — the viewer's own Save-PNG button posts into this Textarea and - # ipywidgets syncs it back, firing this observer (ORBX.1). - self._orb_png_inbox.observe( - self._safe_cb(self._on_orb_png_captured), names="value" - ) - # Same bridge, own inbox — the reorg-geometry viewer's Save-PNG button - # (M-EXPORT2 EXP2.2). - self._reorg_png_inbox.observe( - self._safe_cb(self._on_reorg_png_captured), names="value" - ) - # Same bridge again — the vibrational single-viewer's Save-PNG button - # (M-EXPORT2 EXP2.2). Only fires for that path; the legacy per-mode - # plotlymol3d fallback never renders the button. - self._vib_png_inbox.observe( - self._safe_cb(self._on_vib_png_captured), names="value" - ) - # Molecule (top) viewer — three independent Save-PNG buttons - # (M-EXPORT2 EXP2.2), one per output slot (Calculate/Results/ - # Analysis). Only fires for the py3Dmol backend; see - # visualization_py3dmol.render_molecule_html's capture_class - # docstring for why plotlymol has no equivalent button. - self._mol_calc_png_inbox.observe( - self._safe_cb(self._on_mol_calc_png_captured), names="value" - ) - self._mol_results_png_inbox.observe( - self._safe_cb(self._on_mol_results_png_captured), names="value" - ) - self._mol_analysis_png_inbox.observe( - self._safe_cb(self._on_mol_analysis_png_captured), names="value" - ) - # Click-to-measure (M-MEASURE MEAS.2/3): a click in the Analysis-tab - # viewer posts an atom index into this inbox the same way the PNG - # buttons post a data URI. - self._measure_inbox.observe( - self._safe_cb(self._on_measure_inbox_changed), names="value" - ) - self._measure_clear_btn.on_click(self._on_measure_clear) - self._measure_help_btn.on_click(self._on_measure_help) - self._mulliken_help_btn.on_click(self._on_mulliken_help) - self._mulliken_color_cb.observe( - self._safe_cb(self._on_mulliken_overlay_changed), names="value" - ) - self._mulliken_dipole_cb.observe( - self._safe_cb(self._on_mulliken_overlay_changed), names="value" - ) - self._mulliken_vividness_slider.observe( - self._safe_cb(self._on_mulliken_overlay_changed), names="value" - ) - # Persist the grid choice so it survives a relaunch (ORBX.2). - self._iso_resolution_dd.observe( - self._safe_cb(self._on_iso_resolution_changed), names="value" - ) - # Appearance controls redraw from the cube on disk — no cubegen — so - # they can respond directly rather than behind an Apply button. - for _w in ( - self._iso_isovalue_slider, - self._iso_opacity_slider, - self._iso_wireframe_cb, - self._iso_colors_dd, - # NOT _iso_png_transparent: it is an export-only option, applied at - # capture time. Observing it here would change the live viewer. - ): - _w.observe(self._safe_cb(self._on_iso_appearance_changed), names="value") - self._export_bundle_btn.on_click(self._on_export_bundle) - - # ── Files tab ──────────────────────────────────────────────────────── - - def _files_allowed_roots(self) -> list[Path]: - """Return the approved filesystem roots for the Files tab.""" - roots: list[Path] = [] - candidates: list[Optional[Path]] = [self._get_results_dir(), Path.cwd()] - _last_dir = getattr(self, "_last_result_dir", None) - if isinstance(_last_dir, Path): - candidates.append(_last_dir) - # Expose the app's own log dir (~/.quantui/logs) so the event - # log is reachable in-app. Resolves inside the runtime process, so it - # correctly points at the WSL home when the app runs under WSL. - try: - candidates.append(_calc_log._log_dir()) - except Exception: - pass - - for candidate in candidates: - if candidate is None: - continue - try: - resolved = candidate.resolve() - except OSError: - continue - if resolved not in roots: - roots.append(resolved) - - return roots - - def _is_path_in_allowed_roots(self, path: Path, roots: list[Path]) -> bool: - """True when *path* is inside any configured Files-tab root.""" - try: - resolved = path.resolve() - except OSError: - return False - for root in roots: - try: - resolved.relative_to(root) - return True - except ValueError: - continue - return False - - def _format_file_size(self, size_bytes: int) -> str: - """Return a compact human-readable size label.""" - if size_bytes < 1024: - return f"{size_bytes} B" - if size_bytes < 1024 * 1024: - return f"{size_bytes / 1024:.1f} KB" - return f"{size_bytes / (1024 * 1024):.1f} MB" - - def _set_files_status( - self, message: str, color: str = _theme.css.TEXT_SLATE - ) -> None: - """Update Files tab status text.""" - self._files_status_html.value = ( - f'' - f"{_html.escape(message)}" - ) - - def _format_files_root_label(self, root: Path) -> str: - """Return a readable dropdown label for a root path.""" - labels: list[tuple[str, Path]] = [] - try: - labels.append(("Results", self._get_results_dir().resolve())) - except OSError: - pass - try: - labels.append(("Workspace CWD", Path.cwd().resolve())) - except OSError: - pass - _last_dir = getattr(self, "_last_result_dir", None) - if isinstance(_last_dir, Path): - try: - labels.append(("Current Result", _last_dir.resolve())) - except OSError: - pass - try: - labels.append(("Logs", _calc_log._log_dir().resolve())) - except OSError: - pass - - for prefix, known_root in labels: - if root == known_root: - return f"{prefix} ({root})" - return str(root) - - def _refresh_file_browser(self) -> None: - """Refresh root options and the current directory listing.""" - roots = self._files_allowed_roots() - try: - results_root = self._get_results_dir().resolve() - except OSError: - results_root = None - for root in roots: - if results_root is not None and root == results_root: - try: - root.mkdir(parents=True, exist_ok=True) - except OSError: - pass - - if not roots: - self._files_updating = True - try: - self._files_root_dd.options = [("(no roots)", "")] - self._files_root_dd.value = "" - self._files_entries.options = [("(no files)", "")] - self._files_entries.value = "" - finally: - self._files_updating = False - self._files_current_dir = None - self._files_selected_path = None - self._files_path_html.value = ( - f'' - "Current folder: unavailable" - ) - self._files_open_btn.disabled = True - self._files_up_btn.disabled = True - self._set_files_status( - "No readable roots available.", _theme.css.ACCENT_ERROR - ) - self._files_preview_output.clear_output(wait=True) - return - - old_root_value = str(self._files_root_dd.value or "") - root_options = [ - (self._format_files_root_label(root), str(root)) for root in roots - ] - valid_root_values = {value for _, value in root_options} - selected_root = old_root_value if old_root_value in valid_root_values else "" - if not selected_root: - selected_root = root_options[0][1] - - self._files_updating = True - try: - self._files_root_dd.options = root_options - self._files_root_dd.value = selected_root - finally: - self._files_updating = False - - selected_root_path = Path(selected_root) - if ( - self._files_current_dir is None - or not self._is_path_in_allowed_roots(self._files_current_dir, roots) - or not self._files_current_dir.exists() - or not self._files_current_dir.is_dir() - ): - self._files_current_dir = selected_root_path - - self._update_files_entries() - self._set_files_status("File list refreshed.") - - def _update_files_entries(self) -> None: - """Rebuild the directory listing for the current folder.""" - roots = self._files_allowed_roots() - if not roots: - self._files_entries.options = [("(no files)", "")] - self._files_entries.value = "" - self._files_selected_path = None - self._files_open_btn.disabled = True - self._files_up_btn.disabled = True - self._files_preview_output.clear_output(wait=True) - return - - current = self._files_current_dir or roots[0] - if not self._is_path_in_allowed_roots(current, roots): - current = Path(self._files_root_dd.value) - if not current.exists() or not current.is_dir(): - current = Path(self._files_root_dd.value) - - self._files_current_dir = current - self._files_path_html.value = ( - f'Current folder: ' - f"{_html.escape(str(current))}" - ) - - try: - children = list(current.iterdir()) - except OSError as exc: - self._files_entries.options = [("(unreadable folder)", "")] - self._files_entries.value = "" - self._files_selected_path = None - self._files_open_btn.disabled = True - self._files_up_btn.disabled = True - self._files_preview_output.clear_output(wait=True) - self._set_files_status( - f"Cannot list folder: {exc}", _theme.css.ACCENT_ERROR - ) - return - - children.sort(key=lambda p: (not p.is_dir(), p.name.lower())) - options: list[tuple[str, str]] = [] - for child in children: - if child.is_dir(): - options.append((f"[DIR] {child.name}", str(child))) - continue - try: - size_label = self._format_file_size(child.stat().st_size) - except OSError: - size_label = "?" - options.append((f"{child.name} ({size_label})", str(child))) - - if not options: - options = [("(empty directory)", "")] - - old_selection = str(self._files_entries.value or "") - valid_values = {value for _, value in options if value} - new_selection = old_selection if old_selection in valid_values else "" - if not new_selection and valid_values: - new_selection = next(iter(valid_values)) - - self._files_updating = True - try: - self._files_entries.options = options - self._files_entries.value = new_selection - finally: - self._files_updating = False - - self._files_selected_path = Path(new_selection) if new_selection else None - self._files_open_btn.disabled = self._files_selected_path is None - - _parent = current.parent - self._files_up_btn.disabled = ( - _parent == current or not self._is_path_in_allowed_roots(_parent, roots) - ) - - self._files_preview_output.clear_output(wait=True) - - def _preview_file_path(self, path: Path) -> None: - """Render a safe preview for a selected file path.""" - roots = self._files_allowed_roots() - if not self._is_path_in_allowed_roots(path, roots): - self._set_files_status( - "Selected path is outside allowed roots.", _theme.css.ACCENT_ERROR - ) - return - if not path.exists() or not path.is_file(): - self._set_files_status( - "Selected file no longer exists.", _theme.css.ACCENT_ERROR - ) - return - - self._files_preview_output.clear_output(wait=True) - suffix = path.suffix.lower() - - image_ext = {".png", ".jpg", ".jpeg", ".gif", ".webp"} - text_ext = { - ".txt", - ".log", - ".json", - ".jsonl", - ".md", - ".py", - ".csv", - ".yaml", - ".yml", - ".xyz", - ".cube", - ".molden", - } - - if suffix in image_ext: - from IPython.display import Image as _Image - - with self._files_preview_output: - display(_Image(filename=str(path))) - self._set_files_status(f"Previewing image: {path.name}") - return - - if suffix == ".svg": - # IPython.display.Image doesn't handle SVG well — use SVG. - from IPython.display import SVG as _SVG - - with self._files_preview_output: - display(_SVG(filename=str(path))) - self._set_files_status(f"Previewing SVG: {path.name}") - return - - # Specialized previews for - # extensions where the generic text dump is unhelpful. Each - # handler caps file reads at 256 KB. On any exception inside a - # handler, fall through to the generic text dispatch below so - # the user always sees SOMETHING. Order matters: 3D-structure - # extensions (.xyz/.mol/.pdb) take precedence over their - # text-ext membership. - - if suffix in {".xyz", ".mol", ".pdb"}: - # 3D structure → py3Dmol viewer via raw model load. Falls - # through to text dispatch on failure (so the user still - # sees the raw coordinates). - try: - from quantui.viz_assets import make_view - - model_format = {".xyz": "xyz", ".mol": "mol", ".pdb": "pdb"}[suffix] - raw_text = path.read_text(encoding="utf-8", errors="replace") - if len(raw_text) <= 256_000: - viewer = make_view(width=500, height=380) - viewer.addModel(raw_text, model_format) - viewer.setStyle({"stick": {}, "sphere": {"scale": 0.25}}) - viewer.setBackgroundColor("white") - viewer.zoomTo() - html_str = viewer._make_html() - with self._files_preview_output: - display(HTML(html_str)) - self._set_files_status( - f"3D structure preview: {path.name}" - f" ({model_format.upper()})" - ) - return - except Exception: # noqa: BLE001 — fall through to text preview - pass - - if suffix == ".json": - try: - import json as _json_pretty - - raw = path.read_bytes()[:256_000] - parsed = _json_pretty.loads(raw.decode("utf-8", errors="replace")) - pretty = _json_pretty.dumps(parsed, indent=2, ensure_ascii=False) - # Cap line count so a 10k-key dict doesn't lock the viewport. - lines = pretty.splitlines() - truncated = False - if len(lines) > 500: - lines = lines[:500] - truncated = True - rendered = "\n".join(lines) - if truncated: - rendered += "\n\n[truncated to first 500 lines]" - with self._files_preview_output: - display( - HTML( - "
"
-                            f"{_html.escape(rendered)}
" - ) - ) - self._set_files_status(f"JSON preview: {path.name}") - return - except Exception: # noqa: BLE001 — fall through to text preview - pass - - if suffix == ".jsonl": - # JSONL logs (event_log.jsonl) grow append-only, so the - # newest — and most useful — records are at the END. The generic - # text dispatch keeps the FIRST 200 KB (oldest events), so tail - # the file here instead: show the last N lines, newest last. - try: - _MAX_TAIL_LINES = 300 - raw = path.read_bytes() - total_bytes = len(raw) - # Cap the decode window so a multi-MB log stays responsive; - # the tail is all we render anyway. - tail_raw = raw[-400_000:] - text = tail_raw.decode("utf-8", errors="replace") - lines = text.splitlines() - # A leading partial line can appear after byte-slicing — drop it. - if len(tail_raw) < total_bytes and lines: - lines = lines[1:] - total_lines_shown = min(len(lines), _MAX_TAIL_LINES) - shown = lines[-_MAX_TAIL_LINES:] - rendered = "\n".join(shown) - note = ( - f"Showing the last {total_lines_shown} record(s)" - + ( - " (file tail — older records not shown)" - if len(lines) > _MAX_TAIL_LINES or len(tail_raw) < total_bytes - else "" - ) - + "." - ) - with self._files_preview_output: - display( - HTML( - f"

" - f"{_html.escape(note)}

" - "
"
-                            f"{_html.escape(rendered)}
" - ) - ) - self._set_files_status(f"Log preview (tail): {path.name}") - return - except Exception: # noqa: BLE001 — fall through to text preview - pass - - if suffix == ".csv": - try: - import csv as _csv - - with open(path, encoding="utf-8", errors="replace", newline="") as fh: - reader = _csv.reader(fh) - rows: list[list[str]] = [] - for i, row in enumerate(reader): - if i >= 50: - break - rows.append(row) - if rows: - header = rows[0] - body = rows[1:] - head_html = "".join( - f'{_html.escape(str(c))}' - for c in header - ) - body_html = "".join( - "" - + "".join( - f'{_html.escape(str(c))}' - for c in r - ) - + "" - for r in body - ) - note = ( - f'

' - f"First {len(rows)} rows shown.

" - if len(rows) >= 50 - else "" - ) - table_html = ( - f"{note}" - '' - f"{head_html}" - f"{body_html}
" - ) - with self._files_preview_output: - display(HTML(table_html)) - self._set_files_status( - f"CSV preview: {path.name} ({len(rows)} rows)" - ) - return - except Exception: # noqa: BLE001 — fall through to text preview - pass - - if suffix in {".html", ".htm"}: - try: - html_text = path.read_text(encoding="utf-8", errors="replace") - if len(html_text) <= 1_000_000: - # Sandboxed iframe via srcdoc — embedded JS can't - # reach the parent app. - iframe_html = ( - '' - ) - with self._files_preview_output: - display(HTML(iframe_html)) - self._set_files_status(f"HTML preview (sandboxed): {path.name}") - return - except Exception: # noqa: BLE001 — fall through to text preview - pass - - if suffix == ".cube": - # Cube files can be hundreds of MB (volumetric data). Don't - # dump them — show the header + a size + a hint. - try: - stat = path.stat() - with open(path, encoding="utf-8", errors="replace") as fh: - head_lines = [] - for i, line in enumerate(fh): - if i >= 6: - break - head_lines.append(line.rstrip("\n")) - header_text = "\n".join(head_lines) - size_mb = stat.st_size / (1024 * 1024) - msg_html = ( - f'

' - f"Cube file: {_html.escape(path.name)} " - f"· {size_mb:.2f} MB

" - f'

' - "Use the Analysis tab's Orbital Isosurface panel to " - "render volumetric data; the raw file is too large to " - "preview inline.

" - f'

' - "Header (first 6 lines):

" - '
'
-                    f"{_html.escape(header_text)}
" - ) - with self._files_preview_output: - display(HTML(msg_html)) - self._set_files_status(f"Cube file metadata: {path.name}") - return - except Exception: # noqa: BLE001 — fall through to text preview - pass - - is_text = suffix in text_ext - if not is_text: - try: - sample = path.read_bytes()[:512] - except OSError as exc: - self._set_files_status( - f"Cannot read file: {exc}", _theme.css.ACCENT_ERROR - ) - return - is_text = b"\x00" not in sample - - if not is_text: - with self._files_preview_output: - display( - HTML( - f"

" - "Binary preview is not available for this file type." - "

" - ) - ) - self._set_files_status(f"Binary file selected: {path.name}") - return - - max_bytes = 200_000 - try: - raw = path.read_bytes() - except OSError as exc: - self._set_files_status(f"Cannot read file: {exc}", _theme.css.ACCENT_ERROR) - return - - truncated = len(raw) > max_bytes - raw = raw[:max_bytes] - text = raw.decode("utf-8", errors="replace") - if truncated: - text += "\n\n[Preview truncated to 200 KB]" - - with self._files_preview_output: - display( - HTML( - "
"
-                    f"{_html.escape(text)}"
-                    "
" - ) - ) - self._set_files_status(f"Previewing text file: {path.name}") - - def _on_files_root_changed(self, change) -> None: - if self._files_updating: - return - new_value = str(change.get("new") or "") - if not new_value: - return - - new_root = Path(new_value) - roots = self._files_allowed_roots() - if not self._is_path_in_allowed_roots(new_root, roots): - self._set_files_status( - "Selected root is not allowed.", _theme.css.ACCENT_ERROR - ) - return - - self._files_current_dir = new_root - self._update_files_entries() - self._set_files_status(f"Root changed to: {new_root}") - - def _on_files_entry_changed(self, change) -> None: - if self._files_updating: - return - new_value = str(change.get("new") or "") - self._files_selected_path = Path(new_value) if new_value else None - self._files_open_btn.disabled = self._files_selected_path is None - if self._files_selected_path is None: - self._set_files_status("Select a folder or file.") - return - if self._files_selected_path.is_dir(): - self._set_files_status( - f"Folder selected: {self._files_selected_path.name} — click Open to enter." - ) - else: - # Auto-preview on selection so the user doesn't need to click Open - # for every file. Open remains useful for folders. - self._preview_file_path(self._files_selected_path) - - def _on_files_open(self, _btn) -> None: - self._activity_begin("Opening selected path...") - try: - selected = self._files_selected_path - if selected is None: - self._set_files_status("Select a folder or file first.") - return - if selected.is_dir(): - self._files_current_dir = selected - self._update_files_entries() - self._set_files_status(f"Opened folder: {selected}") - return - self._preview_file_path(selected) - finally: - self._activity_end() - - def _on_files_up(self, _btn) -> None: - self._activity_begin("Moving to parent folder...") - try: - if self._files_current_dir is None: - self._set_files_status( - "No current folder selected.", _theme.css.ACCENT_ERROR - ) - return - - parent = self._files_current_dir.parent - roots = self._files_allowed_roots() - if parent == self._files_current_dir or not self._is_path_in_allowed_roots( - parent, roots - ): - self._set_files_status("Already at the top of the selected root.") - return - - self._files_current_dir = parent - self._update_files_entries() - self._set_files_status(f"Moved to parent folder: {parent}") - finally: - self._activity_end() - - def _on_files_refresh(self, _btn) -> None: - self._activity_begin("Refreshing files browser...") - try: - self._refresh_file_browser() - finally: - self._activity_end() - - # ══ CALLBACK METHODS ═════════════════════════════════════════════════════ - - # ── Theme ───────────────────────────────────────────────────────────── - - def _on_theme_changed(self, change) -> None: - palette_id = change["new"] - if palette_id not in _theme.PALETTE_IDS: - return - self._user_settings.theme.palette = palette_id - self._user_settings.save() - self._theme_style.clear_output() - with self._theme_style: - display(HTML(self._theme_css(palette_id))) - self._rerender_plotly_theme() - - def _plotly_theme_colors(self) -> dict: - """Return plot colours for the active palette.""" - return _theme.plotly_colors(self.theme_btn.value) - - def _apply_plotly_theme(self, fig) -> None: - """Apply current theme colors to a plotly Figure in-place.""" - tc = self._plotly_theme_colors() - fig.update_layout( - plot_bgcolor="rgba(0,0,0,0)", - paper_bgcolor="rgba(0,0,0,0)", - font=dict(color=tc["font_color"]), - xaxis=dict(gridcolor=tc["grid_color"]), - yaxis=dict(gridcolor=tc["grid_color"]), - ) - - def _set_html_output(self, out: widgets.Output, html: str) -> None: - """Render HTML into an Output widget via an atomic outputs swap. - - Plotly HTML contains