Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 120 additions & 15 deletions quantui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
</style>"""

_LAYOUT_TRAITS: frozenset[str] = frozenset(widgets.Layout.class_trait_names())
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1990,6 +2033,7 @@ def _preview_file_path(self, path: Path) -> None:
".txt",
".log",
".json",
".jsonl",
".md",
".py",
".csv",
Expand Down Expand Up @@ -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(
"<p style='font-size:11px;color:#64748b;margin:0 0 4px'>"
f"{_html.escape(note)}</p>"
"<pre style='white-space:pre-wrap;word-break:break-word;"
"font-size:12px;line-height:1.35;margin:0'>"
f"{_html.escape(rendered)}</pre>"
)
)
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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")

Expand Down
50 changes: 44 additions & 6 deletions quantui/app_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1257,7 +1270,12 @@ def build_calc_setup(app: Any, *, layout_fn: Any) -> None:
),
widgets.HTML("&ensp;&ensp;"),
widgets.VBox([app.charge_si, app.mult_si]),
]
widgets.HTML("&ensp;"),
# 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,
Expand All @@ -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,
]
)

Expand Down Expand Up @@ -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(
[
Expand All @@ -1575,6 +1604,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox:
"Orbital isosurface:</span>"
),
app._orb_toggle,
app._orb_index_input,
app._orb_iso_output,
],
layout=layout_fn(display="none", margin="8px 0 0 0"),
Expand Down Expand Up @@ -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='<span class="quantui-spinner"></span>',
layout=layout_fn(display="none", margin="0 0 0 4px"),
)
iso_body = widgets.VBox(
[
widgets.HTML(
Expand All @@ -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,
],
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading