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.
Files changed (62) hide show
  1. quantui/__init__.py +311 -0
  2. quantui/analytics.py +609 -0
  3. quantui/app.py +5650 -0
  4. quantui/app_analysis.py +662 -0
  5. quantui/app_builders.py +2465 -0
  6. quantui/app_exports.py +194 -0
  7. quantui/app_formatters.py +493 -0
  8. quantui/app_history.py +624 -0
  9. quantui/app_runflow.py +1544 -0
  10. quantui/app_visualization.py +2620 -0
  11. quantui/ase_bridge.py +236 -0
  12. quantui/benchmarks.py +1543 -0
  13. quantui/c_stderr.py +124 -0
  14. quantui/cactus.py +88 -0
  15. quantui/calc_log.py +1116 -0
  16. quantui/calculator.py +204 -0
  17. quantui/cancellation.py +88 -0
  18. quantui/cli.py +288 -0
  19. quantui/comparison.py +306 -0
  20. quantui/config.py +725 -0
  21. quantui/data/js/3Dmol-min.js +2 -0
  22. quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
  23. quantui/data/library/library.sqlite +0 -0
  24. quantui/data/manifests/bulk_qm9.json +1 -0
  25. quantui/data/manifests/curated.json +15482 -0
  26. quantui/data/manifests/presets.json +816 -0
  27. quantui/descriptor_cards.py +186 -0
  28. quantui/freq_calc.py +712 -0
  29. quantui/freq_ir_workers.py +229 -0
  30. quantui/gpu_offload.py +278 -0
  31. quantui/help_content.py +474 -0
  32. quantui/ir_plot.py +130 -0
  33. quantui/issue_tracker.py +170 -0
  34. quantui/live_log.py +387 -0
  35. quantui/log_utils.py +492 -0
  36. quantui/molecule.py +577 -0
  37. quantui/molecule_library.py +433 -0
  38. quantui/nmr_calc.py +437 -0
  39. quantui/optimizer.py +670 -0
  40. quantui/orbital_visualization.py +1102 -0
  41. quantui/pes_scan.py +420 -0
  42. quantui/preopt.py +355 -0
  43. quantui/progress.py +111 -0
  44. quantui/pubchem.py +1157 -0
  45. quantui/reorganization_energy.py +435 -0
  46. quantui/results_storage.py +902 -0
  47. quantui/security.py +14 -0
  48. quantui/session_calc.py +622 -0
  49. quantui/structure_providers.py +277 -0
  50. quantui/tddft_calc.py +307 -0
  51. quantui/user_settings.py +238 -0
  52. quantui/utils.py +287 -0
  53. quantui/vib_cache.py +247 -0
  54. quantui/visualization_py3dmol.py +593 -0
  55. quantui/viz_assets.py +101 -0
  56. quantui/viz_backend_router.py +243 -0
  57. quantui-0.5.1.dist-info/METADATA +533 -0
  58. quantui-0.5.1.dist-info/RECORD +62 -0
  59. quantui-0.5.1.dist-info/WHEEL +5 -0
  60. quantui-0.5.1.dist-info/entry_points.txt +2 -0
  61. quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
  62. quantui-0.5.1.dist-info/top_level.txt +1 -0
@@ -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">&#10003;</span>'
84
+ cross = '<span style="color:#ef4444">&#10007;</span>'
85
+ return (tick if flag else cross) + (" " + extra if extra else "")
86
+
87
+ env_badge = (
88
+ f'&nbsp;&nbsp;<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 &mdash; 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">&#8987; checking&hellip;</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"&mdash; <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
+ "&#9888; consumer-class GPU &mdash; 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'&mdash; <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 "&mdash; 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 &ldquo;no data&rdquo;.</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&nbsp;s) is a quick "
449
+ f"SP-only smoke test; tier 2 ({len(benchmark_suite_long)} calcs, "
450
+ f"~3–5&nbsp;min) expands the SP grid; "
451
+ f"tier 3 ({len(_T3)} calcs, ~10–15&nbsp;min) adds small geometry "
452
+ f"optimizations + frequency calcs; "
453
+ f"tier 4 ({len(_T4)} calcs, up to ~30&nbsp;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"> &mdash; 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__} &nbsp;&middot;&nbsp; "
1264
+ "<b>Help</b> tab for instructions &nbsp;&middot;&nbsp; "
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("&ensp;&ensp;"),
1455
+ widgets.VBox([app.charge_si, app.mult_si]),
1456
+ widgets.HTML("&ensp;"),
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
+ "&#9872; 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
+ )