quantui 0.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantui/__init__.py +311 -0
- quantui/analytics.py +609 -0
- quantui/app.py +5650 -0
- quantui/app_analysis.py +662 -0
- quantui/app_builders.py +2465 -0
- quantui/app_exports.py +194 -0
- quantui/app_formatters.py +493 -0
- quantui/app_history.py +624 -0
- quantui/app_runflow.py +1544 -0
- quantui/app_visualization.py +2620 -0
- quantui/ase_bridge.py +236 -0
- quantui/benchmarks.py +1543 -0
- quantui/c_stderr.py +124 -0
- quantui/cactus.py +88 -0
- quantui/calc_log.py +1116 -0
- quantui/calculator.py +204 -0
- quantui/cancellation.py +88 -0
- quantui/cli.py +288 -0
- quantui/comparison.py +306 -0
- quantui/config.py +725 -0
- quantui/data/js/3Dmol-min.js +2 -0
- quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
- quantui/data/library/library.sqlite +0 -0
- quantui/data/manifests/bulk_qm9.json +1 -0
- quantui/data/manifests/curated.json +15482 -0
- quantui/data/manifests/presets.json +816 -0
- quantui/descriptor_cards.py +186 -0
- quantui/freq_calc.py +712 -0
- quantui/freq_ir_workers.py +229 -0
- quantui/gpu_offload.py +278 -0
- quantui/help_content.py +474 -0
- quantui/ir_plot.py +130 -0
- quantui/issue_tracker.py +170 -0
- quantui/live_log.py +387 -0
- quantui/log_utils.py +492 -0
- quantui/molecule.py +577 -0
- quantui/molecule_library.py +433 -0
- quantui/nmr_calc.py +437 -0
- quantui/optimizer.py +670 -0
- quantui/orbital_visualization.py +1102 -0
- quantui/pes_scan.py +420 -0
- quantui/preopt.py +355 -0
- quantui/progress.py +111 -0
- quantui/pubchem.py +1157 -0
- quantui/reorganization_energy.py +435 -0
- quantui/results_storage.py +902 -0
- quantui/security.py +14 -0
- quantui/session_calc.py +622 -0
- quantui/structure_providers.py +277 -0
- quantui/tddft_calc.py +307 -0
- quantui/user_settings.py +238 -0
- quantui/utils.py +287 -0
- quantui/vib_cache.py +247 -0
- quantui/visualization_py3dmol.py +593 -0
- quantui/viz_assets.py +101 -0
- quantui/viz_backend_router.py +243 -0
- quantui-0.5.1.dist-info/METADATA +533 -0
- quantui-0.5.1.dist-info/RECORD +62 -0
- quantui-0.5.1.dist-info/WHEEL +5 -0
- quantui-0.5.1.dist-info/entry_points.txt +2 -0
- quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
- quantui-0.5.1.dist-info/top_level.txt +1 -0
quantui/app_builders.py
ADDED
|
@@ -0,0 +1,2465 @@
|
|
|
1
|
+
"""UI builder helpers used by QuantUIApp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import ipywidgets as widgets
|
|
10
|
+
from IPython.display import HTML, display
|
|
11
|
+
|
|
12
|
+
import quantui
|
|
13
|
+
from quantui import molecule_library as _ml
|
|
14
|
+
from quantui.help_content import HELP_TOPICS
|
|
15
|
+
from quantui.live_log import LiveLog
|
|
16
|
+
|
|
17
|
+
# Friendlier labels for the library category filter.
|
|
18
|
+
_CATEGORY_LABELS = {
|
|
19
|
+
"diatomic": "Diatomics",
|
|
20
|
+
"triatomic": "Triatomics",
|
|
21
|
+
"small-organic": "Small organics",
|
|
22
|
+
"aromatic": "Aromatics",
|
|
23
|
+
"amino-acid": "Amino acids",
|
|
24
|
+
"nucleobase": "Nucleobases",
|
|
25
|
+
"biomolecule": "Biomolecules",
|
|
26
|
+
"solvent": "Solvents",
|
|
27
|
+
"functional-group": "Functional groups",
|
|
28
|
+
"hydrocarbon": "Hydrocarbons",
|
|
29
|
+
"drug": "Drugs",
|
|
30
|
+
"inorganic": "Inorganics",
|
|
31
|
+
"ion": "Ions",
|
|
32
|
+
"bulk-qm9": "Bulk (QM9)",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _category_label(cat: str) -> str:
|
|
37
|
+
return _CATEGORY_LABELS.get(cat, cat.replace("-", " ").title())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def library_result_options(query: str = "", category: str | None = None):
|
|
41
|
+
"""Build (label, id) options for the library results dropdown + a count note.
|
|
42
|
+
|
|
43
|
+
Returns ``(options, note)``. ``options`` always leads with a placeholder so
|
|
44
|
+
the dropdown's value can be reset without triggering a load.
|
|
45
|
+
"""
|
|
46
|
+
limit = 200
|
|
47
|
+
rows = _ml.search(query, category=category, limit=limit)
|
|
48
|
+
opts = [("— select a molecule —", "")]
|
|
49
|
+
for r in rows:
|
|
50
|
+
opts.append((f"{r['name']} · {r['formula']}", r["id"]))
|
|
51
|
+
note = f"{len(rows)} match" + ("" if len(rows) == 1 else "es")
|
|
52
|
+
if len(rows) >= limit:
|
|
53
|
+
note = f"showing first {limit} — narrow with search/category"
|
|
54
|
+
return opts, note
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_status_panel(
|
|
58
|
+
app: Any,
|
|
59
|
+
*,
|
|
60
|
+
layout_fn: Any,
|
|
61
|
+
get_session_resources_fn: Any,
|
|
62
|
+
load_last_calibration_label_fn: Any,
|
|
63
|
+
pyscf_available: bool,
|
|
64
|
+
ase_available: bool,
|
|
65
|
+
pubchem_available: bool,
|
|
66
|
+
visualization_available: bool,
|
|
67
|
+
viz_default_backend: str = "auto",
|
|
68
|
+
vib_framerate_fps: int = 10,
|
|
69
|
+
gpu_enabled: bool = True,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Build the Status tab panel."""
|
|
72
|
+
cores, mem_gb = get_session_resources_fn()
|
|
73
|
+
mem = f"{mem_gb} GB" if mem_gb is not None else "unknown"
|
|
74
|
+
py_ver = (
|
|
75
|
+
f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
76
|
+
)
|
|
77
|
+
env = os.environ.get("CONDA_DEFAULT_ENV", "") or os.path.basename(
|
|
78
|
+
os.environ.get("VIRTUAL_ENV", "")
|
|
79
|
+
)
|
|
80
|
+
cal_label = load_last_calibration_label_fn()
|
|
81
|
+
|
|
82
|
+
def _ok(flag: bool, extra: str = "") -> str:
|
|
83
|
+
tick = '<span style="color:#22c55e">✓</span>'
|
|
84
|
+
cross = '<span style="color:#ef4444">✗</span>'
|
|
85
|
+
return (tick if flag else cross) + (" " + extra if extra else "")
|
|
86
|
+
|
|
87
|
+
env_badge = (
|
|
88
|
+
f' <code style="font-size:11px;background:#e0e7ef;'
|
|
89
|
+
f'padding:1px 5px;border-radius:3px;color:#334155">{env}</code>'
|
|
90
|
+
if env and env not in ("base", "")
|
|
91
|
+
else ""
|
|
92
|
+
)
|
|
93
|
+
cal_line = (
|
|
94
|
+
f'<div style="margin-top:6px;font-size:12px;color:#94a3b8">'
|
|
95
|
+
f"Timing calibration: {cal_label}</div>"
|
|
96
|
+
if cal_label
|
|
97
|
+
else '<div style="margin-top:6px;font-size:12px;color:#94a3b8">'
|
|
98
|
+
"Timing calibration: not yet run — use the Calibrate panel in History</div>"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# GPU offload indicator. Detecting GPUs imports gpu4pyscf +
|
|
102
|
+
# cupy and queries CUDA, which costs ~7 s — far too slow to block startup.
|
|
103
|
+
# So the status HTML is rendered via this closure with the GPU row as a
|
|
104
|
+
# "checking…" placeholder; the app re-renders it (via app._render_status_html)
|
|
105
|
+
# once a background thread resolves is_gpu_available(). The detection helper
|
|
106
|
+
# is the same one the run dispatcher uses, so the badge can't drift from
|
|
107
|
+
# actual behavior.
|
|
108
|
+
def _gpu_cell(gpu_state: Any) -> str:
|
|
109
|
+
if gpu_state is None:
|
|
110
|
+
return '<span style="color:#94a3b8">⌛ checking…</span>'
|
|
111
|
+
# Accepts the 2-tuple from is_gpu_available() or the 3-tuple from
|
|
112
|
+
# probe_gpu() — the app passes the latter so the real reason can be
|
|
113
|
+
# shown instead of a guess.
|
|
114
|
+
avail, name = gpu_state[0], gpu_state[1]
|
|
115
|
+
reason = gpu_state[2] if len(gpu_state) > 2 else ""
|
|
116
|
+
if avail:
|
|
117
|
+
cell = _ok(True, f"— <code>{name}</code>")
|
|
118
|
+
from quantui.gpu_offload import is_low_fp64_device
|
|
119
|
+
|
|
120
|
+
if is_low_fp64_device(name):
|
|
121
|
+
# Detected is not the same as beneficial. Consumer cards gate
|
|
122
|
+
# FP64 to ~1/32–1/64 of FP32 and PySCF SCF is FP64 throughout,
|
|
123
|
+
# so offload can be slower than the CPU. Warn in place rather
|
|
124
|
+
# than let it look like free speed.
|
|
125
|
+
cell += (
|
|
126
|
+
'<div style="color:#b45309;font-size:11px;margin-top:2px">'
|
|
127
|
+
"⚠ consumer-class GPU — weak double precision; "
|
|
128
|
+
"may run <b>slower</b> than CPU. Benchmark before relying "
|
|
129
|
+
"on it.</div>"
|
|
130
|
+
)
|
|
131
|
+
return cell
|
|
132
|
+
detail = reason if reason else "not installed or no CUDA device"
|
|
133
|
+
return _ok(False, f'— <span style="font-size:12px">{detail}</span>')
|
|
134
|
+
|
|
135
|
+
def _render_status(gpu_state: Any) -> str:
|
|
136
|
+
items = [
|
|
137
|
+
(
|
|
138
|
+
"PySCF (calculations)",
|
|
139
|
+
_ok(
|
|
140
|
+
pyscf_available,
|
|
141
|
+
"" if pyscf_available else "— Linux / macOS / WSL required",
|
|
142
|
+
),
|
|
143
|
+
),
|
|
144
|
+
("ASE (structure I/O, opt.)", _ok(ase_available)),
|
|
145
|
+
("PubChem search", _ok(pubchem_available)),
|
|
146
|
+
("3D viewer (py3Dmol)", _ok(visualization_available)),
|
|
147
|
+
("GPU offload (gpu4pyscf)", _gpu_cell(gpu_state)),
|
|
148
|
+
("CPU cores / Memory", f"<b>{cores}</b> cores / <b>{mem}</b>"),
|
|
149
|
+
]
|
|
150
|
+
rows = "".join(
|
|
151
|
+
f'<tr><td style="padding:3px 16px 3px 0;color:#64748b;font-size:13px">'
|
|
152
|
+
f'{k}</td><td style="font-size:13px">{v}</td></tr>'
|
|
153
|
+
for k, v in items
|
|
154
|
+
)
|
|
155
|
+
return (
|
|
156
|
+
'<div style="background:#f8fafc;border:1px solid #e2e8f0;'
|
|
157
|
+
"border-left:4px solid #3b82f6;"
|
|
158
|
+
'padding:12px 16px;border-radius:6px;margin:4px 0 8px">'
|
|
159
|
+
'<div style="font-weight:600;font-size:14px;color:#1e293b">'
|
|
160
|
+
f"QuantUI {quantui.__version__}"
|
|
161
|
+
'<span style="font-weight:400;font-size:12px;color:#94a3b8;'
|
|
162
|
+
f'margin-left:10px">Python {py_ver}{env_badge}</span></div>'
|
|
163
|
+
f'<table style="margin-top:10px;border-collapse:collapse">{rows}</table>'
|
|
164
|
+
f"{cal_line}</div>"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Exposed so the app's background GPU-detection task can refresh the badge.
|
|
168
|
+
app._render_status_html = _render_status
|
|
169
|
+
app._status_html = widgets.HTML(_render_status(None))
|
|
170
|
+
|
|
171
|
+
# ── Settings section ──────────────────────────────────────────────────
|
|
172
|
+
# "Default 3D backend" — user preference persisted via UserSettings.
|
|
173
|
+
# Drives viz_backend_router resolution. Distinct from the Calculate-tab
|
|
174
|
+
# `viz_backend_toggle` (which selects the current effective backend for
|
|
175
|
+
# interactive use).
|
|
176
|
+
app.viz_default_backend_dd = widgets.ToggleButtons(
|
|
177
|
+
options=[
|
|
178
|
+
("Auto", "auto"),
|
|
179
|
+
("py3Dmol", "py3dmol"),
|
|
180
|
+
("plotlymol3d", "plotlymol"),
|
|
181
|
+
],
|
|
182
|
+
value=viz_default_backend,
|
|
183
|
+
style={"button_width": "110px"},
|
|
184
|
+
tooltips=[
|
|
185
|
+
"Use the recommended backend per task (py3Dmol-first where supported).",
|
|
186
|
+
"Always prefer py3Dmol when available.",
|
|
187
|
+
"Always prefer plotlymol3d when available.",
|
|
188
|
+
],
|
|
189
|
+
)
|
|
190
|
+
settings_html = widgets.HTML(
|
|
191
|
+
'<div style="background:#f8fafc;border:1px solid #e2e8f0;'
|
|
192
|
+
"border-left:4px solid #94a3b8;padding:12px 16px;border-radius:6px;"
|
|
193
|
+
'margin:8px 0 4px">'
|
|
194
|
+
'<div style="font-weight:600;font-size:14px;color:#1e293b">Settings</div>'
|
|
195
|
+
'<div style="font-size:12px;color:#475569;margin-top:8px;margin-bottom:4px">'
|
|
196
|
+
"Default 3D backend "
|
|
197
|
+
'<span style="color:#94a3b8;font-size:11px">'
|
|
198
|
+
"(persists across launches)</span></div>"
|
|
199
|
+
"</div>"
|
|
200
|
+
)
|
|
201
|
+
# Vibrational animation framerate (persists across launches).
|
|
202
|
+
app.vib_framerate_si = widgets.IntSlider(
|
|
203
|
+
value=vib_framerate_fps,
|
|
204
|
+
min=5,
|
|
205
|
+
max=60,
|
|
206
|
+
step=1,
|
|
207
|
+
description="Vib fps:",
|
|
208
|
+
style={"description_width": "60px"},
|
|
209
|
+
layout=layout_fn(width="320px", margin="6px 0 0 0"),
|
|
210
|
+
readout=True,
|
|
211
|
+
readout_format="d",
|
|
212
|
+
tooltip=(
|
|
213
|
+
"Frames per second for the py3Dmol vibrational animation. "
|
|
214
|
+
"Higher = smoother + faster oscillation. Cached HTML is "
|
|
215
|
+
"invalidated when this changes."
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
vib_fps_label = widgets.HTML(
|
|
219
|
+
'<div style="font-size:12px;color:#475569;margin-top:10px;'
|
|
220
|
+
'margin-bottom:0px">Vibrational animation framerate '
|
|
221
|
+
'<span style="color:#94a3b8;font-size:11px">'
|
|
222
|
+
"(persists across launches)</span></div>"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# GPU offload toggle (persists across launches). Off is a legitimate choice
|
|
226
|
+
# on consumer cards, where FP64 offload can be slower than the CPU — see
|
|
227
|
+
# gpu_offload.is_low_fp64_device. QUANTUI_DISABLE_GPU=1 overrides this.
|
|
228
|
+
gpu_toggle_label = widgets.HTML(
|
|
229
|
+
'<div style="font-size:12px;color:#475569;margin-top:12px;'
|
|
230
|
+
'margin-bottom:0px">GPU offload '
|
|
231
|
+
'<span style="color:#94a3b8;font-size:11px">'
|
|
232
|
+
"(persists across launches; only applies when a CUDA device is "
|
|
233
|
+
"detected)</span></div>"
|
|
234
|
+
)
|
|
235
|
+
app.gpu_enabled_cb = widgets.Checkbox(
|
|
236
|
+
value=gpu_enabled,
|
|
237
|
+
description="Use GPU when available",
|
|
238
|
+
indent=False,
|
|
239
|
+
layout=layout_fn(width="320px", margin="2px 0 0 0"),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
settings_box = widgets.VBox(
|
|
243
|
+
[
|
|
244
|
+
settings_html,
|
|
245
|
+
app.viz_default_backend_dd,
|
|
246
|
+
vib_fps_label,
|
|
247
|
+
app.vib_framerate_si,
|
|
248
|
+
gpu_toggle_label,
|
|
249
|
+
app.gpu_enabled_cb,
|
|
250
|
+
],
|
|
251
|
+
layout=layout_fn(margin="0 0 8px 0"),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
app._status_tab_panel = widgets.VBox(
|
|
255
|
+
[app._status_html, settings_box],
|
|
256
|
+
layout=layout_fn(padding="8px 0"),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def build_history_section(
|
|
261
|
+
app: Any,
|
|
262
|
+
*,
|
|
263
|
+
layout_fn: Any,
|
|
264
|
+
pyscf_available: bool,
|
|
265
|
+
benchmark_suite: list[Any],
|
|
266
|
+
benchmark_suite_long: list[Any],
|
|
267
|
+
load_last_calibration_label_fn: Any,
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Build the History tab panel including calibration and perf widgets."""
|
|
270
|
+
app.past_dd = widgets.Dropdown(
|
|
271
|
+
description="Load:",
|
|
272
|
+
options=[("(no saved results)", "")],
|
|
273
|
+
style={"description_width": "50px"},
|
|
274
|
+
layout=layout_fn(width="500px"),
|
|
275
|
+
)
|
|
276
|
+
app.past_refresh_btn = widgets.Button(
|
|
277
|
+
description="Refresh",
|
|
278
|
+
button_style="",
|
|
279
|
+
icon="refresh",
|
|
280
|
+
layout=layout_fn(width="100px"),
|
|
281
|
+
tooltip="Rescan the results directory",
|
|
282
|
+
)
|
|
283
|
+
app.copy_path_btn = widgets.Button(
|
|
284
|
+
description="Copy path",
|
|
285
|
+
button_style="",
|
|
286
|
+
icon="clipboard",
|
|
287
|
+
layout=layout_fn(width="120px"),
|
|
288
|
+
tooltip="Copy the results directory path to clipboard",
|
|
289
|
+
)
|
|
290
|
+
app.results_path_lbl = widgets.HTML()
|
|
291
|
+
app.past_output = widgets.Output()
|
|
292
|
+
app.view_log_btn = widgets.Button(
|
|
293
|
+
description="View log",
|
|
294
|
+
button_style="",
|
|
295
|
+
icon="file-text-o",
|
|
296
|
+
layout=layout_fn(width="110px"),
|
|
297
|
+
tooltip="Open the full PySCF output log in the Output tab",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# 4-tier calibration selector. ToggleButtons works for
|
|
301
|
+
# 4 options; switch to a Dropdown if a 5th tier is ever added. Tier 3
|
|
302
|
+
# / tier 4 require PySCF (the geom-opt + freq dispatch); tier 1 / 2
|
|
303
|
+
# are SP-only and gated separately by the run button.
|
|
304
|
+
app._cal_mode_toggle = widgets.ToggleButtons(
|
|
305
|
+
options=[
|
|
306
|
+
("Tier 1 — Quick (~15 s)", "tier1"),
|
|
307
|
+
("Tier 2 — Standard (~3–5 min)", "tier2"),
|
|
308
|
+
("Tier 3 — Mixed (~10–15 min)", "tier3"),
|
|
309
|
+
("Tier 4 — Deep (~30 min)", "tier4"),
|
|
310
|
+
],
|
|
311
|
+
value="tier1",
|
|
312
|
+
description="",
|
|
313
|
+
button_style="",
|
|
314
|
+
style={"description_width": "0px", "button_width": "200px"},
|
|
315
|
+
layout=layout_fn(margin="0 0 8px"),
|
|
316
|
+
)
|
|
317
|
+
app._cal_run_btn = widgets.Button(
|
|
318
|
+
description="Run Calibration",
|
|
319
|
+
button_style="primary",
|
|
320
|
+
icon="play",
|
|
321
|
+
disabled=not pyscf_available,
|
|
322
|
+
tooltip=(
|
|
323
|
+
"Run the benchmark suite to calibrate time estimates"
|
|
324
|
+
if pyscf_available
|
|
325
|
+
else "PySCF required (Linux / macOS / WSL)"
|
|
326
|
+
),
|
|
327
|
+
layout=layout_fn(width="180px"),
|
|
328
|
+
)
|
|
329
|
+
app._cal_stop_btn = widgets.Button(
|
|
330
|
+
description="Stop",
|
|
331
|
+
button_style="warning",
|
|
332
|
+
icon="stop",
|
|
333
|
+
tooltip="Abandon the rest of the calibration (current step is also killed).",
|
|
334
|
+
layout=layout_fn(width="90px", display="none"),
|
|
335
|
+
)
|
|
336
|
+
# Replaced the hard 1800 s per-step timeout with a Skip button so the
|
|
337
|
+
# user can abandon ONE step that's running too long without losing the
|
|
338
|
+
# whole run. Distinct from Stop (which abandons everything remaining).
|
|
339
|
+
app._cal_skip_btn = widgets.Button(
|
|
340
|
+
description="Skip step",
|
|
341
|
+
button_style="info",
|
|
342
|
+
icon="step-forward",
|
|
343
|
+
tooltip=(
|
|
344
|
+
"Abandon the current step and move on to the next. Other "
|
|
345
|
+
"completed steps stay; the calibration continues."
|
|
346
|
+
),
|
|
347
|
+
layout=layout_fn(width="120px", display="none"),
|
|
348
|
+
)
|
|
349
|
+
app._cal_progress = widgets.IntProgress(
|
|
350
|
+
min=0,
|
|
351
|
+
max=len(benchmark_suite),
|
|
352
|
+
value=0,
|
|
353
|
+
description="",
|
|
354
|
+
bar_style="info",
|
|
355
|
+
layout=layout_fn(width="300px", display="none"),
|
|
356
|
+
)
|
|
357
|
+
app._cal_step_label = widgets.HTML(
|
|
358
|
+
value="",
|
|
359
|
+
layout=layout_fn(display="none"),
|
|
360
|
+
)
|
|
361
|
+
app._cal_results_html = widgets.HTML(value="")
|
|
362
|
+
|
|
363
|
+
app._perf_stats_html = widgets.HTML()
|
|
364
|
+
app._perf_events_html = widgets.HTML()
|
|
365
|
+
app._reset_btn = widgets.Button(
|
|
366
|
+
description="Reset performance database",
|
|
367
|
+
button_style="danger",
|
|
368
|
+
icon="trash",
|
|
369
|
+
layout=layout_fn(width="230px"),
|
|
370
|
+
)
|
|
371
|
+
app._reset_confirm_html = widgets.HTML(
|
|
372
|
+
'<span style="color:#dc2626;font-size:13px">'
|
|
373
|
+
"<b>Warning:</b> This will permanently delete all performance records. "
|
|
374
|
+
"Time estimates will reset to “no data”.</span>"
|
|
375
|
+
)
|
|
376
|
+
app._reset_confirm_yes = widgets.Button(
|
|
377
|
+
description="Yes, delete all records",
|
|
378
|
+
button_style="danger",
|
|
379
|
+
icon="check",
|
|
380
|
+
layout=layout_fn(width="190px"),
|
|
381
|
+
)
|
|
382
|
+
app._reset_confirm_no = widgets.Button(
|
|
383
|
+
description="Cancel",
|
|
384
|
+
button_style="",
|
|
385
|
+
icon="times",
|
|
386
|
+
layout=layout_fn(width="90px"),
|
|
387
|
+
)
|
|
388
|
+
app._reset_confirm_box = widgets.VBox(
|
|
389
|
+
[
|
|
390
|
+
app._reset_confirm_html,
|
|
391
|
+
widgets.HBox(
|
|
392
|
+
[app._reset_confirm_yes, app._reset_confirm_no],
|
|
393
|
+
layout=layout_fn(gap="8px", margin="4px 0 0"),
|
|
394
|
+
),
|
|
395
|
+
],
|
|
396
|
+
layout=layout_fn(
|
|
397
|
+
display="none",
|
|
398
|
+
border="1px solid #fca5a5",
|
|
399
|
+
padding="8px 10px",
|
|
400
|
+
margin="6px 0 0",
|
|
401
|
+
),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
perf_stats_panel = widgets.VBox(
|
|
405
|
+
[
|
|
406
|
+
app._perf_stats_html,
|
|
407
|
+
widgets.HTML(
|
|
408
|
+
'<p style="margin:10px 0 4px;color:#475569;font-size:13px;font-weight:600">'
|
|
409
|
+
"Recent events (last 20)</p>"
|
|
410
|
+
),
|
|
411
|
+
app._perf_events_html,
|
|
412
|
+
widgets.HBox(
|
|
413
|
+
[app._reset_btn],
|
|
414
|
+
layout=layout_fn(margin="14px 0 4px"),
|
|
415
|
+
),
|
|
416
|
+
app._reset_confirm_box,
|
|
417
|
+
]
|
|
418
|
+
)
|
|
419
|
+
app._perf_accordion = widgets.Accordion(
|
|
420
|
+
children=[perf_stats_panel], selected_index=None
|
|
421
|
+
)
|
|
422
|
+
app._perf_accordion.set_title(0, "Performance stats")
|
|
423
|
+
|
|
424
|
+
cal_last = load_last_calibration_label_fn()
|
|
425
|
+
cal_note = (
|
|
426
|
+
f'<p style="color:#64748b;font-size:12px;margin:0 0 6px">'
|
|
427
|
+
f"Last run: {cal_last}</p>"
|
|
428
|
+
if cal_last
|
|
429
|
+
else ""
|
|
430
|
+
)
|
|
431
|
+
# Import tier sizes lazily so we can refer to all four
|
|
432
|
+
# in the panel blurb. ``benchmark_suite`` / ``benchmark_suite_long``
|
|
433
|
+
# are kept as positional args for back-compat but new code prefers
|
|
434
|
+
# the four named tiers.
|
|
435
|
+
from quantui.benchmarks import (
|
|
436
|
+
BENCHMARK_SUITE_TIER3 as _T3,
|
|
437
|
+
)
|
|
438
|
+
from quantui.benchmarks import (
|
|
439
|
+
BENCHMARK_SUITE_TIER4 as _T4,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
cal_panel = widgets.VBox(
|
|
443
|
+
[
|
|
444
|
+
widgets.HTML(
|
|
445
|
+
f'<p style="color:#555;font-size:13px;margin:0 0 6px">'
|
|
446
|
+
f"Benchmark this machine so the time estimator uses basis-function "
|
|
447
|
+
f"scaling (N<sup>β</sup>) rather than generic defaults. "
|
|
448
|
+
f"Tier 1 ({len(benchmark_suite)} calcs, ~15 s) is a quick "
|
|
449
|
+
f"SP-only smoke test; tier 2 ({len(benchmark_suite_long)} calcs, "
|
|
450
|
+
f"~3–5 min) expands the SP grid; "
|
|
451
|
+
f"tier 3 ({len(_T3)} calcs, ~10–15 min) adds small geometry "
|
|
452
|
+
f"optimizations + frequency calcs; "
|
|
453
|
+
f"tier 4 ({len(_T4)} calcs, up to ~30 min) anchors every "
|
|
454
|
+
f"calc-type × device combo for the most accurate predictions.</p>"
|
|
455
|
+
+ cal_note
|
|
456
|
+
),
|
|
457
|
+
app._cal_mode_toggle,
|
|
458
|
+
widgets.HBox(
|
|
459
|
+
[app._cal_run_btn, app._cal_skip_btn, app._cal_stop_btn],
|
|
460
|
+
layout=layout_fn(gap="6px", align_items="center"),
|
|
461
|
+
),
|
|
462
|
+
app._cal_progress,
|
|
463
|
+
app._cal_step_label,
|
|
464
|
+
app._cal_results_html,
|
|
465
|
+
],
|
|
466
|
+
layout=layout_fn(padding="4px 0"),
|
|
467
|
+
)
|
|
468
|
+
app._cal_accordion = widgets.Accordion(children=[cal_panel], selected_index=None)
|
|
469
|
+
app._cal_accordion.set_title(0, "Calibrate time estimates")
|
|
470
|
+
|
|
471
|
+
# ── Search / faceted filters (HIST.7) ──────────────────────────────
|
|
472
|
+
# All filtering is client-side over the cached ``app._history_entries``
|
|
473
|
+
# list; widgets here only hold facet state. See app_history.apply_history_filter.
|
|
474
|
+
from quantui.app_history import HISTORY_CALC_TYPE_FACETS, HISTORY_STATUS_FACETS
|
|
475
|
+
|
|
476
|
+
app._history_entries = []
|
|
477
|
+
app._history_filter_suspend = False
|
|
478
|
+
app.history_search = widgets.Text(
|
|
479
|
+
placeholder="search name or formula (e.g. benzene, C6H6)",
|
|
480
|
+
continuous_update=True, # type-to-narrow
|
|
481
|
+
layout=layout_fn(width="300px"),
|
|
482
|
+
)
|
|
483
|
+
app.history_filter_clear_btn = widgets.Button(
|
|
484
|
+
icon="times",
|
|
485
|
+
tooltip="Clear all history filters",
|
|
486
|
+
layout=layout_fn(width="40px"),
|
|
487
|
+
)
|
|
488
|
+
app.history_count_lbl = widgets.HTML(
|
|
489
|
+
'<span style="color:#888;font-size:12px"></span>'
|
|
490
|
+
)
|
|
491
|
+
app._history_calc_chips = {
|
|
492
|
+
key: widgets.ToggleButton(
|
|
493
|
+
value=False,
|
|
494
|
+
description=label,
|
|
495
|
+
tooltip=f"Show only {label} calculations",
|
|
496
|
+
layout=layout_fn(width="auto"),
|
|
497
|
+
)
|
|
498
|
+
for label, key in HISTORY_CALC_TYPE_FACETS
|
|
499
|
+
}
|
|
500
|
+
app.history_method_dd = widgets.Dropdown(
|
|
501
|
+
options=[("Any method", "")],
|
|
502
|
+
value="",
|
|
503
|
+
description="Method:",
|
|
504
|
+
style={"description_width": "55px"},
|
|
505
|
+
layout=layout_fn(width="200px"),
|
|
506
|
+
)
|
|
507
|
+
app.history_basis_dd = widgets.Dropdown(
|
|
508
|
+
options=[("Any basis", "")],
|
|
509
|
+
value="",
|
|
510
|
+
description="Basis:",
|
|
511
|
+
style={"description_width": "55px"},
|
|
512
|
+
layout=layout_fn(width="200px"),
|
|
513
|
+
)
|
|
514
|
+
app.history_date_from = widgets.DatePicker(
|
|
515
|
+
description="From:",
|
|
516
|
+
style={"description_width": "45px"},
|
|
517
|
+
layout=layout_fn(width="200px"),
|
|
518
|
+
)
|
|
519
|
+
app.history_date_to = widgets.DatePicker(
|
|
520
|
+
description="To:",
|
|
521
|
+
style={"description_width": "35px"},
|
|
522
|
+
layout=layout_fn(width="190px"),
|
|
523
|
+
)
|
|
524
|
+
app._history_status_chips = {
|
|
525
|
+
key: widgets.ToggleButton(
|
|
526
|
+
value=False,
|
|
527
|
+
description=label,
|
|
528
|
+
layout=layout_fn(width="auto"),
|
|
529
|
+
)
|
|
530
|
+
for label, key in HISTORY_STATUS_FACETS
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
def _facet_label(text: str, width: str = "60px") -> widgets.HTML:
|
|
534
|
+
return widgets.HTML(
|
|
535
|
+
f'<span style="color:#555;font-size:12px;width:{width};'
|
|
536
|
+
f'display:inline-block">{text}</span>'
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
app._history_filter_box = widgets.VBox(
|
|
540
|
+
[
|
|
541
|
+
widgets.HBox(
|
|
542
|
+
[
|
|
543
|
+
app.history_search,
|
|
544
|
+
app.history_filter_clear_btn,
|
|
545
|
+
app.history_count_lbl,
|
|
546
|
+
],
|
|
547
|
+
layout=layout_fn(align_items="center", gap="6px"),
|
|
548
|
+
),
|
|
549
|
+
widgets.HBox(
|
|
550
|
+
[_facet_label("Type:"), *app._history_calc_chips.values()],
|
|
551
|
+
layout=layout_fn(align_items="center", gap="4px", flex_wrap="wrap"),
|
|
552
|
+
),
|
|
553
|
+
widgets.HBox(
|
|
554
|
+
[app.history_method_dd, app.history_basis_dd],
|
|
555
|
+
layout=layout_fn(align_items="center", gap="8px"),
|
|
556
|
+
),
|
|
557
|
+
widgets.HBox(
|
|
558
|
+
[
|
|
559
|
+
app.history_date_from,
|
|
560
|
+
app.history_date_to,
|
|
561
|
+
_facet_label("Status:", "55px"),
|
|
562
|
+
*app._history_status_chips.values(),
|
|
563
|
+
],
|
|
564
|
+
layout=layout_fn(align_items="center", gap="6px", flex_wrap="wrap"),
|
|
565
|
+
),
|
|
566
|
+
],
|
|
567
|
+
layout=layout_fn(margin="0 0 8px", gap="4px"),
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
# The History tab is now purely the result-browser. Performance stats
|
|
571
|
+
# + Calibrate accordions moved to the System Settings tab — see below —
|
|
572
|
+
# so the user finds benchmarking + system state in one logical place.
|
|
573
|
+
app.history_panel = widgets.VBox(
|
|
574
|
+
[
|
|
575
|
+
widgets.HTML(
|
|
576
|
+
'<p style="color:#555;font-size:13px;margin:0 0 8px">'
|
|
577
|
+
"Calculations are saved automatically. Filter below, then select "
|
|
578
|
+
"one to view its results.</p>"
|
|
579
|
+
),
|
|
580
|
+
app._history_filter_box,
|
|
581
|
+
widgets.HBox(
|
|
582
|
+
[
|
|
583
|
+
app.past_dd,
|
|
584
|
+
app.past_refresh_btn,
|
|
585
|
+
app.copy_path_btn,
|
|
586
|
+
app.view_log_btn,
|
|
587
|
+
],
|
|
588
|
+
layout=layout_fn(align_items="center", gap="8px"),
|
|
589
|
+
),
|
|
590
|
+
app.results_path_lbl,
|
|
591
|
+
app.past_output,
|
|
592
|
+
]
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
# Now that the calibration + performance accordions exist
|
|
596
|
+
# (created above in this function), append them to the System
|
|
597
|
+
# Settings tab. ``_status_tab_panel`` was built earlier in
|
|
598
|
+
# ``build_status_panel`` without these — extend its children tuple.
|
|
599
|
+
app._status_tab_panel.children = (
|
|
600
|
+
*app._status_tab_panel.children,
|
|
601
|
+
app._cal_accordion,
|
|
602
|
+
app._perf_accordion,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
# History population (loads every saved result) is deferred to a background
|
|
606
|
+
# task after the UI paints — see app._start_deferred_startup_tasks. Show a
|
|
607
|
+
# placeholder until then.
|
|
608
|
+
app.past_dd.options = [("⏳ loading saved results…", "")]
|
|
609
|
+
app._refresh_perf_stats()
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def build_shared_widgets(
|
|
613
|
+
app: Any,
|
|
614
|
+
*,
|
|
615
|
+
layout_fn: Any,
|
|
616
|
+
step_progress_cls: Any,
|
|
617
|
+
supported_methods: list[Any],
|
|
618
|
+
supported_basis_sets: list[Any],
|
|
619
|
+
default_method: str,
|
|
620
|
+
default_basis: str,
|
|
621
|
+
default_charge: int,
|
|
622
|
+
default_multiplicity: int,
|
|
623
|
+
default_fmax: float,
|
|
624
|
+
default_opt_steps: int,
|
|
625
|
+
preopt_available: bool,
|
|
626
|
+
visualization_available: bool,
|
|
627
|
+
both_viz_available: bool,
|
|
628
|
+
default_viz_backend: Any,
|
|
629
|
+
default_viz_style: str,
|
|
630
|
+
default_lighting: str,
|
|
631
|
+
viz_style_options: list[Any],
|
|
632
|
+
plotlymol_viz: bool,
|
|
633
|
+
lighting_options: list[Any],
|
|
634
|
+
rdkit_available: bool,
|
|
635
|
+
) -> None:
|
|
636
|
+
"""Build shared widgets used across tabs and callbacks."""
|
|
637
|
+
app.mol_info_html = widgets.HTML(
|
|
638
|
+
value='<i style="color:#888">No molecule loaded yet.</i>'
|
|
639
|
+
)
|
|
640
|
+
app.mol_summary_compact = widgets.HTML(value="")
|
|
641
|
+
# Fixed heights reserve space so swapping content (backend/palette toggle)
|
|
642
|
+
# or streaming output never resizes the container — which would reflow the
|
|
643
|
+
# page and jump the scrollbar. The molecule viewer renders at
|
|
644
|
+
# render_molecule_html's default 500px; the run log scrolls internally.
|
|
645
|
+
# overflow hidden (not auto): the 3D viewer is a fixed-size canvas, so it
|
|
646
|
+
# needs no scrollbar — clipping a few px of margin avoids an internal
|
|
647
|
+
# scrollbar that resets to the top on every backend/palette swap.
|
|
648
|
+
app.viz_output = widgets.Output(layout=layout_fn(height="510px", overflow="hidden"))
|
|
649
|
+
app.viz_output.add_class("quantui-viewer-frame")
|
|
650
|
+
# Live calc log — a QuantUI-owned scroll container, not a widgets.Output
|
|
651
|
+
# (M-LOGSCROLL route C). An Output rebuilds its DOM subtree and resets
|
|
652
|
+
# scrollTop on every appended line, which made it impossible to scroll up
|
|
653
|
+
# during a run; LiveLog appends text nodes to a node that is never
|
|
654
|
+
# re-rendered, so native overflow-anchor holds the user's position. It
|
|
655
|
+
# exposes the same append_stdout / clear_output / .outputs surface the app
|
|
656
|
+
# already used, so the write paths are unchanged. Border, height, padding
|
|
657
|
+
# and the monospace stack live in LiveLog's own container style.
|
|
658
|
+
# No marshaller needed: LiveLog ships text over a traitlet, which is
|
|
659
|
+
# thread-safe and independent of message parentage. (An earlier revision
|
|
660
|
+
# pushed display(Javascript(...)) per chunk and silently dropped every
|
|
661
|
+
# streaming line — see the module docstring.)
|
|
662
|
+
app.run_output = LiveLog(uid=str(id(app)), layout=layout_fn(margin="0"))
|
|
663
|
+
app.run_output.add_class("quantui-run-output")
|
|
664
|
+
app.result_output = widgets.Output()
|
|
665
|
+
app.result_viz_output = widgets.Output()
|
|
666
|
+
app.result_viz_output.add_class("quantui-viewer-frame")
|
|
667
|
+
app.comparison_output = widgets.Output()
|
|
668
|
+
app._last_result_dir = None
|
|
669
|
+
|
|
670
|
+
app._viz_backend = default_viz_backend
|
|
671
|
+
if both_viz_available:
|
|
672
|
+
app.viz_backend_toggle = widgets.ToggleButtons(
|
|
673
|
+
options=[("PlotlyMol", "plotlymol"), ("py3Dmol", "py3dmol")],
|
|
674
|
+
value=default_viz_backend,
|
|
675
|
+
tooltips=["Plotly-based interactive viewer", "WebGL viewer (py3Dmol)"],
|
|
676
|
+
style={"button_width": "90px"},
|
|
677
|
+
layout=layout_fn(margin="2px 0 0 0"),
|
|
678
|
+
)
|
|
679
|
+
else:
|
|
680
|
+
app.viz_backend_toggle = None # type: ignore[assignment]
|
|
681
|
+
|
|
682
|
+
app._viz_style = default_viz_style
|
|
683
|
+
app._viz_lighting = default_lighting
|
|
684
|
+
app.viz_style_dd = widgets.Dropdown(
|
|
685
|
+
options=viz_style_options,
|
|
686
|
+
value=default_viz_style,
|
|
687
|
+
description="Style:",
|
|
688
|
+
style={"description_width": "40px"},
|
|
689
|
+
layout=layout_fn(width="180px"),
|
|
690
|
+
disabled=not visualization_available,
|
|
691
|
+
)
|
|
692
|
+
lighting_available = visualization_available and plotlymol_viz
|
|
693
|
+
app.viz_lighting_dd = widgets.Dropdown(
|
|
694
|
+
options=lighting_options,
|
|
695
|
+
value=default_lighting,
|
|
696
|
+
description="Lighting:",
|
|
697
|
+
style={"description_width": "58px"},
|
|
698
|
+
layout=layout_fn(width="170px"),
|
|
699
|
+
disabled=not lighting_available,
|
|
700
|
+
)
|
|
701
|
+
if not lighting_available:
|
|
702
|
+
app.viz_lighting_dd.layout.visibility = "hidden"
|
|
703
|
+
app.viz_controls_box = widgets.HBox(
|
|
704
|
+
[app.viz_style_dd, app.viz_lighting_dd],
|
|
705
|
+
layout=layout_fn(gap="8px", margin="2px 0 0 0", align_items="center"),
|
|
706
|
+
)
|
|
707
|
+
# Method / basis descriptor cards replace the old inline
|
|
708
|
+
# ``notes_output`` educational-notes block. Two compact HTML cards
|
|
709
|
+
# (icon + one-line) sit to the right of the dropdowns; populated here with
|
|
710
|
+
# the defaults and refreshed by ``update_notes`` on every dropdown change.
|
|
711
|
+
from quantui.descriptor_cards import basis_card_html, method_card_html
|
|
712
|
+
|
|
713
|
+
app._method_card_html = widgets.HTML(value=method_card_html(default_method))
|
|
714
|
+
app._basis_card_html = widgets.HTML(value=basis_card_html(default_basis))
|
|
715
|
+
# Cards sit side-by-side (horizontal) so the setup row stays short — a
|
|
716
|
+
# vertical stack made the row tall and left a large gap above Calc. Type.
|
|
717
|
+
# flex_wrap lets the pair wrap gracefully on a narrow window.
|
|
718
|
+
app._descriptor_cards_box = widgets.HBox(
|
|
719
|
+
[app._method_card_html, app._basis_card_html],
|
|
720
|
+
layout=layout_fn(
|
|
721
|
+
gap="8px",
|
|
722
|
+
align_items="flex-start",
|
|
723
|
+
flex_wrap="wrap",
|
|
724
|
+
margin="0 0 0 4px",
|
|
725
|
+
),
|
|
726
|
+
)
|
|
727
|
+
# Open-shell hint — shown only when multiplicity > 1 (updated by
|
|
728
|
+
# ``update_notes``). Restores the guidance the old notes block carried.
|
|
729
|
+
app._open_shell_hint = widgets.HTML(
|
|
730
|
+
value="", layout=layout_fn(display="none", margin="2px 0 0 0")
|
|
731
|
+
)
|
|
732
|
+
app.perf_estimate_html = widgets.HTML()
|
|
733
|
+
|
|
734
|
+
app.step_progress = step_progress_cls(
|
|
735
|
+
["Choose molecule", "Set method", "Run", "Results"]
|
|
736
|
+
)
|
|
737
|
+
app.step_progress.start(0)
|
|
738
|
+
|
|
739
|
+
app.method_dd = widgets.Dropdown(
|
|
740
|
+
options=supported_methods,
|
|
741
|
+
value=default_method,
|
|
742
|
+
description="Method:",
|
|
743
|
+
style={"description_width": "100px"},
|
|
744
|
+
layout=layout_fn(width="260px"),
|
|
745
|
+
)
|
|
746
|
+
app.basis_dd = widgets.Dropdown(
|
|
747
|
+
options=supported_basis_sets,
|
|
748
|
+
value=default_basis,
|
|
749
|
+
description="Basis Set:",
|
|
750
|
+
style={"description_width": "100px"},
|
|
751
|
+
layout=layout_fn(width="260px"),
|
|
752
|
+
)
|
|
753
|
+
app.charge_si = widgets.BoundedIntText(
|
|
754
|
+
value=default_charge,
|
|
755
|
+
min=-10,
|
|
756
|
+
max=10,
|
|
757
|
+
description="Charge:",
|
|
758
|
+
style={"description_width": "100px"},
|
|
759
|
+
layout=layout_fn(width="190px"),
|
|
760
|
+
)
|
|
761
|
+
app.mult_si = widgets.BoundedIntText(
|
|
762
|
+
value=default_multiplicity,
|
|
763
|
+
min=1,
|
|
764
|
+
max=10,
|
|
765
|
+
description="Multiplicity:",
|
|
766
|
+
style={"description_width": "100px"},
|
|
767
|
+
layout=layout_fn(width="190px"),
|
|
768
|
+
)
|
|
769
|
+
# Classical (MMFF/UFF) pre-optimization is an explicit, transparent tool —
|
|
770
|
+
# Preview → Keep/Revert — NOT a silent checkbox baked into the run. This
|
|
771
|
+
# avoids the confusing dual path (accepting a previewed geometry while a
|
|
772
|
+
# checkbox would re-run the same step) and means nothing pre-optimizes
|
|
773
|
+
# invisibly. (Distinct from the QM "Geometry optimization before
|
|
774
|
+
# calculation" checkbox below, which is a full DFT/HF opt.)
|
|
775
|
+
app.preopt_preview_label = widgets.HTML(
|
|
776
|
+
'<span style="font-size:13px;color:#334155">'
|
|
777
|
+
"Classical pre-optimize geometry</span>"
|
|
778
|
+
'<span style="font-size:11px;color:#94a3b8"> — fast MMFF/UFF '
|
|
779
|
+
"cleanup of a rough structure</span>"
|
|
780
|
+
)
|
|
781
|
+
# Interactive pre-opt: run the bonded-FF pre-opt on
|
|
782
|
+
# demand, watch it relax in-place, then keep or revert.
|
|
783
|
+
app.preopt_preview_btn = widgets.Button(
|
|
784
|
+
description="Preview",
|
|
785
|
+
icon="eye",
|
|
786
|
+
button_style="",
|
|
787
|
+
disabled=not preopt_available,
|
|
788
|
+
layout=layout_fn(width="110px", height="28px"),
|
|
789
|
+
tooltip="Watch the classical pre-optimization relax this geometry, "
|
|
790
|
+
"then keep or revert it"
|
|
791
|
+
+ ("" if preopt_available else " (requires RDKit — not installed)"),
|
|
792
|
+
)
|
|
793
|
+
app.preopt_accept_btn = widgets.Button(
|
|
794
|
+
description="Keep this geometry",
|
|
795
|
+
icon="check",
|
|
796
|
+
button_style="success",
|
|
797
|
+
layout=layout_fn(width="190px", height="30px"),
|
|
798
|
+
tooltip="Make the relaxed geometry the active molecule",
|
|
799
|
+
)
|
|
800
|
+
app.preopt_reset_btn = widgets.Button(
|
|
801
|
+
description="Revert",
|
|
802
|
+
icon="undo",
|
|
803
|
+
button_style="warning",
|
|
804
|
+
layout=layout_fn(width="110px", height="30px"),
|
|
805
|
+
tooltip="Discard the preview and keep your original geometry",
|
|
806
|
+
)
|
|
807
|
+
app.preopt_preview_status = widgets.HTML("")
|
|
808
|
+
app.preopt_preview_output = widgets.Output(
|
|
809
|
+
layout=layout_fn(
|
|
810
|
+
# 290px viewer + stepper controls (slider / play / compare) below.
|
|
811
|
+
height="360px",
|
|
812
|
+
width="100%",
|
|
813
|
+
max_width="480px",
|
|
814
|
+
border="1px solid #e2e8f0",
|
|
815
|
+
overflow="hidden",
|
|
816
|
+
)
|
|
817
|
+
)
|
|
818
|
+
# Keep/Revert live in their own box so the preview handler can hide them
|
|
819
|
+
# independently of the status line. When the relaxation moves the geometry
|
|
820
|
+
# negligibly there is nothing to keep or revert, and offering the choice is
|
|
821
|
+
# just confusing — see app_runflow._preopt_preview_done.
|
|
822
|
+
app._preopt_actions_box = widgets.HBox(
|
|
823
|
+
[app.preopt_accept_btn, app.preopt_reset_btn],
|
|
824
|
+
layout=layout_fn(gap="8px", margin="6px 0 0"),
|
|
825
|
+
)
|
|
826
|
+
# Whole preview block hidden until the user clicks Preview.
|
|
827
|
+
app.preopt_preview_box = widgets.VBox(
|
|
828
|
+
[
|
|
829
|
+
app.preopt_preview_status,
|
|
830
|
+
app.preopt_preview_output,
|
|
831
|
+
app._preopt_actions_box,
|
|
832
|
+
],
|
|
833
|
+
layout=layout_fn(display="none", margin="6px 0 4px", max_width="480px"),
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
from quantui.config import SOLVENT_OPTIONS as _SOLVENT_OPTS
|
|
837
|
+
|
|
838
|
+
# Drop the default Checkbox description gutter + explicit width
|
|
839
|
+
# (style description_width "initial" + indent=False) that produced the
|
|
840
|
+
# indent + horizontal scrollbar.
|
|
841
|
+
app.solvent_cb = widgets.Checkbox(
|
|
842
|
+
value=False,
|
|
843
|
+
description="Implicit solvent (PCM)",
|
|
844
|
+
style={"description_width": "initial"},
|
|
845
|
+
indent=False,
|
|
846
|
+
)
|
|
847
|
+
app.solvent_dd = widgets.Dropdown(
|
|
848
|
+
options=list(_SOLVENT_OPTS.keys()),
|
|
849
|
+
value="Water",
|
|
850
|
+
description="Solvent:",
|
|
851
|
+
style={"description_width": "70px"},
|
|
852
|
+
layout=layout_fn(width="200px", display="none"),
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
app.calc_type_dd = widgets.Dropdown(
|
|
856
|
+
options=[
|
|
857
|
+
"Single Point",
|
|
858
|
+
"Geometry Opt",
|
|
859
|
+
"Frequency",
|
|
860
|
+
"UV-Vis (TD-DFT)",
|
|
861
|
+
"NMR Shielding",
|
|
862
|
+
"PES Scan",
|
|
863
|
+
"Reorganization Energy",
|
|
864
|
+
],
|
|
865
|
+
value="Single Point",
|
|
866
|
+
description="Calc. Type:",
|
|
867
|
+
style={"description_width": "100px"},
|
|
868
|
+
layout=layout_fn(width="310px"),
|
|
869
|
+
)
|
|
870
|
+
app.fmax_fi = widgets.BoundedFloatText(
|
|
871
|
+
value=default_fmax,
|
|
872
|
+
min=0.001,
|
|
873
|
+
max=1.0,
|
|
874
|
+
step=0.005,
|
|
875
|
+
description="Force thr. (eV/Å):",
|
|
876
|
+
style={"description_width": "130px"},
|
|
877
|
+
layout=layout_fn(width="270px"),
|
|
878
|
+
)
|
|
879
|
+
app.max_steps_si = widgets.BoundedIntText(
|
|
880
|
+
value=default_opt_steps,
|
|
881
|
+
min=10,
|
|
882
|
+
max=1000,
|
|
883
|
+
description="Max steps:",
|
|
884
|
+
style={"description_width": "100px"},
|
|
885
|
+
layout=layout_fn(width="200px"),
|
|
886
|
+
)
|
|
887
|
+
app.nstates_si = widgets.BoundedIntText(
|
|
888
|
+
value=10,
|
|
889
|
+
min=1,
|
|
890
|
+
max=50,
|
|
891
|
+
description="# states:",
|
|
892
|
+
style={"description_width": "100px"},
|
|
893
|
+
layout=layout_fn(width="180px"),
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
# Seed geometry — shared across Geometry Opt / Frequency / UV-Vis (TD-DFT):
|
|
897
|
+
# start a run from a previously optimised geometry instead of the current
|
|
898
|
+
# molecule (e.g. optimise cheaply, then refine at a higher level of
|
|
899
|
+
# theory). Only one of these three calc types is ever visible at a time,
|
|
900
|
+
# so ONE widget group is built here and referenced under all three
|
|
901
|
+
# historical per-calc-type attribute names below (UXP2.5, M-UX2,
|
|
902
|
+
# 2026-07-31) — this used to be three near-identical widget groups.
|
|
903
|
+
# Real object count is one; the aliases exist so app.py, app_runflow.py,
|
|
904
|
+
# and the existing per-calc-type tests keep working unchanged.
|
|
905
|
+
app._seed_dd = widgets.Dropdown(
|
|
906
|
+
options=[("(use current molecule)", "")],
|
|
907
|
+
description="Seed geometry:",
|
|
908
|
+
style={"description_width": "110px"},
|
|
909
|
+
layout=layout_fn(width="auto", flex="1 1 auto", min_width="260px"),
|
|
910
|
+
tooltip=(
|
|
911
|
+
"Optionally start from the final geometry of a previous Geo Opt "
|
|
912
|
+
"result — e.g. optimise at a low level of theory, then refine here"
|
|
913
|
+
),
|
|
914
|
+
)
|
|
915
|
+
app._seed_refresh_btn = widgets.Button(
|
|
916
|
+
description="",
|
|
917
|
+
icon="refresh",
|
|
918
|
+
layout=layout_fn(width="32px", height="32px"),
|
|
919
|
+
tooltip="Refresh the list of saved geometry optimisations",
|
|
920
|
+
)
|
|
921
|
+
app._seed_note = widgets.HTML("")
|
|
922
|
+
app._geo_seed_dd = app._freq_seed_dd = app._tddft_seed_dd = app._seed_dd
|
|
923
|
+
app._geo_seed_refresh_btn = app._freq_seed_refresh_btn = (
|
|
924
|
+
app._tddft_seed_refresh_btn
|
|
925
|
+
) = app._seed_refresh_btn
|
|
926
|
+
app._geo_seed_note = app._freq_seed_note = app._tddft_seed_note = app._seed_note
|
|
927
|
+
|
|
928
|
+
app._freq_preopt_cb = widgets.Checkbox(
|
|
929
|
+
value=False,
|
|
930
|
+
description="Geometry optimization before calculation (QM, slower)",
|
|
931
|
+
style={"description_width": "initial"},
|
|
932
|
+
indent=False,
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
app._scan_type_dd = widgets.Dropdown(
|
|
936
|
+
options=["Bond", "Angle", "Dihedral"],
|
|
937
|
+
value="Bond",
|
|
938
|
+
description="Scan type:",
|
|
939
|
+
style={"description_width": "80px"},
|
|
940
|
+
layout=layout_fn(width="220px"),
|
|
941
|
+
)
|
|
942
|
+
atom_idx_layout = layout_fn(width="95px")
|
|
943
|
+
atom_idx_style = {"description_width": "50px"}
|
|
944
|
+
app._scan_atom1 = widgets.BoundedIntText(
|
|
945
|
+
value=1,
|
|
946
|
+
min=1,
|
|
947
|
+
max=999,
|
|
948
|
+
description="Atom 1:",
|
|
949
|
+
style=atom_idx_style,
|
|
950
|
+
layout=atom_idx_layout,
|
|
951
|
+
)
|
|
952
|
+
app._scan_atom2 = widgets.BoundedIntText(
|
|
953
|
+
value=2,
|
|
954
|
+
min=1,
|
|
955
|
+
max=999,
|
|
956
|
+
description="Atom 2:",
|
|
957
|
+
style=atom_idx_style,
|
|
958
|
+
layout=atom_idx_layout,
|
|
959
|
+
)
|
|
960
|
+
app._scan_atom3 = widgets.BoundedIntText(
|
|
961
|
+
value=3,
|
|
962
|
+
min=1,
|
|
963
|
+
max=999,
|
|
964
|
+
description="Atom 3:",
|
|
965
|
+
style=atom_idx_style,
|
|
966
|
+
layout=atom_idx_layout,
|
|
967
|
+
)
|
|
968
|
+
app._scan_atom4 = widgets.BoundedIntText(
|
|
969
|
+
value=4,
|
|
970
|
+
min=1,
|
|
971
|
+
max=999,
|
|
972
|
+
description="Atom 4:",
|
|
973
|
+
style=atom_idx_style,
|
|
974
|
+
layout=atom_idx_layout,
|
|
975
|
+
)
|
|
976
|
+
app._scan_atom34_box = widgets.HBox(
|
|
977
|
+
[app._scan_atom3, app._scan_atom4],
|
|
978
|
+
layout=layout_fn(gap="4px"),
|
|
979
|
+
)
|
|
980
|
+
app._scan_start = widgets.BoundedFloatText(
|
|
981
|
+
value=0.5,
|
|
982
|
+
min=0.01,
|
|
983
|
+
max=1000.0,
|
|
984
|
+
step=0.1,
|
|
985
|
+
description="Start:",
|
|
986
|
+
style={"description_width": "40px"},
|
|
987
|
+
layout=layout_fn(width="140px"),
|
|
988
|
+
)
|
|
989
|
+
app._scan_stop = widgets.BoundedFloatText(
|
|
990
|
+
value=2.0,
|
|
991
|
+
min=0.01,
|
|
992
|
+
max=1000.0,
|
|
993
|
+
step=0.1,
|
|
994
|
+
description="Stop:",
|
|
995
|
+
style={"description_width": "40px"},
|
|
996
|
+
layout=layout_fn(width="140px"),
|
|
997
|
+
)
|
|
998
|
+
app._scan_steps = widgets.BoundedIntText(
|
|
999
|
+
value=10,
|
|
1000
|
+
min=2,
|
|
1001
|
+
max=100,
|
|
1002
|
+
description="Points:",
|
|
1003
|
+
style={"description_width": "50px"},
|
|
1004
|
+
layout=layout_fn(width="120px"),
|
|
1005
|
+
)
|
|
1006
|
+
app._scan_unit_lbl = widgets.HTML(
|
|
1007
|
+
'<span style="font-size:12px;color:#555">Å</span>'
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
# Reorganization energy (Marcus 4-point). The mode selector chooses which
|
|
1011
|
+
# charge-transfer channel(s) to compute; "Both" shares the neutral geometry
|
|
1012
|
+
# optimization across the hole and electron channels.
|
|
1013
|
+
app._reorg_mode_dd = widgets.Dropdown(
|
|
1014
|
+
options=[
|
|
1015
|
+
("Both (hole + electron)", "both"),
|
|
1016
|
+
("Hole (cation, +1)", "hole"),
|
|
1017
|
+
("Electron (anion, −1)", "electron"),
|
|
1018
|
+
],
|
|
1019
|
+
value="both",
|
|
1020
|
+
description="Channel:",
|
|
1021
|
+
style={"description_width": "80px"},
|
|
1022
|
+
layout=layout_fn(width="320px"),
|
|
1023
|
+
tooltip="Which reorganization energy channel(s) to compute",
|
|
1024
|
+
)
|
|
1025
|
+
app._reorg_note = widgets.HTML(
|
|
1026
|
+
'<span style="color:#555;font-size:12px">'
|
|
1027
|
+
"4-point Marcus scheme: optimizes the neutral and ion geometries, then "
|
|
1028
|
+
"evaluates the four single-point energies to obtain λ. Runs 2–3 geometry "
|
|
1029
|
+
"optimizations, so it is slower than a single calculation.</span>"
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
app.calc_extra_opts = widgets.VBox([])
|
|
1033
|
+
|
|
1034
|
+
app.method_help_btn = widgets.Button(
|
|
1035
|
+
description="?",
|
|
1036
|
+
button_style="",
|
|
1037
|
+
layout=layout_fn(width="28px", height="28px"),
|
|
1038
|
+
tooltip="RHF vs UHF — opens Help tab",
|
|
1039
|
+
)
|
|
1040
|
+
app.basis_help_btn = widgets.Button(
|
|
1041
|
+
description="?",
|
|
1042
|
+
button_style="",
|
|
1043
|
+
layout=layout_fn(width="28px", height="28px"),
|
|
1044
|
+
tooltip="Choosing a basis set — opens Help tab",
|
|
1045
|
+
)
|
|
1046
|
+
app.calc_type_help_btn = widgets.Button(
|
|
1047
|
+
description="?",
|
|
1048
|
+
button_style="",
|
|
1049
|
+
layout=layout_fn(width="28px", height="28px"),
|
|
1050
|
+
tooltip="What each calculation type computes — opens Help tab",
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
app.run_btn = widgets.Button(
|
|
1054
|
+
description="Run Calculation",
|
|
1055
|
+
button_style="success",
|
|
1056
|
+
icon="play",
|
|
1057
|
+
disabled=True,
|
|
1058
|
+
layout=layout_fn(width="200px", height="36px"),
|
|
1059
|
+
)
|
|
1060
|
+
app.run_status = widgets.Label()
|
|
1061
|
+
# Live elapsed-time chip next to the status text — a bg-thread
|
|
1062
|
+
# ticker updates it every ~1 s while a calc runs, so even a fully silent
|
|
1063
|
+
# native phase (first gradient / Hessian) visibly advances.
|
|
1064
|
+
app._run_elapsed_lbl = widgets.HTML(value="")
|
|
1065
|
+
|
|
1066
|
+
# Gracefully stops a running calculation at the next SCF cycle / opt step.
|
|
1067
|
+
# Disabled unless a calc is in flight (toggled by _do_run).
|
|
1068
|
+
app.cancel_btn = widgets.Button(
|
|
1069
|
+
description="Cancel",
|
|
1070
|
+
button_style="danger",
|
|
1071
|
+
icon="stop",
|
|
1072
|
+
disabled=True,
|
|
1073
|
+
layout=layout_fn(width="110px", height="36px"),
|
|
1074
|
+
tooltip=("Stop the running calculation at the next SCF cycle / optimizer step"),
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
app.log_clear_btn = widgets.Button(
|
|
1078
|
+
description="Clear",
|
|
1079
|
+
button_style="",
|
|
1080
|
+
icon="times",
|
|
1081
|
+
layout=layout_fn(width="90px", height="26px"),
|
|
1082
|
+
tooltip="Clear calculation output (disabled while a calc is running)",
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
app.accumulate_btn = widgets.Button(
|
|
1086
|
+
description="Add to Comparison",
|
|
1087
|
+
button_style="info",
|
|
1088
|
+
icon="plus",
|
|
1089
|
+
disabled=True,
|
|
1090
|
+
layout=layout_fn(width="190px"),
|
|
1091
|
+
)
|
|
1092
|
+
app.clear_btn = widgets.Button(
|
|
1093
|
+
description="Clear",
|
|
1094
|
+
button_style="warning",
|
|
1095
|
+
icon="trash",
|
|
1096
|
+
layout=layout_fn(width="100px"),
|
|
1097
|
+
)
|
|
1098
|
+
app.export_btn = widgets.Button(
|
|
1099
|
+
description="Export Script",
|
|
1100
|
+
button_style="",
|
|
1101
|
+
icon="download",
|
|
1102
|
+
disabled=True,
|
|
1103
|
+
layout=layout_fn(width="160px"),
|
|
1104
|
+
)
|
|
1105
|
+
app.export_status = widgets.Label()
|
|
1106
|
+
rdkit_tip = (
|
|
1107
|
+
"" if rdkit_available else "Requires RDKit (conda install -c conda-forge rdkit)"
|
|
1108
|
+
)
|
|
1109
|
+
app.export_xyz_btn = widgets.Button(
|
|
1110
|
+
description="Export XYZ",
|
|
1111
|
+
icon="download",
|
|
1112
|
+
disabled=True,
|
|
1113
|
+
layout=layout_fn(width="130px"),
|
|
1114
|
+
)
|
|
1115
|
+
app.export_mol_btn = widgets.Button(
|
|
1116
|
+
description="Export MOL",
|
|
1117
|
+
icon="download",
|
|
1118
|
+
disabled=True,
|
|
1119
|
+
tooltip=rdkit_tip,
|
|
1120
|
+
layout=layout_fn(width="130px"),
|
|
1121
|
+
)
|
|
1122
|
+
app.export_pdb_btn = widgets.Button(
|
|
1123
|
+
description="Export PDB",
|
|
1124
|
+
icon="download",
|
|
1125
|
+
disabled=True,
|
|
1126
|
+
tooltip=rdkit_tip,
|
|
1127
|
+
layout=layout_fn(width="130px"),
|
|
1128
|
+
)
|
|
1129
|
+
app.struct_export_status = widgets.Label()
|
|
1130
|
+
# Zip the entire result folder for emailing /
|
|
1131
|
+
# attaching to a writeup. Disabled until ``_last_result_dir`` is set.
|
|
1132
|
+
app._export_bundle_btn = widgets.Button(
|
|
1133
|
+
description="Export bundle (.zip)",
|
|
1134
|
+
icon="file-archive-o",
|
|
1135
|
+
disabled=True,
|
|
1136
|
+
tooltip=(
|
|
1137
|
+
"Zip the entire result folder (geometry, log, orbitals, cubes, "
|
|
1138
|
+
"spectra) for sharing."
|
|
1139
|
+
),
|
|
1140
|
+
layout=layout_fn(width="180px"),
|
|
1141
|
+
)
|
|
1142
|
+
app._export_bundle_status = widgets.Label()
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def build_theme_selector(app: Any, *, layout_fn: Any) -> None:
|
|
1146
|
+
"""Build the theme selector widgets and apply default theme CSS."""
|
|
1147
|
+
app._theme_style = widgets.Output(
|
|
1148
|
+
layout=layout_fn(height="0px", overflow="hidden", margin="0", padding="0")
|
|
1149
|
+
)
|
|
1150
|
+
app._activity_btn = widgets.Button(
|
|
1151
|
+
description="Idle",
|
|
1152
|
+
icon="circle-o",
|
|
1153
|
+
tooltip="No active operations.",
|
|
1154
|
+
button_style="success",
|
|
1155
|
+
layout=layout_fn(width="118px", margin="0 8px 0 0"),
|
|
1156
|
+
)
|
|
1157
|
+
app.theme_btn = widgets.ToggleButtons(
|
|
1158
|
+
options=["Light", "Dark"],
|
|
1159
|
+
value="Dark",
|
|
1160
|
+
description="Theme:",
|
|
1161
|
+
style={"description_width": "48px", "button_width": "90px"},
|
|
1162
|
+
layout=layout_fn(margin="0"),
|
|
1163
|
+
)
|
|
1164
|
+
with app._theme_style:
|
|
1165
|
+
display(HTML(app._theme_css("Dark")))
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def build_welcome_header(app: Any, *, layout_fn: Any = None) -> None:
|
|
1169
|
+
"""Build the QuantUI welcome banner.
|
|
1170
|
+
|
|
1171
|
+
Third iteration (2026-05-25): the
|
|
1172
|
+
``<img src="data:image/svg+xml;base64,...">`` approach failed too
|
|
1173
|
+
— Voilà's HTML sanitizer (stricter than JupyterLab's) strips
|
|
1174
|
+
``data:`` URIs from ``<img src>`` attributes. The third iteration
|
|
1175
|
+
uses ``widgets.Image(value=svg_bytes, format="svg+xml")`` which
|
|
1176
|
+
routes the SVG through Jupyter's binary widget channel, bypassing
|
|
1177
|
+
the HTML sanitizer entirely. CSS animations inside the SVG still
|
|
1178
|
+
run because the front-end serves it as an external SVG document.
|
|
1179
|
+
|
|
1180
|
+
The original ``_welcome_html`` widget remains (the exit handler at
|
|
1181
|
+
``app_runflow.on_exit_clicked`` rewrites its ``.value`` with the
|
|
1182
|
+
shutdown message; an HBox-based wrapper would break that path).
|
|
1183
|
+
A new ``_welcome_header`` HBox combines the logo widget + text
|
|
1184
|
+
widget for the display() entry point.
|
|
1185
|
+
"""
|
|
1186
|
+
# Full SVG. Includes the orbital animations ported from
|
|
1187
|
+
# ``docs/logo.svg`` — three rings spinning at 9 s / 13 s reverse /
|
|
1188
|
+
# 17 s with prefers-reduced-motion respected.
|
|
1189
|
+
_logo_svg_raw = (
|
|
1190
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 280">'
|
|
1191
|
+
"<defs>"
|
|
1192
|
+
"<style>"
|
|
1193
|
+
".qring{transform-origin:140px 140px}"
|
|
1194
|
+
".qring--1{animation:qspin1 9s linear infinite}"
|
|
1195
|
+
".qring--2{animation:qspin2 13s linear infinite reverse;"
|
|
1196
|
+
"transform:rotate(60deg)}"
|
|
1197
|
+
".qring--3{animation:qspin3 17s linear infinite;"
|
|
1198
|
+
"transform:rotate(120deg)}"
|
|
1199
|
+
"@keyframes qspin1{to{transform:rotate(360deg)}}"
|
|
1200
|
+
"@keyframes qspin2{to{transform:rotate(-300deg)}}"
|
|
1201
|
+
"@keyframes qspin3{to{transform:rotate(480deg)}}"
|
|
1202
|
+
"@media (prefers-reduced-motion:reduce){"
|
|
1203
|
+
".qring{animation-play-state:paused}}"
|
|
1204
|
+
"</style>"
|
|
1205
|
+
'<filter id="q-glow" x="-50%" y="-50%" width="200%" height="200%">'
|
|
1206
|
+
'<feGaussianBlur stdDeviation="7" result="blur"/>'
|
|
1207
|
+
"<feMerge>"
|
|
1208
|
+
'<feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/>'
|
|
1209
|
+
"</feMerge></filter>"
|
|
1210
|
+
'<filter id="q-halo" x="-80%" y="-80%" width="260%" height="260%">'
|
|
1211
|
+
'<feGaussianBlur stdDeviation="22" result="blur"/>'
|
|
1212
|
+
"<feMerge>"
|
|
1213
|
+
'<feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/>'
|
|
1214
|
+
"</feMerge></filter>"
|
|
1215
|
+
"</defs>"
|
|
1216
|
+
'<circle cx="140" cy="140" r="48"'
|
|
1217
|
+
' fill="rgba(37,99,235,0.20)" filter="url(#q-halo)"/>'
|
|
1218
|
+
'<g class="qring qring--1">'
|
|
1219
|
+
'<ellipse cx="140" cy="140" rx="115" ry="33" fill="none"'
|
|
1220
|
+
' stroke="#0891b2" stroke-width="1.4" opacity="0.70"/>'
|
|
1221
|
+
'<circle cx="255" cy="140" r="5.5" fill="#67e8f9"/>'
|
|
1222
|
+
"</g>"
|
|
1223
|
+
'<g class="qring qring--2">'
|
|
1224
|
+
'<ellipse cx="140" cy="140" rx="115" ry="33" fill="none"'
|
|
1225
|
+
' stroke="#0891b2" stroke-width="1.4" opacity="0.55"/>'
|
|
1226
|
+
'<circle cx="255" cy="140" r="4.5" fill="#93c5fd"/>'
|
|
1227
|
+
"</g>"
|
|
1228
|
+
'<g class="qring qring--3">'
|
|
1229
|
+
'<ellipse cx="140" cy="140" rx="115" ry="33" fill="none"'
|
|
1230
|
+
' stroke="#3b82f6" stroke-width="1.4" opacity="0.42"/>'
|
|
1231
|
+
'<circle cx="255" cy="140" r="4" fill="#60a5fa"/>'
|
|
1232
|
+
"</g>"
|
|
1233
|
+
'<circle cx="140" cy="140" r="20"'
|
|
1234
|
+
' fill="rgba(37,99,235,0.25)" filter="url(#q-glow)"/>'
|
|
1235
|
+
'<circle cx="140" cy="140" r="14"'
|
|
1236
|
+
' fill="#2563eb" filter="url(#q-glow)"/>'
|
|
1237
|
+
'<circle cx="140" cy="140" r="8" fill="#60a5fa"/>'
|
|
1238
|
+
'<circle cx="137" cy="137" r="3" fill="rgba(255,255,255,0.45)"/>'
|
|
1239
|
+
"</svg>"
|
|
1240
|
+
)
|
|
1241
|
+
# widgets.Image takes the raw SVG bytes and serves them as
|
|
1242
|
+
# ``format="svg+xml"`` over Jupyter's BINARY widget channel — no
|
|
1243
|
+
# HTML sanitizer touches the bytes, no ``data:`` URI restriction.
|
|
1244
|
+
# The browser renders the SVG natively as an image (CSS animations
|
|
1245
|
+
# inside the SVG still play).
|
|
1246
|
+
app._welcome_logo = widgets.Image(
|
|
1247
|
+
value=_logo_svg_raw.encode("utf-8"),
|
|
1248
|
+
format="svg+xml",
|
|
1249
|
+
width=120,
|
|
1250
|
+
height=120,
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
# Text-only HTML. ``_welcome_html`` is kept as a pure HTML widget so
|
|
1254
|
+
# ``app_runflow.on_exit_clicked`` can still ``.value = ...`` it with
|
|
1255
|
+
# the shutdown message.
|
|
1256
|
+
text_html = (
|
|
1257
|
+
"<div>"
|
|
1258
|
+
'<div style="font-size:44px;font-weight:700;letter-spacing:-0.8px;'
|
|
1259
|
+
'color:#0f172a;line-height:1.05">QuantUI</div>'
|
|
1260
|
+
'<div style="font-size:20px;color:#475569;margin-top:7px">'
|
|
1261
|
+
"Free, open, and interactive quantum chemistry</div>"
|
|
1262
|
+
'<div style="font-size:13px;color:#94a3b8;margin-top:5px">'
|
|
1263
|
+
f"v{quantui.__version__} · "
|
|
1264
|
+
"<b>Help</b> tab for instructions · "
|
|
1265
|
+
"<b>System Settings</b> tab for environment + calibration</div>"
|
|
1266
|
+
"</div>"
|
|
1267
|
+
)
|
|
1268
|
+
app._welcome_html = widgets.HTML(value=text_html)
|
|
1269
|
+
|
|
1270
|
+
# Container that combines logo + text. ``display()`` mounts this
|
|
1271
|
+
# instead of ``_welcome_html`` directly (see app.py:1001).
|
|
1272
|
+
_layout = (
|
|
1273
|
+
layout_fn if layout_fn is not None else (lambda **kw: widgets.Layout(**kw))
|
|
1274
|
+
)
|
|
1275
|
+
app._welcome_header = widgets.HBox(
|
|
1276
|
+
[app._welcome_logo, app._welcome_html],
|
|
1277
|
+
layout=_layout(
|
|
1278
|
+
align_items="center",
|
|
1279
|
+
justify_content="flex-start",
|
|
1280
|
+
padding="22px 4px 18px",
|
|
1281
|
+
margin="0 0 4px",
|
|
1282
|
+
border_bottom="1px solid #e2e8f0",
|
|
1283
|
+
),
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def build_molecule_section(
|
|
1288
|
+
app: Any,
|
|
1289
|
+
*,
|
|
1290
|
+
layout_fn: Any,
|
|
1291
|
+
pubchem_available: bool,
|
|
1292
|
+
visualization_available: bool,
|
|
1293
|
+
) -> None:
|
|
1294
|
+
"""Build molecule input widgets and collapsed summary container."""
|
|
1295
|
+
# ── Library browse/search — category filter + search + results.
|
|
1296
|
+
cat_opts = [("All categories", "")] + [
|
|
1297
|
+
(_category_label(c), c) for c in _ml.categories()
|
|
1298
|
+
]
|
|
1299
|
+
app.lib_category_dd = widgets.Dropdown(
|
|
1300
|
+
options=cat_opts,
|
|
1301
|
+
value="",
|
|
1302
|
+
description="Category:",
|
|
1303
|
+
style={"description_width": "70px"},
|
|
1304
|
+
layout=layout_fn(width="260px"),
|
|
1305
|
+
)
|
|
1306
|
+
app.lib_search_txt = widgets.Text(
|
|
1307
|
+
placeholder="search name or formula (e.g. aspirin, C6H6)",
|
|
1308
|
+
continuous_update=False, # fire on Enter/blur, not per keystroke
|
|
1309
|
+
layout=layout_fn(width="300px"),
|
|
1310
|
+
)
|
|
1311
|
+
init_opts, init_note = library_result_options()
|
|
1312
|
+
app.lib_results_dd = widgets.Dropdown(
|
|
1313
|
+
options=init_opts,
|
|
1314
|
+
value="",
|
|
1315
|
+
description="Molecule:",
|
|
1316
|
+
style={"description_width": "70px"},
|
|
1317
|
+
layout=layout_fn(width="420px"),
|
|
1318
|
+
)
|
|
1319
|
+
app.lib_count_lbl = widgets.HTML(
|
|
1320
|
+
f'<span style="color:#888;font-size:12px">{init_note}</span>'
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
app.xyz_area = widgets.Textarea(
|
|
1324
|
+
placeholder=(
|
|
1325
|
+
"Paste XYZ coordinates (symbol x y z):\n"
|
|
1326
|
+
"O 0.000 0.000 0.000\n"
|
|
1327
|
+
"H 0.757 0.587 0.000\n"
|
|
1328
|
+
"H -0.757 0.587 0.000"
|
|
1329
|
+
),
|
|
1330
|
+
layout=layout_fn(width="440px", height="130px"),
|
|
1331
|
+
)
|
|
1332
|
+
app.xyz_btn = widgets.Button(
|
|
1333
|
+
description="Load XYZ", button_style="info", icon="upload"
|
|
1334
|
+
)
|
|
1335
|
+
app.xyz_msg = widgets.Label()
|
|
1336
|
+
|
|
1337
|
+
app.pubchem_txt = widgets.Text(
|
|
1338
|
+
placeholder="name, SMILES, CID, or InChI (e.g. aspirin, CC(=O)O, 2244)",
|
|
1339
|
+
layout=layout_fn(width="380px"),
|
|
1340
|
+
)
|
|
1341
|
+
app.pubchem_btn = widgets.Button(
|
|
1342
|
+
description="Search",
|
|
1343
|
+
button_style="info",
|
|
1344
|
+
icon="search",
|
|
1345
|
+
disabled=not pubchem_available,
|
|
1346
|
+
layout=layout_fn(width="100px"),
|
|
1347
|
+
)
|
|
1348
|
+
app.pubchem_msg = widgets.Label(
|
|
1349
|
+
value=(
|
|
1350
|
+
""
|
|
1351
|
+
if pubchem_available
|
|
1352
|
+
else "PubChem unavailable — check internet connection"
|
|
1353
|
+
)
|
|
1354
|
+
)
|
|
1355
|
+
# Disambiguation pick-list — hidden until a query has >1 match.
|
|
1356
|
+
app.pubchem_candidates_dd = widgets.Dropdown(
|
|
1357
|
+
options=[("— pick a match —", "")],
|
|
1358
|
+
value="",
|
|
1359
|
+
description="Matches:",
|
|
1360
|
+
style={"description_width": "70px"},
|
|
1361
|
+
layout=layout_fn(width="460px"),
|
|
1362
|
+
)
|
|
1363
|
+
app.pubchem_candidates_dd.layout.display = "none"
|
|
1364
|
+
|
|
1365
|
+
hint = '<p style="margin:4px 0 8px;color:#666;font-size:13px">'
|
|
1366
|
+
tab_preset = widgets.VBox(
|
|
1367
|
+
[
|
|
1368
|
+
widgets.HTML(
|
|
1369
|
+
hint + "Browse the bundled library — filter by category or "
|
|
1370
|
+
"search by name/formula. Includes thousands of small molecules "
|
|
1371
|
+
"for offline use.</p>"
|
|
1372
|
+
),
|
|
1373
|
+
widgets.HBox([app.lib_category_dd, app.lib_search_txt]),
|
|
1374
|
+
app.lib_results_dd,
|
|
1375
|
+
app.lib_count_lbl,
|
|
1376
|
+
]
|
|
1377
|
+
)
|
|
1378
|
+
tab_xyz = widgets.VBox(
|
|
1379
|
+
[
|
|
1380
|
+
widgets.HTML(
|
|
1381
|
+
hint + "Paste XYZ coordinates (element x y z, one atom per line).</p>"
|
|
1382
|
+
),
|
|
1383
|
+
app.xyz_area,
|
|
1384
|
+
widgets.HBox([app.xyz_btn, app.xyz_msg]),
|
|
1385
|
+
]
|
|
1386
|
+
)
|
|
1387
|
+
tab_pubchem = widgets.VBox(
|
|
1388
|
+
[
|
|
1389
|
+
widgets.HTML(
|
|
1390
|
+
hint + "Search by name, SMILES, CID, or InChI. Tries PubChem "
|
|
1391
|
+
"then NCI CACTUS, and falls back to the bundled library offline."
|
|
1392
|
+
"</p>"
|
|
1393
|
+
),
|
|
1394
|
+
widgets.HBox([app.pubchem_txt, app.pubchem_btn]),
|
|
1395
|
+
app.pubchem_msg,
|
|
1396
|
+
app.pubchem_candidates_dd,
|
|
1397
|
+
]
|
|
1398
|
+
)
|
|
1399
|
+
input_tab = widgets.Tab(children=[tab_preset, tab_xyz, tab_pubchem])
|
|
1400
|
+
for i, title in enumerate(["Library", "XYZ Input", "Online Search"]):
|
|
1401
|
+
input_tab.set_title(i, title)
|
|
1402
|
+
|
|
1403
|
+
app.mol_input_expanded = widgets.VBox(
|
|
1404
|
+
[
|
|
1405
|
+
widgets.HTML('<h3 style="margin:8px 0 6px">Molecule Input</h3>'),
|
|
1406
|
+
input_tab,
|
|
1407
|
+
]
|
|
1408
|
+
)
|
|
1409
|
+
app.change_mol_btn = widgets.Button(
|
|
1410
|
+
description="Change",
|
|
1411
|
+
button_style="",
|
|
1412
|
+
icon="pencil",
|
|
1413
|
+
layout=layout_fn(width="100px", height="32px"),
|
|
1414
|
+
tooltip="Re-expand the molecule input panel",
|
|
1415
|
+
)
|
|
1416
|
+
app.mol_input_collapsed = widgets.HBox(
|
|
1417
|
+
[app.mol_summary_compact, app.change_mol_btn],
|
|
1418
|
+
layout=layout_fn(align_items="center", gap="12px", padding="6px 0"),
|
|
1419
|
+
)
|
|
1420
|
+
mol_container_children = [
|
|
1421
|
+
app.mol_input_expanded,
|
|
1422
|
+
app.mol_info_html,
|
|
1423
|
+
app.viz_output,
|
|
1424
|
+
]
|
|
1425
|
+
if app.viz_backend_toggle is not None:
|
|
1426
|
+
mol_container_children.append(app.viz_backend_toggle)
|
|
1427
|
+
if visualization_available:
|
|
1428
|
+
mol_container_children.append(app.viz_controls_box)
|
|
1429
|
+
app.mol_input_container = widgets.VBox(
|
|
1430
|
+
mol_container_children,
|
|
1431
|
+
layout=layout_fn(margin="0 0 4px 0"),
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
def build_calc_setup(app: Any, *, layout_fn: Any) -> None:
|
|
1436
|
+
"""Build the calculation setup panel."""
|
|
1437
|
+
app.calc_setup_panel = widgets.VBox(
|
|
1438
|
+
[
|
|
1439
|
+
widgets.HTML('<h3 style="margin:14px 0 6px">Calculation Setup</h3>'),
|
|
1440
|
+
widgets.HBox(
|
|
1441
|
+
[
|
|
1442
|
+
widgets.VBox(
|
|
1443
|
+
[
|
|
1444
|
+
widgets.HBox(
|
|
1445
|
+
[app.method_dd, app.method_help_btn],
|
|
1446
|
+
layout=layout_fn(align_items="center", gap="4px"),
|
|
1447
|
+
),
|
|
1448
|
+
widgets.HBox(
|
|
1449
|
+
[app.basis_dd, app.basis_help_btn],
|
|
1450
|
+
layout=layout_fn(align_items="center", gap="4px"),
|
|
1451
|
+
),
|
|
1452
|
+
]
|
|
1453
|
+
),
|
|
1454
|
+
widgets.HTML("  "),
|
|
1455
|
+
widgets.VBox([app.charge_si, app.mult_si]),
|
|
1456
|
+
widgets.HTML(" "),
|
|
1457
|
+
# Descriptor cards — to the right of the inputs so
|
|
1458
|
+
# they don't displace charge/multiplicity.
|
|
1459
|
+
app._descriptor_cards_box,
|
|
1460
|
+
],
|
|
1461
|
+
layout=layout_fn(flex_wrap="wrap", align_items="flex-start"),
|
|
1462
|
+
),
|
|
1463
|
+
app._open_shell_hint,
|
|
1464
|
+
widgets.HBox(
|
|
1465
|
+
[app.calc_type_dd, app.calc_type_help_btn],
|
|
1466
|
+
layout=layout_fn(align_items="center", gap="4px"),
|
|
1467
|
+
),
|
|
1468
|
+
app.calc_extra_opts,
|
|
1469
|
+
widgets.HBox(
|
|
1470
|
+
[app.preopt_preview_label, app.preopt_preview_btn],
|
|
1471
|
+
layout=layout_fn(align_items="center", gap="10px"),
|
|
1472
|
+
),
|
|
1473
|
+
app.preopt_preview_box,
|
|
1474
|
+
app._freq_preopt_cb,
|
|
1475
|
+
widgets.HBox(
|
|
1476
|
+
[app.solvent_cb, app.solvent_dd],
|
|
1477
|
+
layout=layout_fn(align_items="center", gap="4px"),
|
|
1478
|
+
),
|
|
1479
|
+
]
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def build_run_section(app: Any, *, layout_fn: Any) -> None:
|
|
1484
|
+
"""Build the run panel shown in the Calculate tab."""
|
|
1485
|
+
app.run_panel = widgets.VBox(
|
|
1486
|
+
[
|
|
1487
|
+
widgets.HTML(
|
|
1488
|
+
'<h3 style="margin:14px 0 6px">Run Calculation</h3>'
|
|
1489
|
+
'<p style="color:#555;font-size:13px;margin:0 0 8px">PySCF runs in this '
|
|
1490
|
+
"kernel. Output appears live below. Large molecules or high-accuracy basis "
|
|
1491
|
+
"sets may take several minutes on a laptop.</p>"
|
|
1492
|
+
),
|
|
1493
|
+
app.perf_estimate_html,
|
|
1494
|
+
widgets.HBox(
|
|
1495
|
+
[
|
|
1496
|
+
app.run_btn,
|
|
1497
|
+
app.cancel_btn,
|
|
1498
|
+
# Status + elapsed/remaining chip stacked vertically so the
|
|
1499
|
+
# timer never crowds/truncates the (longer) status line.
|
|
1500
|
+
widgets.VBox(
|
|
1501
|
+
[app.run_status, app._run_elapsed_lbl],
|
|
1502
|
+
layout=layout_fn(gap="0px"),
|
|
1503
|
+
),
|
|
1504
|
+
],
|
|
1505
|
+
layout=layout_fn(align_items="center", gap="8px"),
|
|
1506
|
+
),
|
|
1507
|
+
widgets.HBox(
|
|
1508
|
+
[
|
|
1509
|
+
widgets.HTML(
|
|
1510
|
+
'<span style="font-size:13px;font-weight:600;color:#444">'
|
|
1511
|
+
"Calculation Output</span>"
|
|
1512
|
+
),
|
|
1513
|
+
app.log_clear_btn,
|
|
1514
|
+
],
|
|
1515
|
+
layout=layout_fn(
|
|
1516
|
+
align_items="center",
|
|
1517
|
+
justify_content="space-between",
|
|
1518
|
+
margin="10px 0 4px",
|
|
1519
|
+
max_width="460px",
|
|
1520
|
+
),
|
|
1521
|
+
),
|
|
1522
|
+
app.run_output,
|
|
1523
|
+
]
|
|
1524
|
+
)
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def build_results_section(app: Any, *, layout_fn: Any) -> None:
|
|
1528
|
+
"""Build results and analysis tab panels/widgets."""
|
|
1529
|
+
|
|
1530
|
+
def _plot_export_row(prefix: str) -> widgets.HBox:
|
|
1531
|
+
fmt_dd = widgets.Dropdown(
|
|
1532
|
+
options=[("HTML", "html"), ("PNG", "png")],
|
|
1533
|
+
value="html",
|
|
1534
|
+
description="Export:",
|
|
1535
|
+
style={"description_width": "55px"},
|
|
1536
|
+
layout=layout_fn(width="170px"),
|
|
1537
|
+
)
|
|
1538
|
+
btn = widgets.Button(
|
|
1539
|
+
description="Save Plot",
|
|
1540
|
+
icon="download",
|
|
1541
|
+
layout=layout_fn(width="130px"),
|
|
1542
|
+
tooltip="Export the current plot as HTML or PNG",
|
|
1543
|
+
)
|
|
1544
|
+
# Per-panel "Copy data" button that exports
|
|
1545
|
+
# the underlying numerical data to CSV (saved to result_dir) and
|
|
1546
|
+
# also attempts to copy to the system clipboard via the browser
|
|
1547
|
+
# API. Status widget below is shared with the Save Plot path —
|
|
1548
|
+
# whichever action runs last updates the visible status string.
|
|
1549
|
+
copy_btn = widgets.Button(
|
|
1550
|
+
description="Copy data",
|
|
1551
|
+
icon="clipboard",
|
|
1552
|
+
layout=layout_fn(width="120px"),
|
|
1553
|
+
tooltip=(
|
|
1554
|
+
"Save the plot's underlying (x, y) data to CSV in the "
|
|
1555
|
+
"result folder and copy it to the system clipboard"
|
|
1556
|
+
),
|
|
1557
|
+
)
|
|
1558
|
+
status = widgets.HTML(value="", layout=layout_fn(margin="0 0 0 8px"))
|
|
1559
|
+
setattr(app, f"_{prefix}_export_fmt_dd", fmt_dd)
|
|
1560
|
+
setattr(app, f"_{prefix}_export_btn", btn)
|
|
1561
|
+
setattr(app, f"_{prefix}_copy_data_btn", copy_btn)
|
|
1562
|
+
setattr(app, f"_{prefix}_export_status", status)
|
|
1563
|
+
return widgets.HBox(
|
|
1564
|
+
[fmt_dd, btn, copy_btn, status],
|
|
1565
|
+
layout=layout_fn(align_items="center", margin="0 0 6px 0", gap="6px"),
|
|
1566
|
+
)
|
|
1567
|
+
|
|
1568
|
+
pes_export_row = _plot_export_row("pes")
|
|
1569
|
+
app._pes_plot_html = widgets.Output(layout=layout_fn(width="100%"))
|
|
1570
|
+
app._pes_scan_accordion = widgets.Accordion(
|
|
1571
|
+
children=[
|
|
1572
|
+
widgets.VBox(
|
|
1573
|
+
[pes_export_row, app._pes_plot_html],
|
|
1574
|
+
layout=layout_fn(padding="8px"),
|
|
1575
|
+
)
|
|
1576
|
+
],
|
|
1577
|
+
layout=layout_fn(display="none", margin="8px 0"),
|
|
1578
|
+
)
|
|
1579
|
+
app._pes_scan_accordion.set_title(0, "PES Energy Profile")
|
|
1580
|
+
app._pes_scan_accordion.selected_index = None
|
|
1581
|
+
|
|
1582
|
+
# traj_output is a VBox container (NOT widgets.Output) so widget content
|
|
1583
|
+
# can be added as direct children via `traj_output.children = (...)`.
|
|
1584
|
+
# Using `widgets.Output` here previously caused widget references inside
|
|
1585
|
+
# `with output: display(widget)` to be deferred/asynchronous, leaving the
|
|
1586
|
+
# accordion visibly empty even after _show_opt_trajectory logged success.
|
|
1587
|
+
app.traj_output = widgets.VBox(layout=layout_fn(margin="0"))
|
|
1588
|
+
app.traj_accordion = widgets.Accordion(
|
|
1589
|
+
children=[app.traj_output],
|
|
1590
|
+
layout=layout_fn(display="none", margin="8px 0"),
|
|
1591
|
+
)
|
|
1592
|
+
app.traj_accordion.set_title(0, "Trajectory Viewer")
|
|
1593
|
+
app.traj_accordion.selected_index = None
|
|
1594
|
+
app.traj_accordion.observe(
|
|
1595
|
+
app._safe_cb(app._on_traj_expand), names=["selected_index"]
|
|
1596
|
+
)
|
|
1597
|
+
|
|
1598
|
+
app.vib_mode_dd = widgets.Dropdown(
|
|
1599
|
+
description="Mode:",
|
|
1600
|
+
options=[],
|
|
1601
|
+
style={"description_width": "50px"},
|
|
1602
|
+
layout=layout_fn(width="360px"),
|
|
1603
|
+
)
|
|
1604
|
+
# Prev/next arrow buttons for one-step navigation through modes. Click
|
|
1605
|
+
# handlers step ``vib_mode_dd.value`` to the adjacent option; the
|
|
1606
|
+
# existing dropdown observer then drives the re-render. Mirrors the
|
|
1607
|
+
# trajectory-viewer prev/next pattern.
|
|
1608
|
+
app.vib_prev_btn = widgets.Button(
|
|
1609
|
+
icon="arrow-left",
|
|
1610
|
+
tooltip="Previous mode",
|
|
1611
|
+
layout=layout_fn(width="40px", margin="0 4px 0 0"),
|
|
1612
|
+
disabled=True,
|
|
1613
|
+
)
|
|
1614
|
+
app.vib_next_btn = widgets.Button(
|
|
1615
|
+
icon="arrow-right",
|
|
1616
|
+
tooltip="Next mode",
|
|
1617
|
+
layout=layout_fn(width="40px", margin="0 8px 0 4px"),
|
|
1618
|
+
disabled=True,
|
|
1619
|
+
)
|
|
1620
|
+
vib_mode_row = widgets.HBox(
|
|
1621
|
+
[app.vib_prev_btn, app.vib_mode_dd, app.vib_next_btn],
|
|
1622
|
+
layout=layout_fn(align_items="center", margin="0 0 4px 0"),
|
|
1623
|
+
)
|
|
1624
|
+
# Fixed-dimension Output container so the box never resizes between
|
|
1625
|
+
# content swaps (placeholder ↔ 3Dmol HTML). Without this, the empty
|
|
1626
|
+
# state between atomic outputs assignments briefly collapses the
|
|
1627
|
+
# container, the page reflows up, then reflows back when the new
|
|
1628
|
+
# content arrives — visible as a scroll-jump flicker on every mode
|
|
1629
|
+
# switch. Matches the trajectory frame_out fix pattern. 460+20=480
|
|
1630
|
+
# accommodates the py3Dmol view (460px) plus a small horizontal pad;
|
|
1631
|
+
# 420+20=440 likewise for the 420px view height.
|
|
1632
|
+
app.vib_output = widgets.Output(layout=layout_fn(height="440px", width="480px"))
|
|
1633
|
+
|
|
1634
|
+
# Vibration animation export: writes the current mode as a self-contained
|
|
1635
|
+
# HTML file. Backend selection is independent of the user's default — see
|
|
1636
|
+
# _on_vib_export_animation: plotlymol3d is preferred for export quality,
|
|
1637
|
+
# py3Dmol is the fallback when plotlymol3d isn't installed.
|
|
1638
|
+
app._vib_export_btn = widgets.Button(
|
|
1639
|
+
description="⬇ Export Animation",
|
|
1640
|
+
tooltip=(
|
|
1641
|
+
"Save the current vibrational mode as a self-contained HTML "
|
|
1642
|
+
"file (plotlymol3d preferred for export quality; py3Dmol "
|
|
1643
|
+
"fallback if plotlymol3d is not installed)"
|
|
1644
|
+
),
|
|
1645
|
+
layout=layout_fn(width="180px"),
|
|
1646
|
+
)
|
|
1647
|
+
app._vib_export_status = widgets.HTML(
|
|
1648
|
+
value="",
|
|
1649
|
+
layout=layout_fn(flex="1 1 auto", margin="0 0 0 8px"),
|
|
1650
|
+
)
|
|
1651
|
+
vib_export_row = widgets.HBox(
|
|
1652
|
+
[app._vib_export_btn, app._vib_export_status],
|
|
1653
|
+
layout=layout_fn(align_items="center", margin="6px 0 0 0"),
|
|
1654
|
+
)
|
|
1655
|
+
|
|
1656
|
+
# Hidden sink for Python→JS calls that switch the single-viewer's mode
|
|
1657
|
+
# client-side (window.__quantuiVibSetMode). Kept in the DOM (not display:none)
|
|
1658
|
+
# so the injected Javascript executes; empty/cleared between calls so it
|
|
1659
|
+
# takes no visible space. See app_visualization._vib_bridge_set_mode.
|
|
1660
|
+
app._vib_js_bridge = widgets.Output(layout=layout_fn(margin="0", padding="0"))
|
|
1661
|
+
|
|
1662
|
+
app.vib_accordion = widgets.Accordion(
|
|
1663
|
+
children=[
|
|
1664
|
+
widgets.VBox(
|
|
1665
|
+
[vib_mode_row, app.vib_output, vib_export_row, app._vib_js_bridge],
|
|
1666
|
+
layout=layout_fn(padding="8px"),
|
|
1667
|
+
)
|
|
1668
|
+
],
|
|
1669
|
+
layout=layout_fn(display="none", margin="8px 0"),
|
|
1670
|
+
)
|
|
1671
|
+
app.vib_accordion.set_title(0, "Vibrational Mode Viewer")
|
|
1672
|
+
app.vib_accordion.selected_index = None
|
|
1673
|
+
|
|
1674
|
+
app._ir_mode_toggle = widgets.ToggleButtons(
|
|
1675
|
+
options=["Stick", "Broadened"],
|
|
1676
|
+
value="Stick",
|
|
1677
|
+
style={"button_width": "80px"},
|
|
1678
|
+
layout=layout_fn(margin="0 8px 0 0"),
|
|
1679
|
+
)
|
|
1680
|
+
app._ir_fwhm_slider = widgets.FloatSlider(
|
|
1681
|
+
value=20.0,
|
|
1682
|
+
min=5.0,
|
|
1683
|
+
max=100.0,
|
|
1684
|
+
step=5.0,
|
|
1685
|
+
description="Line width (cm⁻¹):",
|
|
1686
|
+
readout_format=".0f",
|
|
1687
|
+
style={"description_width": "120px"},
|
|
1688
|
+
layout=layout_fn(width="300px", display="none"),
|
|
1689
|
+
# continuous_update=False so dragging the slider only fires on
|
|
1690
|
+
# release, not 30-60 times per second during the drag.
|
|
1691
|
+
# Combined with the atomic outputs swap in _set_html_output this
|
|
1692
|
+
# eliminates the IR re-render storm that caused visible flicker.
|
|
1693
|
+
continuous_update=False,
|
|
1694
|
+
)
|
|
1695
|
+
# min_height matches the Plotly IR figure's intrinsic height (300px in
|
|
1696
|
+
# ir_plot.plot_ir_spectrum) so the Output container does not collapse
|
|
1697
|
+
# to 0px between renders. Pairs with the atomic outputs swap in
|
|
1698
|
+
# _set_html_output to keep mode toggle / slider changes flicker-free.
|
|
1699
|
+
app._ir_fig = widgets.Output(
|
|
1700
|
+
layout=layout_fn(width="100%", min_height="300px"),
|
|
1701
|
+
)
|
|
1702
|
+
ir_export_row = _plot_export_row("ir")
|
|
1703
|
+
|
|
1704
|
+
ir_controls = widgets.HBox(
|
|
1705
|
+
[app._ir_mode_toggle, app._ir_fwhm_slider],
|
|
1706
|
+
layout=layout_fn(align_items="center", margin="0 0 6px 0"),
|
|
1707
|
+
)
|
|
1708
|
+
ir_body_children = [ir_controls, ir_export_row, app._ir_fig]
|
|
1709
|
+
app._ir_accordion = widgets.Accordion(
|
|
1710
|
+
children=[
|
|
1711
|
+
widgets.VBox(
|
|
1712
|
+
ir_body_children,
|
|
1713
|
+
layout=layout_fn(padding="8px"),
|
|
1714
|
+
)
|
|
1715
|
+
],
|
|
1716
|
+
layout=layout_fn(display="none", margin="8px 0"),
|
|
1717
|
+
)
|
|
1718
|
+
app._ir_accordion.set_title(0, "IR Spectrum")
|
|
1719
|
+
app._ir_accordion.selected_index = None
|
|
1720
|
+
|
|
1721
|
+
app._orb_ymin_input = widgets.BoundedFloatText(
|
|
1722
|
+
value=-30.0,
|
|
1723
|
+
min=-500.0,
|
|
1724
|
+
max=200.0,
|
|
1725
|
+
step=1.0,
|
|
1726
|
+
description="Y min:",
|
|
1727
|
+
layout=layout_fn(width="140px"),
|
|
1728
|
+
style={"description_width": "45px"},
|
|
1729
|
+
)
|
|
1730
|
+
app._orb_ymax_input = widgets.BoundedFloatText(
|
|
1731
|
+
value=5.0,
|
|
1732
|
+
min=-500.0,
|
|
1733
|
+
max=500.0,
|
|
1734
|
+
step=1.0,
|
|
1735
|
+
description="Y max:",
|
|
1736
|
+
layout=layout_fn(width="140px"),
|
|
1737
|
+
style={"description_width": "45px"},
|
|
1738
|
+
)
|
|
1739
|
+
app._orb_n_orb_input = widgets.BoundedIntText(
|
|
1740
|
+
value=20,
|
|
1741
|
+
min=4,
|
|
1742
|
+
max=200,
|
|
1743
|
+
step=2,
|
|
1744
|
+
description="Show N:",
|
|
1745
|
+
layout=layout_fn(width="120px"),
|
|
1746
|
+
style={"description_width": "50px"},
|
|
1747
|
+
)
|
|
1748
|
+
orb_controls_row = widgets.HBox(
|
|
1749
|
+
[
|
|
1750
|
+
widgets.HTML(
|
|
1751
|
+
'<span style="font-size:11px;color:#555;font-weight:600">Y range:</span>'
|
|
1752
|
+
),
|
|
1753
|
+
app._orb_ymin_input,
|
|
1754
|
+
app._orb_ymax_input,
|
|
1755
|
+
widgets.HTML(
|
|
1756
|
+
'<span style="font-size:11px;color:#555;font-weight:600;margin-left:8px">'
|
|
1757
|
+
"Levels shown:</span>"
|
|
1758
|
+
),
|
|
1759
|
+
app._orb_n_orb_input,
|
|
1760
|
+
],
|
|
1761
|
+
layout=layout_fn(
|
|
1762
|
+
align_items="center",
|
|
1763
|
+
flex_wrap="wrap",
|
|
1764
|
+
gap="4px",
|
|
1765
|
+
margin="0 0 6px 0",
|
|
1766
|
+
),
|
|
1767
|
+
)
|
|
1768
|
+
app._orb_diagram_html = widgets.Output(layout=layout_fn(width="100%"))
|
|
1769
|
+
orb_export_row = _plot_export_row("orb")
|
|
1770
|
+
orb_diagram_content: list[Any] = [
|
|
1771
|
+
orb_controls_row,
|
|
1772
|
+
orb_export_row,
|
|
1773
|
+
app._orb_diagram_html,
|
|
1774
|
+
]
|
|
1775
|
+
app._orb_diagram_box = widgets.VBox(
|
|
1776
|
+
orb_diagram_content,
|
|
1777
|
+
layout=layout_fn(width="100%"),
|
|
1778
|
+
)
|
|
1779
|
+
# Widen the preset buttons and add a "By index" mode that reveals a
|
|
1780
|
+
# free-entry 0-based MO index input, so any orbital (not just the HOMO/LUMO
|
|
1781
|
+
# neighbourhood) can be rendered as an isosurface.
|
|
1782
|
+
app._orb_toggle = widgets.ToggleButtons(
|
|
1783
|
+
options=["HOMO-1", "HOMO", "LUMO", "LUMO+1", "By index"],
|
|
1784
|
+
value="HOMO",
|
|
1785
|
+
style={"button_width": "92px"},
|
|
1786
|
+
layout=layout_fn(margin="8px 0 4px 0"),
|
|
1787
|
+
)
|
|
1788
|
+
app._orb_index_input = widgets.BoundedIntText(
|
|
1789
|
+
value=0,
|
|
1790
|
+
min=0,
|
|
1791
|
+
max=100000,
|
|
1792
|
+
description="MO #",
|
|
1793
|
+
tooltip="0-based molecular-orbital index (0 = lowest-energy MO)",
|
|
1794
|
+
style={"description_width": "40px"},
|
|
1795
|
+
layout=layout_fn(display="none", width="150px", margin="0 0 4px 0"),
|
|
1796
|
+
)
|
|
1797
|
+
app._orb_iso_output = widgets.Output()
|
|
1798
|
+
app._orb_iso_controls = widgets.VBox(
|
|
1799
|
+
[
|
|
1800
|
+
widgets.HTML(
|
|
1801
|
+
'<span style="font-size:12px;color:#555;font-weight:bold">'
|
|
1802
|
+
"Orbital isosurface:</span>"
|
|
1803
|
+
),
|
|
1804
|
+
app._orb_toggle,
|
|
1805
|
+
app._orb_index_input,
|
|
1806
|
+
app._orb_iso_output,
|
|
1807
|
+
],
|
|
1808
|
+
layout=layout_fn(display="none", margin="8px 0 0 0"),
|
|
1809
|
+
)
|
|
1810
|
+
app._orb_accordion = widgets.Accordion(
|
|
1811
|
+
children=[
|
|
1812
|
+
widgets.VBox(
|
|
1813
|
+
[app._orb_diagram_box],
|
|
1814
|
+
layout=layout_fn(padding="8px"),
|
|
1815
|
+
)
|
|
1816
|
+
],
|
|
1817
|
+
layout=layout_fn(display="none", margin="8px 0"),
|
|
1818
|
+
)
|
|
1819
|
+
app._orb_accordion.set_title(0, "Energy-level Diagram")
|
|
1820
|
+
app._orb_accordion.selected_index = None
|
|
1821
|
+
|
|
1822
|
+
app._iso_generate_btn = widgets.Button(
|
|
1823
|
+
description="Generate Isosurface",
|
|
1824
|
+
button_style="primary",
|
|
1825
|
+
icon="flask",
|
|
1826
|
+
disabled=True,
|
|
1827
|
+
tooltip=(
|
|
1828
|
+
"Generate a 3D orbital isosurface. "
|
|
1829
|
+
"Available after running or loading a Single Point or Geometry Optimization."
|
|
1830
|
+
),
|
|
1831
|
+
layout=layout_fn(width="200px", margin="8px 0 4px 0"),
|
|
1832
|
+
)
|
|
1833
|
+
# Copy the last-generated cube to the top-level
|
|
1834
|
+
# result dir under a friendly name (HOMO.cube / LUMO.cube / etc.).
|
|
1835
|
+
# Disabled until the first isosurface generation populates
|
|
1836
|
+
# ``app._last_cube_path``.
|
|
1837
|
+
app._iso_export_cube_btn = widgets.Button(
|
|
1838
|
+
description="Export cube",
|
|
1839
|
+
icon="download",
|
|
1840
|
+
disabled=True,
|
|
1841
|
+
tooltip=(
|
|
1842
|
+
"Copy the last-generated cube file to the result folder under a "
|
|
1843
|
+
"friendly name (e.g. HOMO.cube) for use in Avogadro / VMD / Multiwfn."
|
|
1844
|
+
),
|
|
1845
|
+
layout=layout_fn(width="160px", margin="8px 0 4px 8px"),
|
|
1846
|
+
)
|
|
1847
|
+
app._iso_export_status = widgets.HTML(
|
|
1848
|
+
value="", layout=layout_fn(margin="0 0 0 8px")
|
|
1849
|
+
)
|
|
1850
|
+
# Inline "calculating" spinner shown while a cube is being computed.
|
|
1851
|
+
# Hidden until on_iso_generate reveals it; hidden again on completion.
|
|
1852
|
+
app._iso_spinner = widgets.HTML(
|
|
1853
|
+
value='<span class="quantui-spinner"></span>',
|
|
1854
|
+
layout=layout_fn(display="none", margin="0 0 0 4px"),
|
|
1855
|
+
)
|
|
1856
|
+
iso_body = widgets.VBox(
|
|
1857
|
+
[
|
|
1858
|
+
widgets.HTML(
|
|
1859
|
+
'<p style="color:#555;font-size:12px;margin:0 0 8px">'
|
|
1860
|
+
"Visualise a molecular orbital as a 3D isosurface (Linux / WSL only — "
|
|
1861
|
+
"requires PySCF and RDKit). Run or load a Single Point or Geometry "
|
|
1862
|
+
"Optimization first, then click <b>Generate</b>.</p>"
|
|
1863
|
+
),
|
|
1864
|
+
app._orb_iso_controls,
|
|
1865
|
+
widgets.HBox(
|
|
1866
|
+
[
|
|
1867
|
+
app._iso_generate_btn,
|
|
1868
|
+
app._iso_spinner,
|
|
1869
|
+
app._iso_export_cube_btn,
|
|
1870
|
+
app._iso_export_status,
|
|
1871
|
+
],
|
|
1872
|
+
layout=layout_fn(align_items="center", gap="6px"),
|
|
1873
|
+
),
|
|
1874
|
+
],
|
|
1875
|
+
layout=layout_fn(padding="8px"),
|
|
1876
|
+
)
|
|
1877
|
+
app._iso_accordion = widgets.Accordion(
|
|
1878
|
+
children=[iso_body],
|
|
1879
|
+
layout=layout_fn(display="none", margin="8px 0"),
|
|
1880
|
+
)
|
|
1881
|
+
app._iso_accordion.set_title(0, "Orbital Isosurface")
|
|
1882
|
+
app._iso_accordion.selected_index = None
|
|
1883
|
+
|
|
1884
|
+
app._uv_mode_toggle = widgets.ToggleButtons(
|
|
1885
|
+
options=["Stick", "Broadened"],
|
|
1886
|
+
value="Stick",
|
|
1887
|
+
style={"button_width": "80px"},
|
|
1888
|
+
layout=layout_fn(margin="0 8px 0 0"),
|
|
1889
|
+
)
|
|
1890
|
+
app._uv_fwhm_slider = widgets.FloatSlider(
|
|
1891
|
+
value=20.0,
|
|
1892
|
+
min=5.0,
|
|
1893
|
+
max=100.0,
|
|
1894
|
+
step=5.0,
|
|
1895
|
+
description="Line width (nm):",
|
|
1896
|
+
readout_format=".0f",
|
|
1897
|
+
style={"description_width": "110px"},
|
|
1898
|
+
layout=layout_fn(width="290px", display="none"),
|
|
1899
|
+
# Fire only on slider release — avoids a re-render storm during drag
|
|
1900
|
+
# that, combined with the full HTML output swap, causes the page
|
|
1901
|
+
# to scroll back to the top mid-drag.
|
|
1902
|
+
continuous_update=False,
|
|
1903
|
+
)
|
|
1904
|
+
# min_height matches the Plotly UV-Vis figure height (320px) so the
|
|
1905
|
+
# Output container does not briefly collapse to 0px during the atomic
|
|
1906
|
+
# outputs swap on mode/slider changes — same fix as the IR Output above.
|
|
1907
|
+
app._tddft_fig = widgets.Output(
|
|
1908
|
+
layout=layout_fn(width="100%", min_height="320px"),
|
|
1909
|
+
)
|
|
1910
|
+
uv_export_row = _plot_export_row("uv")
|
|
1911
|
+
uv_controls = widgets.HBox(
|
|
1912
|
+
[app._uv_mode_toggle, app._uv_fwhm_slider],
|
|
1913
|
+
layout=layout_fn(align_items="center", margin="0 0 6px 0"),
|
|
1914
|
+
)
|
|
1915
|
+
app._tddft_accordion = widgets.Accordion(
|
|
1916
|
+
children=[
|
|
1917
|
+
widgets.VBox(
|
|
1918
|
+
[uv_controls, uv_export_row, app._tddft_fig],
|
|
1919
|
+
layout=layout_fn(padding="8px"),
|
|
1920
|
+
)
|
|
1921
|
+
],
|
|
1922
|
+
layout=layout_fn(display="none", margin="8px 0"),
|
|
1923
|
+
)
|
|
1924
|
+
app._tddft_accordion.set_title(0, "UV-Vis Absorption Spectrum")
|
|
1925
|
+
app._tddft_accordion.selected_index = None
|
|
1926
|
+
|
|
1927
|
+
app._nmr_output = widgets.HTML(value="", layout=layout_fn(width="100%"))
|
|
1928
|
+
app._nmr_accordion = widgets.Accordion(
|
|
1929
|
+
children=[
|
|
1930
|
+
widgets.VBox(
|
|
1931
|
+
[app._nmr_output],
|
|
1932
|
+
layout=layout_fn(padding="8px"),
|
|
1933
|
+
)
|
|
1934
|
+
],
|
|
1935
|
+
layout=layout_fn(display="none", margin="8px 0"),
|
|
1936
|
+
)
|
|
1937
|
+
app._nmr_accordion.set_title(0, "NMR Chemical Shifts")
|
|
1938
|
+
app._nmr_accordion.selected_index = None
|
|
1939
|
+
|
|
1940
|
+
app._result_dir_label = widgets.HTML(
|
|
1941
|
+
value="",
|
|
1942
|
+
layout=layout_fn(display="none", margin="4px 0 0 0"),
|
|
1943
|
+
)
|
|
1944
|
+
|
|
1945
|
+
app._result_log_output = widgets.Output()
|
|
1946
|
+
app._result_log_accordion = widgets.Accordion(
|
|
1947
|
+
children=[app._result_log_output],
|
|
1948
|
+
layout=layout_fn(display="none", margin="8px 0 0 0"),
|
|
1949
|
+
)
|
|
1950
|
+
app._result_log_accordion.set_title(0, "Full output log (pyscf.log)")
|
|
1951
|
+
app._result_log_accordion.selected_index = None
|
|
1952
|
+
|
|
1953
|
+
app._go_results_btn = widgets.Button(
|
|
1954
|
+
description="→ View Results",
|
|
1955
|
+
button_style="success",
|
|
1956
|
+
layout=layout_fn(width="130px"),
|
|
1957
|
+
)
|
|
1958
|
+
app._go_analysis_btn = widgets.Button(
|
|
1959
|
+
description="→ View Analysis",
|
|
1960
|
+
button_style="info",
|
|
1961
|
+
layout=layout_fn(width="140px"),
|
|
1962
|
+
)
|
|
1963
|
+
app._completion_mol_lbl = widgets.HTML(value="")
|
|
1964
|
+
app._completion_banner = widgets.HBox(
|
|
1965
|
+
[
|
|
1966
|
+
widgets.HTML(
|
|
1967
|
+
'<span style="color:#22c55e;font-weight:600;font-size:13px">'
|
|
1968
|
+
"✓ Calculation complete — </span>"
|
|
1969
|
+
),
|
|
1970
|
+
app._completion_mol_lbl,
|
|
1971
|
+
app._go_results_btn,
|
|
1972
|
+
app._go_analysis_btn,
|
|
1973
|
+
],
|
|
1974
|
+
layout=layout_fn(
|
|
1975
|
+
display="none",
|
|
1976
|
+
align_items="center",
|
|
1977
|
+
gap="8px",
|
|
1978
|
+
padding="10px 12px",
|
|
1979
|
+
border="1px solid #bbf7d0",
|
|
1980
|
+
border_radius="6px",
|
|
1981
|
+
background_color="#f0fdf4",
|
|
1982
|
+
margin="8px 0",
|
|
1983
|
+
),
|
|
1984
|
+
)
|
|
1985
|
+
|
|
1986
|
+
app._to_analysis_btn = widgets.Button(
|
|
1987
|
+
description="→ View Analysis",
|
|
1988
|
+
button_style="",
|
|
1989
|
+
icon="bar-chart",
|
|
1990
|
+
layout=layout_fn(display="none", width="160px", margin="8px 0 0 0"),
|
|
1991
|
+
)
|
|
1992
|
+
app._viz_label = widgets.HTML(
|
|
1993
|
+
value="",
|
|
1994
|
+
layout=layout_fn(display="none"),
|
|
1995
|
+
)
|
|
1996
|
+
app.results_tab_panel = widgets.VBox(
|
|
1997
|
+
[
|
|
1998
|
+
widgets.HTML('<h3 style="margin:14px 0 6px">Results</h3>'),
|
|
1999
|
+
app.result_output,
|
|
2000
|
+
app._viz_label,
|
|
2001
|
+
app.result_viz_output,
|
|
2002
|
+
app._result_dir_label,
|
|
2003
|
+
app._to_analysis_btn,
|
|
2004
|
+
],
|
|
2005
|
+
layout=layout_fn(padding="8px 0"),
|
|
2006
|
+
)
|
|
2007
|
+
app.results_panel = app.results_tab_panel
|
|
2008
|
+
|
|
2009
|
+
app._analysis_mol_output = widgets.Output()
|
|
2010
|
+
app._analysis_mol_output.add_class("quantui-viewer-frame")
|
|
2011
|
+
|
|
2012
|
+
# Analysis-tab backend toggle — mirrors the Calculate-tab `viz_backend_toggle`.
|
|
2013
|
+
# Created only when both backends are available (matches Calculate-tab
|
|
2014
|
+
# convention). Synchronized with the Calculate-tab toggle via
|
|
2015
|
+
# `_set_viz_preference` + `_viz_sync_in_progress` flag in app.py.
|
|
2016
|
+
if app.viz_backend_toggle is not None:
|
|
2017
|
+
app.viz_backend_toggle_ana = widgets.ToggleButtons(
|
|
2018
|
+
options=[("PlotlyMol", "plotlymol"), ("py3Dmol", "py3dmol")],
|
|
2019
|
+
value=app._viz_backend,
|
|
2020
|
+
tooltips=["Plotly-based interactive viewer", "WebGL viewer (py3Dmol)"],
|
|
2021
|
+
style={"button_width": "90px"},
|
|
2022
|
+
layout=layout_fn(margin="2px 0 4px 0"),
|
|
2023
|
+
)
|
|
2024
|
+
# Small "Rendering with: X" label — updated by _update_analysis_backend_label
|
|
2025
|
+
# after each render so the user can see what's actually rendering even
|
|
2026
|
+
# when preference is "auto" (per-task routing may select different
|
|
2027
|
+
# backends than the toggle suggests).
|
|
2028
|
+
app.viz_backend_label_ana = widgets.HTML(
|
|
2029
|
+
value=(
|
|
2030
|
+
'<span style="font-size:11px;color:#94a3b8;font-style:italic">'
|
|
2031
|
+
"Rendering with: —</span>"
|
|
2032
|
+
),
|
|
2033
|
+
layout=layout_fn(margin="0 0 8px 0"),
|
|
2034
|
+
)
|
|
2035
|
+
ana_backend_row = widgets.VBox(
|
|
2036
|
+
[
|
|
2037
|
+
widgets.HBox(
|
|
2038
|
+
[
|
|
2039
|
+
widgets.HTML(
|
|
2040
|
+
'<span style="font-size:11px;color:#94a3b8;'
|
|
2041
|
+
'margin-right:6px;align-self:center">Backend:</span>'
|
|
2042
|
+
),
|
|
2043
|
+
app.viz_backend_toggle_ana,
|
|
2044
|
+
],
|
|
2045
|
+
layout=layout_fn(align_items="center"),
|
|
2046
|
+
),
|
|
2047
|
+
app.viz_backend_label_ana,
|
|
2048
|
+
],
|
|
2049
|
+
)
|
|
2050
|
+
else:
|
|
2051
|
+
app.viz_backend_toggle_ana = None # type: ignore[assignment]
|
|
2052
|
+
app.viz_backend_label_ana = None # type: ignore[assignment]
|
|
2053
|
+
ana_backend_row = None
|
|
2054
|
+
|
|
2055
|
+
app._analysis_context_lbl = widgets.HTML(
|
|
2056
|
+
value=(
|
|
2057
|
+
'<p style="color:#555;font-size:13px;margin:4px 0 12px">'
|
|
2058
|
+
"No result loaded yet. Run a calculation or load one from History.</p>"
|
|
2059
|
+
)
|
|
2060
|
+
)
|
|
2061
|
+
app._analysis_empty_html = widgets.HTML(
|
|
2062
|
+
value=(
|
|
2063
|
+
'<p style="color:#888;font-size:13px;font-style:italic;margin:8px 0">'
|
|
2064
|
+
"No interactive analysis is available for this calculation type.<br>"
|
|
2065
|
+
"Run a Single Point, Geo Opt, or Frequency calculation to see "
|
|
2066
|
+
"energy-level diagrams, trajectory animations, and spectra here.</p>"
|
|
2067
|
+
),
|
|
2068
|
+
layout=layout_fn(display="none"),
|
|
2069
|
+
)
|
|
2070
|
+
app._ana_unavail_html = widgets.HTML(value="", layout=layout_fn(display="none"))
|
|
2071
|
+
app._build_ana_switcher()
|
|
2072
|
+
|
|
2073
|
+
ana_children = [
|
|
2074
|
+
app._analysis_context_lbl,
|
|
2075
|
+
app._analysis_mol_output,
|
|
2076
|
+
]
|
|
2077
|
+
if ana_backend_row is not None:
|
|
2078
|
+
ana_children.append(ana_backend_row)
|
|
2079
|
+
ana_children.extend(
|
|
2080
|
+
[
|
|
2081
|
+
app._analysis_empty_html,
|
|
2082
|
+
app._ana_unavail_html,
|
|
2083
|
+
app._orb_accordion,
|
|
2084
|
+
app._pes_scan_accordion,
|
|
2085
|
+
app.traj_accordion,
|
|
2086
|
+
app.vib_accordion,
|
|
2087
|
+
app._ir_accordion,
|
|
2088
|
+
app._iso_accordion,
|
|
2089
|
+
app._tddft_accordion,
|
|
2090
|
+
app._nmr_accordion,
|
|
2091
|
+
]
|
|
2092
|
+
)
|
|
2093
|
+
app.analysis_tab_panel = widgets.VBox(
|
|
2094
|
+
ana_children,
|
|
2095
|
+
layout=layout_fn(padding="8px 0"),
|
|
2096
|
+
)
|
|
2097
|
+
app.post_calc_panel = app.analysis_tab_panel
|
|
2098
|
+
|
|
2099
|
+
|
|
2100
|
+
def build_compare_section(app: Any, *, layout_fn: Any, rdkit_available: bool) -> None:
|
|
2101
|
+
"""Build compare tab widgets and export accordion."""
|
|
2102
|
+
app.compare_select = widgets.SelectMultiple(
|
|
2103
|
+
options=[("(no saved results)", "")],
|
|
2104
|
+
rows=8,
|
|
2105
|
+
description="",
|
|
2106
|
+
layout=layout_fn(width="100%"),
|
|
2107
|
+
)
|
|
2108
|
+
app.compare_refresh_btn = widgets.Button(
|
|
2109
|
+
description="Refresh",
|
|
2110
|
+
button_style="",
|
|
2111
|
+
icon="refresh",
|
|
2112
|
+
layout=layout_fn(width="100px"),
|
|
2113
|
+
)
|
|
2114
|
+
app.compare_btn = widgets.Button(
|
|
2115
|
+
description="Compare selected",
|
|
2116
|
+
button_style="primary",
|
|
2117
|
+
icon="bar-chart",
|
|
2118
|
+
disabled=True,
|
|
2119
|
+
layout=layout_fn(width="180px"),
|
|
2120
|
+
)
|
|
2121
|
+
app.compare_clear_btn = widgets.Button(
|
|
2122
|
+
description="Clear",
|
|
2123
|
+
button_style="warning",
|
|
2124
|
+
icon="times",
|
|
2125
|
+
layout=layout_fn(width="90px"),
|
|
2126
|
+
)
|
|
2127
|
+
app.compare_output = widgets.Output()
|
|
2128
|
+
|
|
2129
|
+
app.compare_panel = widgets.VBox(
|
|
2130
|
+
[
|
|
2131
|
+
widgets.HTML(
|
|
2132
|
+
'<h3 style="margin:14px 0 6px">Compare Calculations</h3>'
|
|
2133
|
+
'<p style="color:#555;font-size:13px;margin:0 0 8px">'
|
|
2134
|
+
"Select two or more saved calculations to compare side-by-side. "
|
|
2135
|
+
"Hold Ctrl (or ⌘) to select multiple entries.</p>"
|
|
2136
|
+
),
|
|
2137
|
+
widgets.HBox([app.compare_refresh_btn]),
|
|
2138
|
+
app.compare_select,
|
|
2139
|
+
widgets.HBox(
|
|
2140
|
+
[app.compare_btn, app.compare_clear_btn],
|
|
2141
|
+
layout=layout_fn(gap="8px", margin="6px 0"),
|
|
2142
|
+
),
|
|
2143
|
+
app.compare_output,
|
|
2144
|
+
],
|
|
2145
|
+
layout=layout_fn(padding="8px 0"),
|
|
2146
|
+
)
|
|
2147
|
+
|
|
2148
|
+
rdkit_note = (
|
|
2149
|
+
""
|
|
2150
|
+
if rdkit_available
|
|
2151
|
+
else '<p style="color:#888;font-size:12px;margin:4px 0 0">MOL/PDB export requires RDKit '
|
|
2152
|
+
"(<code>conda install -c conda-forge rdkit</code>).</p>"
|
|
2153
|
+
)
|
|
2154
|
+
export_content = widgets.VBox(
|
|
2155
|
+
[
|
|
2156
|
+
widgets.HTML(
|
|
2157
|
+
'<p style="color:#555;font-size:13px;margin:0 0 8px">'
|
|
2158
|
+
"Download a self-contained PySCF script you can study or run outside the notebook.</p>"
|
|
2159
|
+
),
|
|
2160
|
+
widgets.HBox([app.export_btn, app.export_status]),
|
|
2161
|
+
widgets.HTML('<hr style="margin:10px 0 8px">'),
|
|
2162
|
+
widgets.HTML(
|
|
2163
|
+
'<p style="color:#555;font-size:13px;margin:0 0 6px">'
|
|
2164
|
+
"Download the molecular structure in a standard chemistry file format.</p>"
|
|
2165
|
+
+ rdkit_note
|
|
2166
|
+
),
|
|
2167
|
+
widgets.HBox(
|
|
2168
|
+
[app.export_xyz_btn, app.export_mol_btn, app.export_pdb_btn],
|
|
2169
|
+
layout=layout_fn(flex_wrap="wrap", gap="6px"),
|
|
2170
|
+
),
|
|
2171
|
+
app.struct_export_status,
|
|
2172
|
+
widgets.HTML('<hr style="margin:10px 0 8px">'),
|
|
2173
|
+
widgets.HTML(
|
|
2174
|
+
'<p style="color:#555;font-size:13px;margin:0 0 6px">'
|
|
2175
|
+
"Bundle every file in this result folder into a single zip "
|
|
2176
|
+
"for sharing.</p>"
|
|
2177
|
+
),
|
|
2178
|
+
widgets.HBox(
|
|
2179
|
+
[app._export_bundle_btn, app._export_bundle_status],
|
|
2180
|
+
layout=layout_fn(align_items="center", gap="6px"),
|
|
2181
|
+
),
|
|
2182
|
+
]
|
|
2183
|
+
)
|
|
2184
|
+
app.advanced_accordion = widgets.Accordion(children=[export_content])
|
|
2185
|
+
app.advanced_accordion.set_title(0, "Export")
|
|
2186
|
+
app.advanced_accordion.selected_index = None
|
|
2187
|
+
|
|
2188
|
+
# Deferred to a background startup task (loads every saved result) — see
|
|
2189
|
+
# app._start_deferred_startup_tasks. Placeholder until then.
|
|
2190
|
+
app.compare_select.options = [("⏳ loading saved results…", "")]
|
|
2191
|
+
|
|
2192
|
+
|
|
2193
|
+
def build_output_tab(app: Any, *, layout_fn: Any) -> None:
|
|
2194
|
+
"""Build the Output tab panel widgets."""
|
|
2195
|
+
app._log_output_html = widgets.HTML(
|
|
2196
|
+
'<span style="color:#94a3b8;font-size:13px">'
|
|
2197
|
+
"No log yet — run a calculation first, or use "
|
|
2198
|
+
"<b>View log</b> in the History tab.</span>"
|
|
2199
|
+
)
|
|
2200
|
+
app._log_source_lbl = widgets.HTML()
|
|
2201
|
+
app._log_clear_btn = widgets.Button(
|
|
2202
|
+
description="Clear",
|
|
2203
|
+
button_style="",
|
|
2204
|
+
icon="times",
|
|
2205
|
+
layout=layout_fn(width="80px"),
|
|
2206
|
+
)
|
|
2207
|
+
app._clear_log_cache_btn = widgets.Button(
|
|
2208
|
+
description="Clear Log Cache",
|
|
2209
|
+
button_style="",
|
|
2210
|
+
icon="trash",
|
|
2211
|
+
tooltip=(
|
|
2212
|
+
"Delete the session event log (event_log.jsonl). "
|
|
2213
|
+
"Calculation performance data is preserved."
|
|
2214
|
+
),
|
|
2215
|
+
layout=layout_fn(width="160px"),
|
|
2216
|
+
)
|
|
2217
|
+
app._clear_log_cache_confirm_btn = widgets.Button(
|
|
2218
|
+
description="Confirm clear?",
|
|
2219
|
+
button_style="danger",
|
|
2220
|
+
layout=layout_fn(width="140px", display="none"),
|
|
2221
|
+
)
|
|
2222
|
+
# The Log tab moved to be an Accordion inside the History tab. The
|
|
2223
|
+
# explanatory text no longer needs to say "Use View log in the
|
|
2224
|
+
# History tab" since the user IS in the History tab now.
|
|
2225
|
+
app.log_tab_panel = widgets.VBox(
|
|
2226
|
+
[
|
|
2227
|
+
widgets.HTML(
|
|
2228
|
+
'<p style="color:#555;font-size:13px;margin:4px 0 8px">'
|
|
2229
|
+
"Raw PySCF output for the most recent calculation or the "
|
|
2230
|
+
"currently-selected history result. "
|
|
2231
|
+
"Energy-level diagrams, trajectories, and spectra are in the "
|
|
2232
|
+
"<b>Analysis</b> tab.</p>"
|
|
2233
|
+
),
|
|
2234
|
+
widgets.HBox(
|
|
2235
|
+
[app._log_clear_btn],
|
|
2236
|
+
layout=layout_fn(margin="0 0 8px"),
|
|
2237
|
+
),
|
|
2238
|
+
app._log_source_lbl,
|
|
2239
|
+
app._log_output_html,
|
|
2240
|
+
app._result_log_accordion,
|
|
2241
|
+
widgets.HTML(
|
|
2242
|
+
'<hr style="border:none;border-top:1px solid #e2e8f0;margin:16px 0 10px"/>'
|
|
2243
|
+
'<p style="color:#94a3b8;font-size:12px;margin:0 0 6px">'
|
|
2244
|
+
"Session event log — records molecule loads, calculations, "
|
|
2245
|
+
"and issue reports across this session.</p>"
|
|
2246
|
+
),
|
|
2247
|
+
widgets.HBox(
|
|
2248
|
+
[app._clear_log_cache_btn, app._clear_log_cache_confirm_btn],
|
|
2249
|
+
layout=layout_fn(align_items="center", gap="8px"),
|
|
2250
|
+
),
|
|
2251
|
+
],
|
|
2252
|
+
layout=layout_fn(padding="8px 0"),
|
|
2253
|
+
)
|
|
2254
|
+
|
|
2255
|
+
# Wrap the log panel in an Accordion + append to the
|
|
2256
|
+
# History tab. ``history_panel`` was built in
|
|
2257
|
+
# ``build_history_section`` earlier in the app-init sequence
|
|
2258
|
+
# (see app.py: _build_history_section runs BEFORE _build_output_tab).
|
|
2259
|
+
app._history_log_accordion = widgets.Accordion(
|
|
2260
|
+
children=[app.log_tab_panel],
|
|
2261
|
+
selected_index=None,
|
|
2262
|
+
)
|
|
2263
|
+
app._history_log_accordion.set_title(0, "PySCF output log")
|
|
2264
|
+
app.history_panel.children = (
|
|
2265
|
+
*app.history_panel.children,
|
|
2266
|
+
app._history_log_accordion,
|
|
2267
|
+
)
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
def build_files_tab(app: Any, *, layout_fn: Any) -> None:
|
|
2271
|
+
"""Build the read-only Files tab widgets."""
|
|
2272
|
+
app._files_root_dd = widgets.Dropdown(
|
|
2273
|
+
options=[("(loading)", "")],
|
|
2274
|
+
value="",
|
|
2275
|
+
description="Root:",
|
|
2276
|
+
style={"description_width": "40px"},
|
|
2277
|
+
layout=layout_fn(width="520px"),
|
|
2278
|
+
)
|
|
2279
|
+
app._files_path_html = widgets.HTML(
|
|
2280
|
+
value=(
|
|
2281
|
+
'<span style="font-size:12px;color:#64748b">'
|
|
2282
|
+
"Current folder: (not set)</span>"
|
|
2283
|
+
)
|
|
2284
|
+
)
|
|
2285
|
+
app._files_entries = widgets.Select(
|
|
2286
|
+
options=[("(no files)", "")],
|
|
2287
|
+
rows=12,
|
|
2288
|
+
description="",
|
|
2289
|
+
layout=layout_fn(width="100%"),
|
|
2290
|
+
)
|
|
2291
|
+
app._files_up_btn = widgets.Button(
|
|
2292
|
+
description="Up",
|
|
2293
|
+
icon="arrow-up",
|
|
2294
|
+
layout=layout_fn(width="80px"),
|
|
2295
|
+
tooltip="Go to parent folder",
|
|
2296
|
+
)
|
|
2297
|
+
app._files_open_btn = widgets.Button(
|
|
2298
|
+
description="Open",
|
|
2299
|
+
button_style="primary",
|
|
2300
|
+
icon="folder-open",
|
|
2301
|
+
layout=layout_fn(width="100px"),
|
|
2302
|
+
tooltip="Open selected folder or preview selected file",
|
|
2303
|
+
)
|
|
2304
|
+
app._files_refresh_btn = widgets.Button(
|
|
2305
|
+
description="Refresh",
|
|
2306
|
+
icon="refresh",
|
|
2307
|
+
layout=layout_fn(width="100px"),
|
|
2308
|
+
tooltip="Refresh roots, folder contents, and preview",
|
|
2309
|
+
)
|
|
2310
|
+
app._files_status_html = widgets.HTML(
|
|
2311
|
+
value=(
|
|
2312
|
+
'<span style="font-size:12px;color:#94a3b8">'
|
|
2313
|
+
"Select a file to preview it; use Open to enter a folder.</span>"
|
|
2314
|
+
)
|
|
2315
|
+
)
|
|
2316
|
+
app._files_preview_output = widgets.Output(
|
|
2317
|
+
layout=layout_fn(
|
|
2318
|
+
border="1px solid #cbd5e1",
|
|
2319
|
+
min_height="220px",
|
|
2320
|
+
max_height="420px",
|
|
2321
|
+
overflow="auto",
|
|
2322
|
+
padding="6px",
|
|
2323
|
+
)
|
|
2324
|
+
)
|
|
2325
|
+
|
|
2326
|
+
app.files_tab_panel = widgets.VBox(
|
|
2327
|
+
[
|
|
2328
|
+
widgets.HTML(
|
|
2329
|
+
'<p style="color:#555;font-size:13px;margin:4px 0 8px">'
|
|
2330
|
+
"Read-only file browser for result artifacts and logs. "
|
|
2331
|
+
"Browsing is limited to approved roots.</p>"
|
|
2332
|
+
),
|
|
2333
|
+
app._files_root_dd,
|
|
2334
|
+
app._files_path_html,
|
|
2335
|
+
widgets.HBox(
|
|
2336
|
+
[app._files_up_btn, app._files_open_btn, app._files_refresh_btn],
|
|
2337
|
+
layout=layout_fn(gap="8px", margin="6px 0"),
|
|
2338
|
+
),
|
|
2339
|
+
app._files_entries,
|
|
2340
|
+
app._files_status_html,
|
|
2341
|
+
app._files_preview_output,
|
|
2342
|
+
],
|
|
2343
|
+
layout=layout_fn(padding="8px 0"),
|
|
2344
|
+
)
|
|
2345
|
+
|
|
2346
|
+
|
|
2347
|
+
def build_help_section(app: Any, *, layout_fn: Any) -> None:
|
|
2348
|
+
"""Build the floating help panel and top-bar help/exit buttons."""
|
|
2349
|
+
help_keys = list(HELP_TOPICS.keys())
|
|
2350
|
+
help_labels = [HELP_TOPICS[k]["title"] for k in help_keys]
|
|
2351
|
+
app.help_topic_dd = widgets.Dropdown(
|
|
2352
|
+
options=list(zip(help_labels, help_keys)),
|
|
2353
|
+
description="Topic:",
|
|
2354
|
+
style={"description_width": "60px"},
|
|
2355
|
+
layout=layout_fn(width="460px"),
|
|
2356
|
+
)
|
|
2357
|
+
app.help_content_html = widgets.HTML()
|
|
2358
|
+
app._render_help_topic()
|
|
2359
|
+
|
|
2360
|
+
# The single-character "?" was visually noisy and hard to recognise
|
|
2361
|
+
# as the global help toggle.
|
|
2362
|
+
# Field-level "?" buttons (method_help_btn / basis_help_btn earlier
|
|
2363
|
+
# in this file) keep the symbol — for inline-with-input help it's
|
|
2364
|
+
# universally understood.
|
|
2365
|
+
app._help_btn = widgets.Button(
|
|
2366
|
+
description="Help",
|
|
2367
|
+
button_style="",
|
|
2368
|
+
icon="question-circle",
|
|
2369
|
+
tooltip="Help topics",
|
|
2370
|
+
layout=layout_fn(width="80px", margin="0 0 0 8px"),
|
|
2371
|
+
)
|
|
2372
|
+
|
|
2373
|
+
app._exit_btn = widgets.Button(
|
|
2374
|
+
description="Exit",
|
|
2375
|
+
button_style="danger",
|
|
2376
|
+
tooltip="Shut down the QuantUI server and end this session",
|
|
2377
|
+
layout=layout_fn(width="64px", margin="0 0 0 8px"),
|
|
2378
|
+
)
|
|
2379
|
+
# Two-stage exit confirm: the first Exit click reveals this warning + the
|
|
2380
|
+
# Cancel button and re-labels Exit to "Confirm shutdown"; only a second
|
|
2381
|
+
# click shuts down. Prevents a single stray click from tearing down a
|
|
2382
|
+
# cluster/Voilà session — on NCShare the server process IS the interactive
|
|
2383
|
+
# job. Both start hidden; ``app_runflow._arm_exit`` shows them.
|
|
2384
|
+
app._exit_warn_html = widgets.HTML(value="", layout=layout_fn(display="none"))
|
|
2385
|
+
app._exit_cancel_btn = widgets.Button(
|
|
2386
|
+
description="Cancel",
|
|
2387
|
+
button_style="",
|
|
2388
|
+
tooltip="Keep the session running",
|
|
2389
|
+
layout=layout_fn(width="72px", margin="0 0 0 8px", display="none"),
|
|
2390
|
+
)
|
|
2391
|
+
app._exit_output = widgets.Output(layout=layout_fn(height="0px", overflow="hidden"))
|
|
2392
|
+
|
|
2393
|
+
app.help_tab_panel = widgets.VBox(
|
|
2394
|
+
[
|
|
2395
|
+
widgets.HTML(
|
|
2396
|
+
'<p style="color:#555;font-size:13px;margin:4px 0 12px">'
|
|
2397
|
+
"Browse help topics below. Click <b>?</b> next to the Method or Basis Set "
|
|
2398
|
+
"dropdown in the Calculate tab to jump directly to a relevant topic.</p>"
|
|
2399
|
+
),
|
|
2400
|
+
app.help_topic_dd,
|
|
2401
|
+
app.help_content_html,
|
|
2402
|
+
],
|
|
2403
|
+
layout=layout_fn(
|
|
2404
|
+
display="none",
|
|
2405
|
+
padding="8px 0",
|
|
2406
|
+
border="1px solid #e2e8f0",
|
|
2407
|
+
border_radius="6px",
|
|
2408
|
+
padding_left="12px",
|
|
2409
|
+
margin="0 0 8px",
|
|
2410
|
+
),
|
|
2411
|
+
)
|
|
2412
|
+
|
|
2413
|
+
|
|
2414
|
+
def build_issue_widgets(app: Any, *, layout_fn: Any) -> None:
|
|
2415
|
+
"""Build issue-report widgets shown from the top toolbar."""
|
|
2416
|
+
app._issue_btn = widgets.Button(
|
|
2417
|
+
description="Report Issue",
|
|
2418
|
+
button_style="warning",
|
|
2419
|
+
icon="flag",
|
|
2420
|
+
tooltip="Report a bug or unexpected behaviour observed in this session",
|
|
2421
|
+
layout=layout_fn(width="140px", margin="0 0 0 8px"),
|
|
2422
|
+
)
|
|
2423
|
+
app._issue_textarea = widgets.Textarea(
|
|
2424
|
+
placeholder=(
|
|
2425
|
+
"Describe what you observed — what you did, what you expected, "
|
|
2426
|
+
"and what actually happened."
|
|
2427
|
+
),
|
|
2428
|
+
layout=layout_fn(width="100%", height="90px"),
|
|
2429
|
+
)
|
|
2430
|
+
app._issue_submit_btn = widgets.Button(
|
|
2431
|
+
description="Submit",
|
|
2432
|
+
button_style="success",
|
|
2433
|
+
layout=layout_fn(width="90px"),
|
|
2434
|
+
)
|
|
2435
|
+
app._issue_cancel_btn = widgets.Button(
|
|
2436
|
+
description="Cancel",
|
|
2437
|
+
button_style="",
|
|
2438
|
+
layout=layout_fn(width="80px"),
|
|
2439
|
+
)
|
|
2440
|
+
app._issue_status_html = widgets.HTML()
|
|
2441
|
+
app._issue_overlay = widgets.VBox(
|
|
2442
|
+
[
|
|
2443
|
+
widgets.HTML(
|
|
2444
|
+
'<p style="font-size:13px;font-weight:600;margin:0 0 6px;color:#92400e">'
|
|
2445
|
+
"⚐ Report Issue</p>"
|
|
2446
|
+
'<p style="font-size:12px;color:#78350f;margin:0 0 8px">'
|
|
2447
|
+
"Your report (and a snapshot of the current session state) will be "
|
|
2448
|
+
"saved to <code>issues.db</code> and the session event log.</p>"
|
|
2449
|
+
),
|
|
2450
|
+
app._issue_textarea,
|
|
2451
|
+
widgets.HBox(
|
|
2452
|
+
[app._issue_submit_btn, app._issue_cancel_btn],
|
|
2453
|
+
layout=layout_fn(margin="6px 0 0", gap="8px"),
|
|
2454
|
+
),
|
|
2455
|
+
app._issue_status_html,
|
|
2456
|
+
],
|
|
2457
|
+
layout=layout_fn(
|
|
2458
|
+
display="none",
|
|
2459
|
+
border="1px solid #f59e0b",
|
|
2460
|
+
border_radius="6px",
|
|
2461
|
+
padding="12px 14px",
|
|
2462
|
+
margin="0 0 6px",
|
|
2463
|
+
background_color="#fffbeb",
|
|
2464
|
+
),
|
|
2465
|
+
)
|