diff --git a/quantui/app.py b/quantui/app.py index b47ccef..ab3e15a 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -392,6 +392,7 @@ from quantui.app_visualization import ( wire_uv_controls as _viz_wire_uv_controls, ) +from quantui.cancellation import CalcCancelled as _CalcCancelled # Import directly from submodules to avoid circular-import issues. # quantui/__init__.py imports this module (app.py), so using @@ -606,6 +607,35 @@ def _load_last_calibration_label() -> str: .jp-OutputArea-stderr, .output_stderr { background: transparent !important; } + +/* 3D molecule-viewer frames (UXP.2) — a subtle bounding box so the viewer + extent reads clearly. The light border inverts to dark automatically under + the global dark-mode invert filter. */ +.quantui-viewer-frame { + border: 1px solid #c0ccd8 !important; + border-radius: 6px !important; + overflow: hidden !important; +} +/* Collapse the frame to nothing when the viewer output is empty (e.g. before + a calc runs) so no hollow box shows. Degrades to an always-on border where + :has() is unsupported. */ +.quantui-viewer-frame:not(:has(.jp-OutputArea-child)) { + border-color: transparent !important; +} + +/* Inline "calculating" spinner (UXP.3) — shown next to slow on-demand + controls (e.g. orbital-isosurface generation) while work is in flight. */ +@keyframes quantui-spin { to { transform: rotate(360deg); } } +.quantui-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid #c0ccd8; + border-top-color: #2563eb; + border-radius: 50%; + animation: quantui-spin 0.7s linear infinite; + vertical-align: middle; +} """ _LAYOUT_TRAITS: frozenset[str] = frozenset(widgets.Layout.class_trait_names()) @@ -638,19 +668,12 @@ def _layout(**kwargs: Any) -> widgets.Layout: # ══ LOG CAPTURE ══════════════════════════════════════════════════════════════ -class _CalcCancelled(BaseException): - """Raised to abort a running calculation when the user clicks Cancel. - - The calc streams output line-by-line through ``_LogCapture.write`` (SCF - cycles, optimizer steps), so checking a cancel flag there lets us bail at - the next output — a cooperative, graceful stop that needs no thread-kill. - - Inherits ``BaseException`` (like ``KeyboardInterrupt``) on purpose: PySCF / - session_calc / the optimizer wrap their kernels in ``except Exception``, so - a plain ``Exception`` cancel would be swallowed and re-reported as a - "Calculation failed" error. As a ``BaseException`` it sails through those - handlers and reaches ``_do_run``'s ``except _CalcCancelled`` cleanly. - """ +# ``_CalcCancelled`` is defined in quantui.cancellation (imported at the top of +# this module) so the calc modules (session_calc / optimizer / freq / tddft / +# nmr / pes) can raise the SAME class from their SCF callbacks + optimizer +# observers (UXP.5) without importing the app layer. ``_do_run``'s +# ``except _CalcCancelled`` catches it whether it was raised by +# ``_LogCapture.write`` or by one of those hooks. class _LogCapture: @@ -670,6 +693,9 @@ def __init__( self._on_scf_converged = on_scf_converged self._scf_converged_seen = False self._cancel_check = cancel_check + # Public alias so calc modules can duck-type the predicate off the + # progress stream (see quantui.cancellation.cancel_check_from_stream). + self.cancel_check = cancel_check def write(self, text: str) -> None: if not text: @@ -961,7 +987,9 @@ class QuantUIApp: mol_info_html: Any mol_summary_compact: Any mult_si: Any - notes_output: Any + _method_card_html: Any + _basis_card_html: Any + _descriptor_cards_box: Any nstates_si: Any perf_estimate_html: Any post_calc_panel: Any @@ -1757,6 +1785,10 @@ def _wire_callbacks(self) -> None: ) # Orbital isosurface generate button self._iso_generate_btn.on_click(self._on_iso_generate) + # UXP.4: reveal the free-entry MO-index input only in "By index" mode. + self._orb_toggle.observe( + self._safe_cb(self._on_orb_toggle_changed), names="value" + ) # M-EXPORT / EXPORT.5: cube + bundle exports self._iso_export_cube_btn.on_click(self._on_iso_export_cube) self._export_bundle_btn.on_click(self._on_export_bundle) @@ -1770,6 +1802,13 @@ def _files_allowed_roots(self) -> list[Path]: _last_dir = getattr(self, "_last_result_dir", None) if isinstance(_last_dir, Path): candidates.append(_last_dir) + # UXP.1: expose the app's own log dir (~/.quantui/logs) so the event + # log is reachable in-app. Resolves inside the runtime process, so it + # correctly points at the WSL home when the app runs under WSL. + try: + candidates.append(_calc_log._log_dir()) + except Exception: + pass for candidate in candidates: if candidate is None: @@ -1829,6 +1868,10 @@ def _format_files_root_label(self, root: Path) -> str: labels.append(("Current Result", _last_dir.resolve())) except OSError: pass + try: + labels.append(("Logs", _calc_log._log_dir().resolve())) + except OSError: + pass for prefix, known_root in labels: if root == known_root: @@ -1990,6 +2033,7 @@ def _preview_file_path(self, path: Path) -> None: ".txt", ".log", ".json", + ".jsonl", ".md", ".py", ".csv", @@ -2080,6 +2124,50 @@ def _preview_file_path(self, path: Path) -> None: except Exception: # noqa: BLE001 — fall through to text preview pass + if suffix == ".jsonl": + # UXP.1: JSONL logs (event_log.jsonl) grow append-only, so the + # newest — and most useful — records are at the END. The generic + # text dispatch keeps the FIRST 200 KB (oldest events), so tail + # the file here instead: show the last N lines, newest last. + try: + _MAX_TAIL_LINES = 300 + raw = path.read_bytes() + total_bytes = len(raw) + # Cap the decode window so a multi-MB log stays responsive; + # the tail is all we render anyway. + tail_raw = raw[-400_000:] + text = tail_raw.decode("utf-8", errors="replace") + lines = text.splitlines() + # A leading partial line can appear after byte-slicing — drop it. + if len(tail_raw) < total_bytes and lines: + lines = lines[1:] + total_lines_shown = min(len(lines), _MAX_TAIL_LINES) + shown = lines[-_MAX_TAIL_LINES:] + rendered = "\n".join(shown) + note = ( + f"Showing the last {total_lines_shown} record(s)" + + ( + " (file tail — older records not shown)" + if len(lines) > _MAX_TAIL_LINES or len(tail_raw) < total_bytes + else "" + ) + + "." + ) + with self._files_preview_output: + display( + HTML( + "
" + f"{_html.escape(note)}
" + ""
+ f"{_html.escape(rendered)}"
+ )
+ )
+ self._set_files_status(f"Log preview (tail): {path.name}")
+ return
+ except Exception: # noqa: BLE001 — fall through to text preview
+ pass
+
if suffix == ".csv":
try:
import csv as _csv
@@ -2944,7 +3032,17 @@ def _on_cancel(self, btn=None) -> None:
return
self._cancel_event.set()
self.cancel_btn.disabled = True
- self.run_status.value = "Cancelling — stopping at the next step…"
+ self.cancel_btn.description = "Cancelling…"
+ self.run_status.value = "Cancelling — stopping at the next cycle/step…"
+ # UXP.5: write an immediate marker to the live log so the click reads as
+ # acknowledged even if the next cooperative checkpoint is a moment away.
+ try:
+ self.run_output.append_stdout(
+ "\n⏹ Cancel requested — stopping at the next SCF cycle / "
+ "optimizer step…\n"
+ )
+ except Exception:
+ pass
def _on_preopt_preview(self, btn=None) -> None:
_run_on_preopt_preview(self, btn)
@@ -3824,6 +3922,12 @@ def _show_orbital_diagram(self, result) -> bool:
def _on_iso_generate(self, btn) -> None:
_viz_on_iso_generate(self, btn)
+ def _on_orb_toggle_changed(self, change) -> None:
+ """Show/hide the free-entry MO-index input for the 'By index' mode."""
+ self._orb_index_input.layout.display = (
+ "" if change["new"] == "By index" else "none"
+ )
+
def _on_orb_range_changed(self, _change=None) -> None:
_viz_on_orb_range_changed(self, _change)
@@ -4864,6 +4968,7 @@ def _run_required_final_single_point(target_mol, reason: str):
self._calc_running = False
self._cancel_event.clear()
self.cancel_btn.disabled = True
+ self.cancel_btn.description = "Cancel"
self.log_clear_btn.disabled = False
self._activity_end(kind="compute")
diff --git a/quantui/app_builders.py b/quantui/app_builders.py
index 9e61409..b249408 100644
--- a/quantui/app_builders.py
+++ b/quantui/app_builders.py
@@ -509,6 +509,7 @@ def build_shared_widgets(
# needs no scrollbar — clipping a few px of margin avoids an internal
# scrollbar that resets to the top on every backend/palette swap.
app.viz_output = widgets.Output(layout=layout_fn(height="510px", overflow="hidden"))
+ app.viz_output.add_class("quantui-viewer-frame")
app.run_output = widgets.Output(
layout=layout_fn(
border="1px solid #c0ccd8",
@@ -528,6 +529,7 @@ def build_shared_widgets(
)
app.result_output = widgets.Output()
app.result_viz_output = widgets.Output()
+ app.result_viz_output.add_class("quantui-viewer-frame")
app.comparison_output = widgets.Output()
app._last_result_dir = None
@@ -568,7 +570,18 @@ def build_shared_widgets(
[app.viz_style_dd, app.viz_lighting_dd],
layout=layout_fn(gap="8px", margin="2px 0 0 0", align_items="center"),
)
- app.notes_output = widgets.Output()
+ # UXP.7: method / basis descriptor cards replace the old inline
+ # ``notes_output`` educational-notes block. Two compact HTML cards
+ # (icon + one-line) sit to the right of the dropdowns; populated here with
+ # the defaults and refreshed by ``update_notes`` on every dropdown change.
+ from quantui.descriptor_cards import basis_card_html, method_card_html
+
+ app._method_card_html = widgets.HTML(value=method_card_html(default_method))
+ app._basis_card_html = widgets.HTML(value=basis_card_html(default_basis))
+ app._descriptor_cards_box = widgets.VBox(
+ [app._method_card_html, app._basis_card_html],
+ layout=layout_fn(margin="0 0 0 4px"),
+ )
app.perf_estimate_html = widgets.HTML()
app.step_progress = step_progress_cls(
@@ -875,7 +888,7 @@ def build_shared_widgets(
icon="stop",
disabled=True,
layout=layout_fn(width="110px", height="36px"),
- tooltip="Stop the running calculation (at the next step)",
+ tooltip=("Stop the running calculation at the next SCF cycle / optimizer step"),
)
app.log_clear_btn = widgets.Button(
@@ -1257,7 +1270,12 @@ def build_calc_setup(app: Any, *, layout_fn: Any) -> None:
),
widgets.HTML(" "),
widgets.VBox([app.charge_si, app.mult_si]),
- ]
+ widgets.HTML(" "),
+ # UXP.7 descriptor cards — to the right of the inputs so
+ # they don't displace charge/multiplicity.
+ app._descriptor_cards_box,
+ ],
+ layout=layout_fn(flex_wrap="wrap", align_items="flex-start"),
),
app.calc_type_dd,
app.calc_extra_opts,
@@ -1271,7 +1289,6 @@ def build_calc_setup(app: Any, *, layout_fn: Any) -> None:
[app.solvent_cb, app.solvent_dd],
layout=layout_fn(align_items="center", gap="4px"),
),
- app.notes_output,
]
)
@@ -1561,12 +1578,24 @@ def _plot_export_row(prefix: str) -> widgets.HBox:
orb_diagram_content,
layout=layout_fn(width="100%"),
)
+ # UXP.4: widen the preset buttons and add a "By index" mode that reveals a
+ # free-entry 0-based MO index input, so any orbital (not just the HOMO/LUMO
+ # neighbourhood) can be rendered as an isosurface.
app._orb_toggle = widgets.ToggleButtons(
- options=["HOMO-1", "HOMO", "LUMO", "LUMO+1"],
+ options=["HOMO-1", "HOMO", "LUMO", "LUMO+1", "By index"],
value="HOMO",
- style={"button_width": "70px"},
+ style={"button_width": "92px"},
layout=layout_fn(margin="8px 0 4px 0"),
)
+ app._orb_index_input = widgets.BoundedIntText(
+ value=0,
+ min=0,
+ max=100000,
+ description="MO #",
+ tooltip="0-based molecular-orbital index (0 = lowest-energy MO)",
+ style={"description_width": "40px"},
+ layout=layout_fn(display="none", width="150px", margin="0 0 4px 0"),
+ )
app._orb_iso_output = widgets.Output()
app._orb_iso_controls = widgets.VBox(
[
@@ -1575,6 +1604,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox:
"Orbital isosurface:"
),
app._orb_toggle,
+ app._orb_index_input,
app._orb_iso_output,
],
layout=layout_fn(display="none", margin="8px 0 0 0"),
@@ -1619,6 +1649,12 @@ def _plot_export_row(prefix: str) -> widgets.HBox:
app._iso_export_status = widgets.HTML(
value="", layout=layout_fn(margin="0 0 0 8px")
)
+ # UXP.3: inline "calculating" spinner shown while a cube is being computed.
+ # Hidden until on_iso_generate reveals it; hidden again on completion.
+ app._iso_spinner = widgets.HTML(
+ value='',
+ layout=layout_fn(display="none", margin="0 0 0 4px"),
+ )
iso_body = widgets.VBox(
[
widgets.HTML(
@@ -1631,6 +1667,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox:
widgets.HBox(
[
app._iso_generate_btn,
+ app._iso_spinner,
app._iso_export_cube_btn,
app._iso_export_status,
],
@@ -1772,6 +1809,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox:
app.results_panel = app.results_tab_panel
app._analysis_mol_output = widgets.Output()
+ app._analysis_mol_output.add_class("quantui-viewer-frame")
# Analysis-tab backend toggle — mirrors the Calculate-tab `viz_backend_toggle`.
# Created only when both backends are available (matches Calculate-tab
diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py
index afaef8d..56d967a 100644
--- a/quantui/app_runflow.py
+++ b/quantui/app_runflow.py
@@ -21,6 +21,45 @@ def _calc_type_badge(calc_type: str) -> str:
}.get(calc_type, calc_type or "Unknown")
+_RUN_HEADER_CALC_LABELS = {
+ "Single Point": "Single Point Energy",
+ "Geometry Opt": "Geometry Optimization",
+ "Frequency": "Frequency Analysis",
+ "UV-Vis (TD-DFT)": "TD-DFT (UV-Vis)",
+ "NMR Shielding": "NMR Shielding",
+ "PES Scan": "PES Scan",
+}
+
+
+def _write_provisional_run_header(app: Any) -> None:
+ """Print an immediate one-line header the instant Run is clicked (UXP.6).
+
+ The full structured banner (``format_log_header``) is written from the
+ background ``_do_run`` thread and can lag behind the first observable
+ output because it calls ``get_system_info()`` (which may shell out to
+ ``nvidia-smi``) and only runs once the thread has spun up. This provisional
+ line runs synchronously on the main thread so the user gets instant
+ feedback that the click registered; the full banner follows and augments it.
+ """
+ mol = getattr(app, "_molecule", None)
+ if mol is None:
+ return
+ try:
+ formula = mol.get_formula()
+ except Exception:
+ formula = "?"
+ ct_label = _RUN_HEADER_CALC_LABELS.get(
+ app.calc_type_dd.value, app.calc_type_dd.value
+ )
+ try:
+ app.run_output.append_stdout(
+ f"▶ Starting {ct_label} — {formula} · "
+ f"{app.method_dd.value}/{app.basis_dd.value} …\n"
+ )
+ except Exception:
+ pass
+
+
def on_run_clicked(app: Any, btn: Any) -> None:
"""Reset result panes and start the background run thread."""
app.run_output.clear_output()
@@ -39,6 +78,7 @@ def on_run_clicked(app: Any, btn: Any) -> None:
app._completion_banner.layout.display = "none"
app._to_analysis_btn.layout.display = "none"
app._analysis_empty_html.layout.display = "none"
+ _write_provisional_run_header(app)
threading.Thread(target=app._do_run, daemon=True).start()
@@ -1058,40 +1098,17 @@ def _progress(
def update_notes(app: Any, change: Any = None) -> None:
- """Refresh educational method notes for the active molecule/method."""
- app.notes_output.clear_output(wait=True)
- if app._molecule is None:
- return
+ """Refresh the method / basis descriptor cards (UXP.7).
+
+ Replaces the old inline educational-notes text block. The cards describe
+ the *method* and *basis* themselves, so — unlike the old notes — they
+ refresh independently of whether a molecule is loaded.
+ """
try:
- from quantui import PySCFCalculation
+ from quantui.descriptor_cards import basis_card_html, method_card_html
- calc = PySCFCalculation(
- app._molecule,
- method=app.method_dd.value,
- basis=app.basis_dd.value,
- )
- notes = calc.get_educational_notes()
- if notes:
- # M12 audit fix (2026-07-14): .replace("**", ..., 1) only
- # converts the FIRST **bold** pair in the whole string — every
- # note after the first (get_educational_notes() typically
- # returns 2-3 separate "**Label**: description" paragraphs
- # joined by "\n\n") kept its literal "**" markers instead of
- # being rendered bold. A regex replaces every **...** pair.
- import re as _re
-
- safe = _re.sub(r"\*\*(.+?)\*\*", r"\1", notes).replace(
- "\n\n", "' + f"⚠ Orbital index out of range. This calculation has " + f"{n_total} molecular orbitals (valid indices 0–" + f"{n_total - 1}; HOMO = {n_occ - 1}).
" + ) + ) + + app._queue_main_thread_callback(_show_range_err) return mo_coeff = getattr(app, "_last_orb_mo_coeff", None) diff --git a/quantui/cancellation.py b/quantui/cancellation.py new file mode 100644 index 0000000..0ddc3e6 --- /dev/null +++ b/quantui/cancellation.py @@ -0,0 +1,67 @@ +"""Shared cancellation primitive for in-flight calculations (UXP.5). + +The Cancel button sets a ``threading.Event`` on the app; the run's +``_LogCapture`` checks it on every output line and raises +:class:`CalcCancelled` at the next line — a cooperative stop that needs no +(unsafe) thread-kill. UXP.5 tightens that latency by also attaching the same +check to PySCF SCF callbacks and ASE optimizer observers, so cancellation +fires between SCF cycles / optimizer steps even when the calc is running +silently (verbose=0) and no output line is triggering the stream check. + +The cancellation exception inherits :class:`BaseException`, **not** +``Exception`` — PySCF / ASE / ``session_calc`` wrap their kernels in +``except Exception``, so a plain-``Exception`` cancel would be swallowed and +re-reported as "Calculation failed". A ``BaseException`` (like +``KeyboardInterrupt``) sails through and reaches ``_do_run``'s +``except _CalcCancelled`` cleanly. See reflection 02 Rule 8. +""" + +from __future__ import annotations + +from typing import Any, Callable, Optional + +CancelCheck = Callable[[], bool] + + +class CalcCancelled(BaseException): + """Raised to abort an in-flight calculation at a cooperative checkpoint.""" + + +def raise_if_cancelled(cancel_check: Optional[CancelCheck]) -> None: + """Raise :class:`CalcCancelled` if *cancel_check* is set and returns True.""" + if cancel_check is not None and cancel_check(): + raise CalcCancelled() + + +def cancel_check_from_stream(stream: Any) -> Optional[CancelCheck]: + """Extract the cancel-check predicate carried on a progress stream. + + The run's ``_LogCapture`` exposes its cancel predicate as a public + ``cancel_check`` attribute. Any calc module that receives the stream can + duck-type it out with this helper — no import of the app layer needed. + Returns ``None`` when the stream carries no (callable) predicate. + """ + cc = getattr(stream, "cancel_check", None) + return cc if callable(cc) else None + + +def attach_scf_cancel_callback(mf: Any, cancel_check: Optional[CancelCheck]) -> None: + """Attach a cancel check to a PySCF SCF object's per-cycle ``callback``. + + PySCF invokes ``mf.callback(envs)`` once per SCF macro-iteration. Raising + :class:`CalcCancelled` there stops the run between cycles regardless of + whether output is being streamed. No-op when *cancel_check* is ``None`` or + the object doesn't accept a callback (best-effort — cooperative + output-line cancellation still applies). + """ + if cancel_check is None or mf is None: + return + + def _cb(_envs: Any, _cc: CancelCheck = cancel_check) -> None: + if _cc(): + raise CalcCancelled() + + try: + mf.callback = _cb + except Exception: # noqa: BLE001 — best-effort; not all backends allow it + pass diff --git a/quantui/descriptor_cards.py b/quantui/descriptor_cards.py new file mode 100644 index 0000000..f18d19b --- /dev/null +++ b/quantui/descriptor_cards.py @@ -0,0 +1,177 @@ +"""Method / basis descriptor cards (UXP.7 — FR-DESCRIPTOR-CARDS). + +Replaces the inline multi-paragraph educational-notes block next to the +method / basis dropdowns with two compact "descriptor cards" — an icon, a +one-line title, and a single distilled sentence — styled like the History-tab +result cards (light background + coloured left border). + +Pure string builders (no widgets / no PySCF import) so the card content is +unit-testable in isolation. Icons are inline SVG (offline-safe — never a CDN +asset) drawn with ``stroke="currentColor"`` so they take the family accent +colour from the wrapping span and invert with the global dark-mode filter like +the rest of the UI (inline SVG is not one of the ``canvas/img/iframe/video`` +tags the filter excludes). + +Method family comes from ``config.METHOD_INFO[method]["type"]`` +(``hf`` / ``dft`` / ``wavefunction``); the one-line body reuses that entry's +``use_for`` so the card can never drift from the help text. Basis family is +derived from the basis-set name. +""" + +from __future__ import annotations + +from . import config + +# ── Icons (24×24 inline SVG, currentColor) ─────────────────────────────────── + +_SVG_OPEN = ( + '' +) + +# DFT — electron-density cloud. +_ICON_DFT = ( + _SVG_OPEN + '