diff --git a/app_desktop/constants_editor.py b/app_desktop/constants_editor.py index 340829b3..3d3bd9f1 100644 --- a/app_desktop/constants_editor.py +++ b/app_desktop/constants_editor.py @@ -7,6 +7,7 @@ from PySide6.QtWidgets import ( QCheckBox, QHBoxLayout, + QLabel, QPushButton, QPlainTextEdit, QStackedWidget, @@ -21,7 +22,7 @@ normalize_constants_state, parse_constants_text, ) -from app_desktop.theme import constants_editor_style +from app_desktop.theme import CARD_PADDING, constants_editor_style from app_desktop.widget_hints import set_accessible_description @@ -73,22 +74,12 @@ def __init__( layout.setContentsMargins(8, 8, 8, 8) layout.setSpacing(8) - header_layout = QHBoxLayout() - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.setSpacing(6) + # The checkbox is legacy/hidden; keep it for callers that still poke it, but it no longer + # occupies its own header row (the summary + ? sit in the controls row, like the data card). self.checkbox = QCheckBox(checkbox_text) self.checkbox.setChecked(bool(checked)) self.checkbox.toggled.connect(self._on_checked_changed) self.checkbox.hide() - header_layout.addWidget(self.checkbox) - self.help_button = _HelpButton("?") - self.help_button.setFlat(True) - self.help_button.setFocusPolicy(Qt.NoFocus) - self.help_button.setFixedWidth(24) - self.help_button.hide() - header_layout.addWidget(self.help_button) - header_layout.addStretch() - layout.addLayout(header_layout) self.controls_widget = QWidget() controls_layout = QHBoxLayout(self.controls_widget) @@ -103,11 +94,26 @@ def __init__( self.remove_button.clicked.connect(self._remove_row) self.clear_button.clicked.connect(self.clear) self.view_toggle_button.clicked.connect(self._toggle_view) + # Card title on the LEFT of the controls row (mirrors the data card's "输入数据" title). + self.title_label = QLabel("输入常数") + self.title_label.setObjectName("constants_title") + controls_layout.addWidget(self.title_label) controls_layout.addWidget(self.add_button) controls_layout.addWidget(self.remove_button) controls_layout.addWidget(self.clear_button) controls_layout.addWidget(self.view_toggle_button) controls_layout.addStretch() + # Row-count summary (mirrors the data card's "N 行"), then the ? help button — both on the + # RIGHT of the controls row, not a separate header line. + self.summary_label = QLabel("") + self.summary_label.setObjectName("constants_summary") + controls_layout.addWidget(self.summary_label) + self.help_button = _HelpButton("?") + self.help_button.setFlat(True) + self.help_button.setFocusPolicy(Qt.NoFocus) + self.help_button.setFixedWidth(24) + self.help_button.hide() + controls_layout.addWidget(self.help_button) layout.addWidget(self.controls_widget) self.stack = QStackedWidget() @@ -115,6 +121,10 @@ def __init__( self.table_view.setHorizontalHeaderLabels(["Name", "Value"]) self.table_view.setMinimumHeight(120) self.table_view.itemChanged.connect(self._on_table_changed) + # Excel-like block copy (Ctrl/Cmd+C → TSV). + from app_desktop.table_copy import install_cell_copy + + install_cell_copy(self.table_view) self.stack.addWidget(self.table_view) self.text_view = QPlainTextEdit() @@ -130,6 +140,7 @@ def __init__( layout.addWidget(self.stack) self._on_checked_changed(self.checkbox.isChecked()) + self._update_summary() self._constructed = True def set_embedded_in_workbench(self, embedded: bool) -> None: @@ -137,8 +148,21 @@ def set_embedded_in_workbench(self, embedded: bool) -> None: self.setProperty("datalab_constants_embedded", embedded) layout = self.layout() if layout is not None: - margin = 0 if embedded else 8 - layout.setContentsMargins(margin, margin, margin, margin) + # Embedded card now has its own border (like the data card) → pad content off it with + # the shared CARD_PADDING; standalone keeps its tighter 8px inset. + if embedded: + layout.setContentsMargins(*CARD_PADDING) + else: + layout.setContentsMargins(8, 8, 8, 8) + self.setStyleSheet(constants_editor_style(embedded=embedded)) + self.style().unpolish(self) + self.style().polish(self) + + def refresh_theme_style(self) -> None: + """Re-apply the (theme-dependent) editor style for the current embedded state. Its style is + otherwise set once at construction/embedding, so a live light↔dark toggle would leave the + button colors stale — the theme refresh calls this.""" + embedded = bool(self.property("datalab_constants_embedded")) self.setStyleSheet(constants_editor_style(embedded=embedded)) self.style().unpolish(self) self.style().polish(self) @@ -273,7 +297,19 @@ def _apply_inputs_visibility(self) -> None: self.controls_widget.setEnabled(visible) self.stack.setEnabled(visible) + def _update_summary(self) -> None: + """Show the number of filled constant rows (mirrors the data card's "N 行").""" + label = getattr(self, "summary_label", None) + if label is None: + return + try: + count = len([r for r in self.rows() if (r.get("name") or r.get("value"))]) + except Exception: + count = 0 + label.setText(f"{count} 行") + def _emit_changed(self, *_args: object) -> None: + self._update_summary() if not self._syncing: self.changed.emit() diff --git a/app_desktop/current_page_stack.py b/app_desktop/current_page_stack.py index eb1191cc..3588c264 100644 --- a/app_desktop/current_page_stack.py +++ b/app_desktop/current_page_stack.py @@ -1,20 +1,45 @@ from __future__ import annotations from PySide6.QtCore import QSize -from PySide6.QtWidgets import QStackedWidget +from PySide6.QtWidgets import QStackedWidget, QWidget class CurrentPageStack(QStackedWidget): - """QStackedWidget whose layout hints come only from the current page.""" + """QStackedWidget whose height tracks the CURRENT page only. + + A plain QStackedWidget sizes to its tallest page, so a short mode config would sit in a hollow + gap; capping it at Maximum policy instead clipped a mode whose config grows after layout + (fitting→comparison). This subclass pins its own fixed height to the active page's sizeHint, + re-syncing on page change and when the active page's layout invalidates — so it is always + exactly as tall as the current page needs (no gap, no clip). + """ + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.currentChanged.connect(lambda _index: self._sync_height_to_current()) def sizeHint(self) -> QSize: page = self.currentWidget() - if page is None: - return super().sizeHint() - return page.sizeHint() + return page.sizeHint() if page is not None else super().sizeHint() def minimumSizeHint(self) -> QSize: + page = self.currentWidget() + return page.minimumSizeHint() if page is not None else super().minimumSizeHint() + + def _sync_height_to_current(self) -> None: page = self.currentWidget() if page is None: - return super().minimumSizeHint() - return page.minimumSizeHint() + return + # Fix the stack to the current page's preferred height so the layout neither inflates a + # short page (gap) nor caps a taller/grown page (clip). + self.setFixedHeight(max(page.sizeHint().height(), page.minimumSizeHint().height())) + + def event(self, evt) -> bool: # type: ignore[no-untyped-def] + # LayoutRequest fires when the current page's contents change size (e.g. a mode reveals + # extra fields). Re-sync so a dynamically growing page is not clipped. + result = super().event(evt) + from PySide6.QtCore import QEvent + + if evt.type() == QEvent.Type.LayoutRequest: + self._sync_height_to_current() + return result diff --git a/app_desktop/fitting_latex_writer.py b/app_desktop/fitting_latex_writer.py index a17b9c09..9e909404 100644 --- a/app_desktop/fitting_latex_writer.py +++ b/app_desktop/fitting_latex_writer.py @@ -38,8 +38,13 @@ def latex_escape(text: str) -> str: return "".join(mapping.get(ch, ch) for ch in str(text)) -def build_fit_latex_preamble(*, use_dcolumn: bool, digits: int, latex_group_size: int) -> list[str]: - group_size = max(1, int(latex_group_size)) +def build_fit_latex_preamble( + *, use_dcolumn: bool, digits: int, latex_group_size: int, native_group_width: bool = True +) -> list[str]: + # max(0, ...) not max(1, ...): group_size 0 must stay 0 so build_sisetup_block emits the + # "no grouping" body (group-digits = false). max(1,..) forced 0→1 → grouping stayed ON, + # contradicting the UI's "0 = 不分组" (dual-model review F1). + group_size = max(0, int(latex_group_size)) lines = [ "\\documentclass{article}", "\\usepackage{ifxetex}", @@ -65,12 +70,14 @@ def build_fit_latex_preamble(*, use_dcolumn: bool, digits: int, latex_group_size ] ) lines.append("\\usepackage{siunitx}") - # Centralized v2/v3-compatible \sisetup{...} block — see helper for - # the ``\@ifpackagelater`` guard around v3-only ``digit-group-size``. + # native_group_width True → emit siunitx digit-group-size (native S-column variable-width + # grouping); False (bundled Tectonic) → don't (cells are pre-grouped app-side). + emit_dgs = bool(native_group_width and not use_dcolumn and group_size > 0) lines.append( build_sisetup_block( group_size=group_size, include_dcolumn=use_dcolumn, + emit_digit_group_size=emit_dgs, ).rstrip("\n") ) lines.extend( @@ -105,10 +112,25 @@ def build_fit_latex_block( default_uncertainty_digits: int | None = None, cleaned_substituted: str | None = None, units: Mapping[str, Any] | None = None, + native_group_width: bool = True, ) -> list[str]: + from datalab_latex.latex_formatting import group_digits_both_sides + default_unc_digits = default_uncertainty_digits variable_pairs = variable_pairs or [] target_column = (target_column or "").strip() + # App-side grouping when the engine can't vary the siunitx group WIDTH (non-native) and + # grouping is on in siunitx (non-dcolumn) mode: pre-group each value cell + use a plain r + # column instead of an S column siunitx would re-group at a fixed 3. + _group = max(0, int(latex_group_size)) + app_group = (not native_group_width) and (not use_dcolumn) and _group > 0 + + def _maybe_group(cell: str) -> str: + if app_group and "\\multicolumn" not in cell and "\\text" not in cell: + # The grouped value goes into a plain r column, so return the grouped string directly — + # wrapping it in \text{...} (with its \, thin-spaces) broke TeX compilation (CodeRabbit). + return group_digits_both_sides(cell, _group) + return cell def _format_cell_value(val: mp.mpf, sigma_obj, *, is_input: bool) -> str: sigma_digits = None if is_input else default_unc_digits @@ -125,14 +147,16 @@ def _format_cell_value(val: mp.mpf, sigma_obj, *, is_input: bool) -> str: sigma = mp.mpf(sigma) except Exception: sigma = None - return format_value_for_latex_file( - mp.mpf(val), - sigma, - use_dcolumn=use_dcolumn, - latex_input_decimals=digits, - is_input=is_input, - latex_group_size=latex_group_size, - uncertainty_digits=sigma_digits, + return _maybe_group( + format_value_for_latex_file( + mp.mpf(val), + sigma, + use_dcolumn=use_dcolumn, + latex_input_decimals=digits, + is_input=is_input, + latex_group_size=latex_group_size, + uncertainty_digits=sigma_digits, + ) ) def _format_key(key: str) -> str: @@ -221,26 +245,26 @@ def _target_unit() -> str: ("RMSE", fit_result.rmse), ] for label, value in metrics: - val_text = format_value_for_latex_file( + val_text = _maybe_group(format_value_for_latex_file( mp.mpf(value), None, use_dcolumn=use_dcolumn, latex_input_decimals=digits, is_input=True, latex_group_size=latex_group_size, - ) + )) row_unit = output_unit if label == "RMSE" else "" table_rows.append((label, val_text, row_unit)) def _format_diagnostic_value(value: object) -> str: - return format_value_for_latex_file( + return _maybe_group(format_value_for_latex_file( mp.mpf(value), None, use_dcolumn=use_dcolumn, latex_input_decimals=digits, is_input=True, latex_group_size=latex_group_size, - ) + )) diagnostic_entries, diagnostic_warnings = build_fitting_diagnostic_latex_entries( fit_result, @@ -296,6 +320,9 @@ def _format_diagnostic_value(value: object) -> str: value_cells = [val for _, val, _unit in table_rows] if use_dcolumn: numeric_spec = calculate_dcolumn_format_for_column(value_cells, "fit_values") + elif app_group: + # Cells are pre-grouped + wrapped in \text{}; a plain right-aligned column. + numeric_spec = "r" else: numeric_spec = siunitx_column_spec(value_cells) include_unit_column = any(unit for _key, _val, unit in table_rows) diff --git a/app_desktop/formula_preview.py b/app_desktop/formula_preview.py index eb6e9e8e..f6874b5c 100644 --- a/app_desktop/formula_preview.py +++ b/app_desktop/formula_preview.py @@ -35,6 +35,13 @@ _IDENTIFIER_RE: Final = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _INLINE_PREVIEW_MAX_WIDTH: Final = 520 _INLINE_PREVIEW_MAX_HEIGHT: Final = 104 +# The label reserves 12px padding + a 1px border on each side (formula_inline_preview_style). The +# rendered pixmap must fit INSIDE that inset, otherwise a tall formula fills the label edge-to-edge +# and paints over the bottom padding/rounded border (the "border not closed" bug). Cap the pixmap a +# little smaller than the label content box so the rounded border always stays visible. +_INLINE_PREVIEW_INSET: Final = 2 * (12 + 1) +_INLINE_PREVIEW_PIXMAP_MAX_HEIGHT: Final = _INLINE_PREVIEW_MAX_HEIGHT - _INLINE_PREVIEW_INSET +_INLINE_PREVIEW_PIXMAP_MAX_WIDTH: Final = _INLINE_PREVIEW_MAX_WIDTH - _INLINE_PREVIEW_INSET class FormulaPreviewLabel(QLabel): @@ -192,6 +199,9 @@ def configure_formula_preview_label(label: QLabel, *, constrain_size: bool = Fal label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) label.setCursor(Qt.CursorShape.PointingHandCursor) label.setToolTip("Click to enlarge formula") + # WA_StyledBackground makes Qt honour the stylesheet's border-radius on a QLabel — without + # it the rounded background/border isn't clipped to the corners, so they look squared-off. + label.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) label.setStyleSheet(formula_inline_preview_style()) @@ -264,14 +274,16 @@ def update_formula_preview_with_empty_text( ) pixmap = QPixmap() if result.ok and result.png_bytes and _load_png_pixmap(pixmap, result.png_bytes): - if pixmap.width() > _INLINE_PREVIEW_MAX_WIDTH: + # Scale to fit INSIDE the label's padded content box (leaving the border visible), not to the + # label's outer max size — see _INLINE_PREVIEW_PIXMAP_MAX_* above. + if pixmap.width() > _INLINE_PREVIEW_PIXMAP_MAX_WIDTH: pixmap = pixmap.scaledToWidth( - _INLINE_PREVIEW_MAX_WIDTH, + _INLINE_PREVIEW_PIXMAP_MAX_WIDTH, Qt.TransformationMode.SmoothTransformation, ) - if pixmap.height() > _INLINE_PREVIEW_MAX_HEIGHT: + if pixmap.height() > _INLINE_PREVIEW_PIXMAP_MAX_HEIGHT: pixmap = pixmap.scaledToHeight( - _INLINE_PREVIEW_MAX_HEIGHT, + _INLINE_PREVIEW_PIXMAP_MAX_HEIGHT, Qt.TransformationMode.SmoothTransformation, ) label.setPixmap(pixmap) diff --git a/app_desktop/history_panel.py b/app_desktop/history_panel.py index 6a75162b..38915e35 100644 --- a/app_desktop/history_panel.py +++ b/app_desktop/history_panel.py @@ -115,6 +115,25 @@ def __init__(self, owner: Any, parent: QWidget | None = None) -> None: self.message_label.setWordWrap(True) layout.addWidget(self.message_label) + # Collapse-by-default: the history body (list + action buttons + export + message) + # is hidden until the user clicks the header, so the section does not hog space in + # the result overview. The header shows a ▸/▾ indicator. + self._history_collapsible = [ + self.entry_list, + self.restore_button, + self.compare_button, + self.budget_button, + self.rename_button, + self.pin_button, + self.delete_button, + self.export_button, + self.message_label, + ] + self._history_collapsed = True + self.title_label.setCursor(Qt.CursorShape.PointingHandCursor) + self.title_label.mousePressEvent = lambda _e: self.toggle_history_collapsed() # type: ignore[method-assign] + self._apply_history_collapsed() + self.restore_button.clicked.connect(self.restore_selected) self.compare_button.clicked.connect(self.compare_selected) self.budget_button.clicked.connect(self.show_budget_selected) @@ -125,6 +144,26 @@ def __init__(self, owner: Any, parent: QWidget | None = None) -> None: self._register_texts() self.refresh() + # Re-apply after refresh()/_register_texts() so the collapsed state + ▸ indicator win. + self._apply_history_collapsed() + + # -- collapse-by-default ------------------------------------------------- + def is_history_collapsed(self) -> bool: + return bool(getattr(self, "_history_collapsed", True)) + + def set_history_collapsed(self, collapsed: bool) -> None: + self._history_collapsed = bool(collapsed) + self._apply_history_collapsed() + + def toggle_history_collapsed(self) -> None: + self.set_history_collapsed(not self.is_history_collapsed()) + + def _apply_history_collapsed(self) -> None: + collapsed = self.is_history_collapsed() + for widget in getattr(self, "_history_collapsible", ()): + widget.setVisible(not collapsed) + base = self._tr("历史", "History") + self.title_label.setText(f"{'▸' if collapsed else '▾'} {base}") def refresh(self) -> None: selected = self._selected_ref() @@ -144,7 +183,9 @@ def refresh(self) -> None: self.entry_list.addItem(item) self.count_label.setText(self._count_text(store)) - self.entry_list.setVisible(bool(rows)) + # Respect the collapsed state — when collapsed the whole body stays hidden + # regardless of whether there are rows. + self.entry_list.setVisible(bool(rows) and not self.is_history_collapsed()) if not rows: self.message_label.setText(self._tr("暂无历史记录。", "No history yet.")) elif ( @@ -315,6 +356,7 @@ def _show_display_in_results(self, display: Any, *, result_kind: str, success_me previous_export_enabled = export_is_enabled() if callable(export_is_enabled) else None self._owner._last_result_kind = result_kind self._owner._last_result_payloads = {} + self._owner._last_latex_inputs = {} self._owner._last_result_semantic_snapshot = None self._owner._last_result_semantic_snapshot_kind = None set_result_text = getattr(self._owner, "_set_result_text", None) diff --git a/app_desktop/history_popup.py b/app_desktop/history_popup.py new file mode 100644 index 00000000..a140016b --- /dev/null +++ b/app_desktop/history_popup.py @@ -0,0 +1,64 @@ +"""Toolbar-launched history popup. + +The history panel is a full interactive widget (entry list + restore/compare/budget/rename/pin/ +delete/export buttons) — too large for the thin toolbar. Instead a toolbar 历史 button opens it +in a top-level ``Qt.Popup`` window, mirroring how the status chip opens the result-overview +popover. Unlike that popover (which builds its own read-only labels), the history panel is +interactive, so the REAL ``workbench_history_panel`` widget is reparented into the popup when +shown and back out when hidden — its buttons keep working and no state is duplicated. +""" + +from __future__ import annotations + +from typing import Any + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QVBoxLayout, QWidget + + +def _build_popup(owner: Any) -> QWidget | None: + popup = getattr(owner, "_history_popup", None) + if popup is None: + popup = QWidget(owner, Qt.WindowType.Popup) + popup.setObjectName("history_popup") + layout = QVBoxLayout(popup) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(0) + owner._history_popup = popup + return popup + + +def toggle_history_popup(owner: Any) -> None: + """Open (or close) the history popup, hosting the real history panel, anchored to the + toolbar 历史 button.""" + panel = getattr(owner, "workbench_history_panel", None) + if panel is None: + return + popup = _build_popup(owner) + if popup is None: + return + if popup.isVisible(): + popup.hide() + return + + # Host the real panel inside the popup for this showing (reparents it in). + layout = popup.layout() + if panel.parent() is not popup: + layout.addWidget(panel) + panel.show() + + # Refresh so the list reflects the latest history before showing. + refresh = getattr(panel, "refresh", None) + if callable(refresh): + refresh() + + anchor = getattr(owner, "history_button", None) + if anchor is not None: + try: + global_pos = anchor.mapToGlobal(anchor.rect().bottomLeft()) + popup.move(global_pos) + except (RuntimeError, AttributeError): + pass + popup.adjustSize() + popup.show() + popup.raise_() diff --git a/app_desktop/latex_inputs_serialization.py b/app_desktop/latex_inputs_serialization.py new file mode 100644 index 00000000..cf86dc49 --- /dev/null +++ b/app_desktop/latex_inputs_serialization.py @@ -0,0 +1,117 @@ +"""Serialize the on-demand-tex stash (``_last_latex_inputs``) for workspace persistence. + +The stash holds mpmath-heavy, mode-specific data (``mp.mpf`` scalars, ``FitResult`` dataclasses, +``UncertainValue`` objects, and nested lists/tuples/dicts). JSON can't carry those directly, so +this module recursively encodes them into a JSON-safe form with small type tags, and decodes +back to the exact originals. ``mp.mpf`` is stored via ``mp.nstr`` at high precision so re-opening +a workspace regenerates identical TeX without recomputing. + +Type tags (dict with a single ``__t__`` key): +- ``mpf`` → mpmath float, value stored as a decimal string (full precision) +- ``tuple`` → tuple (JSON only has arrays; we must not silently turn tuples into lists) +- ``uv`` → ``UncertainValue`` +- ``fit`` → ``FitResult`` +""" + +from __future__ import annotations + +from dataclasses import fields as dataclass_fields +from typing import Any + +import mpmath as mp + +from fitting.hp_fitter import FitResult +from shared.precision import MAX_MPMATH_DPS, precision_guard +from shared.uncertainty import UncertainValue + +# Working precision used to RECONSTRUCT an mpf from its raw (sign, mantissa, exp) parts. It must +# comfortably exceed the mantissa bit width of any stored value; 1e6 dps (the app's clamp ceiling) +# guarantees the man * 2^exp product is formed without rounding. See _decode. +_MPF_RECONSTRUCT_DPS = 1_000_000 + + +def _encode(obj: Any) -> Any: + if isinstance(obj, bool): # bool before int/mpf (bool is an int subclass) + return obj + if isinstance(obj, mp.mpf): + # Store the EXACT binary value as (sign, mantissa, exp) integers — NOT a decimal string + # via mp.nstr, which capped precision at a fixed digit count AND re-rounded to the ambient + # mp.dps on decode (two-sided precision loss, review S1). A finite mpf equals + # (-1)^sign * mantissa * 2^exp exactly; special values (inf/nan) have no finite mantissa + # so fall back to their string form. + if mp.isfinite(obj): + sign, man, exp, _bc = obj._mpf_ + return {"__t__": "mpf", "s": int(sign), "m": str(int(man)), "e": int(exp)} + return {"__t__": "mpf_special", "v": mp.nstr(obj)} + if isinstance(obj, FitResult): + return { + "__t__": "fit", + "fields": {f.name: _encode(getattr(obj, f.name)) for f in dataclass_fields(obj)}, + } + if isinstance(obj, UncertainValue): + return { + "__t__": "uv", + "value": _encode(obj.value), + "uncertainty": _encode(obj.uncertainty), + "uncertainty_digits": obj.uncertainty_digits, + } + if isinstance(obj, tuple): + return {"__t__": "tuple", "items": [_encode(x) for x in obj]} + if isinstance(obj, list): + return [_encode(x) for x in obj] + if isinstance(obj, dict): + # Keys in the stash are always strings; coerce defensively for JSON. + return {str(k): _encode(v) for k, v in obj.items()} + if isinstance(obj, (str, int, float)) or obj is None: + return obj + # Unknown type: fall back to a string tag so encoding never raises (fail-soft). The decoder + # returns it verbatim; a builder that needs the real object will simply see a string. + return {"__t__": "repr", "v": repr(obj)} + + +def _decode(obj: Any) -> Any: + if isinstance(obj, dict): + tag = obj.get("__t__") + if tag == "mpf": + # Reconstruct man * 2^exp under a working precision wide enough that the product is + # formed WITHOUT rounding to the ambient mp.dps — exact regardless of session dps. + if "m" in obj: + with precision_guard(_MPF_RECONSTRUCT_DPS, clamp_max=MAX_MPMATH_DPS): + value = mp.mpf(int(obj["m"])) * mp.power(2, int(obj["e"])) + return -value if int(obj.get("s", 0)) else value + # Back-compat: an older workspace may hold the legacy decimal-string form. Parse it + # under high precision so at least the stored digits survive. + with precision_guard(_MPF_RECONSTRUCT_DPS, clamp_max=MAX_MPMATH_DPS): + return mp.mpf(obj["v"]) + if tag == "mpf_special": + return mp.mpf(obj["v"]) + if tag == "tuple": + return tuple(_decode(x) for x in obj["items"]) + if tag == "uv": + return UncertainValue( + _decode(obj["value"]), + _decode(obj["uncertainty"]), + uncertainty_digits=obj.get("uncertainty_digits"), + ) + if tag == "fit": + return FitResult(**{k: _decode(v) for k, v in obj["fields"].items()}) + if tag == "repr": + return obj["v"] + return {k: _decode(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_decode(x) for x in obj] + return obj + + +def encode_latex_inputs(store: dict[str, Any] | None) -> dict[str, Any]: + """Encode the whole ``_last_latex_inputs`` store to a JSON-safe dict (empty if falsy).""" + if not isinstance(store, dict): + return {} + return {str(kind): _encode(inputs) for kind, inputs in store.items()} + + +def decode_latex_inputs(encoded: dict[str, Any] | None) -> dict[str, Any]: + """Decode a previously-encoded store back to the original mpmath-bearing structures.""" + if not isinstance(encoded, dict): + return {} + return {str(kind): _decode(inputs) for kind, inputs in encoded.items()} diff --git a/app_desktop/latex_preview_dialog.py b/app_desktop/latex_preview_dialog.py new file mode 100644 index 00000000..4627eb18 --- /dev/null +++ b/app_desktop/latex_preview_dialog.py @@ -0,0 +1,286 @@ +"""LaTeX preview dialog — a resizable window with TeX-source and PDF-preview tabs. + +Per the 2026-07-05 spec, LaTeX/PDF move out of the result tabs into this dedicated dialog. +It uses NEW display widgets and REUSES the underlying logic — it never reparents the +result-tab ``latex_edit`` / ``pdf_scroll`` (those are the result-panel's own widgets): + +* **TeX tab** — a fresh ``NumberedTextEdit`` + ``LatexHighlighter`` showing the current tex + source (from ``window.latex_edit``). 复制 copies it to the clipboard; 保存 writes it to a + ``QFileDialog``-chosen path (the ONLY user-path write). +* **PDF tab** — compiles the current tex via the window's tectonic-only + ``compile_latex_to_pdf`` (Module 2) to a temp PDF, then rasterizes it with the pure + ``shared.pdf_preview_raster.convert_pdf_to_images`` helper into the dialog's OWN scroll + (the dialog owns its zoom/dpi — no coupling to the main window's pdf state). + +The dialog is non-modal and parented to the main window; it is created lazily and reused. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from PySide6.QtCore import Qt +from PySide6.QtGui import QImage, QPixmap +from PySide6.QtWidgets import ( + QApplication, + QDialog, + QFileDialog, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QScrollArea, + QTabWidget, + QVBoxLayout, + QWidget, +) + +__all__ = ["LatexPreviewDialog", "open_latex_preview_dialog"] + +# Default rasterization DPI for the dialog's PDF preview (the dialog owns this, not self). +_PREVIEW_DPI = 150 + + +def _worker_is_running(worker: Any) -> bool: + """True if a compile worker (QThread) is genuinely still running. + + A worker handle left set after a crash/early-exit reports ``isRunning() == False``; we + treat that as clearable so a fresh compile is not blocked forever.""" + is_running = getattr(worker, "isRunning", None) + if callable(is_running): + try: + return bool(is_running()) + except Exception: # noqa: BLE001 — a dead C++ object counts as not running + return False + return False + + +class LatexPreviewDialog(QDialog): + """Resizable, non-modal TeX/PDF preview window (see module docstring).""" + + def __init__(self, owner: Any) -> None: + super().__init__(owner) + self._owner = owner + self.setObjectName("latex_preview_dialog") + self.setModal(False) + self.setWindowModality(Qt.WindowModality.NonModal) + self.resize(720, 640) + + layout = QVBoxLayout(self) + self._tabs = QTabWidget() + self._tabs.setObjectName("latex_preview_tabs") + layout.addWidget(self._tabs) + + self._build_tex_tab() + self._build_pdf_tab() + + # Switching TO the PDF tab compiles + renders on demand — users expect the PDF tab + # to show the PDF, not only the 预览 PDF button. Without this, manually clicking the + # PDF tab left the status frozen and no compile ever ran. + self._tabs.currentChanged.connect(self._on_tab_changed) + + # -- TeX tab ------------------------------------------------------------ + def _build_tex_tab(self) -> None: + from app_desktop.latex_highlighter import LatexHighlighter + from app_desktop.numbered_text_edit import NumberedTextEdit + + tab = QWidget() + v = QVBoxLayout(tab) + self._tex_view = NumberedTextEdit() + self._tex_view.setObjectName("latex_preview_tex_view") + self._tex_highlighter = LatexHighlighter(self._tex_view.document()) + v.addWidget(self._tex_view, 1) + + buttons = QHBoxLayout() + buttons.addStretch(1) + self._copy_button = QPushButton(self._tr("复制", "Copy")) + self._copy_button.setObjectName("latex_preview_copy_button") + self._copy_button.clicked.connect(lambda _c=False: self._copy_tex()) + self._save_button = QPushButton(self._tr("保存", "Save")) + self._save_button.setObjectName("latex_preview_save_button") + self._save_button.clicked.connect(lambda _c=False: self._save_tex()) + buttons.addWidget(self._copy_button) + buttons.addWidget(self._save_button) + v.addLayout(buttons) + + self._tex_tab_index = self._tabs.addTab(tab, "TeX") + + def _copy_tex(self) -> None: + QApplication.clipboard().setText(self._tex_view.toPlainText()) + + def _save_tex(self) -> None: + filename, _ = QFileDialog.getSaveFileName( + self, + self._tr("保存 LaTeX 文件", "Save LaTeX File"), + "", + "LaTeX (*.tex);;All Files (*)", + ) + if not filename: + return + try: + Path(filename).write_text(self._tex_view.toPlainText(), encoding="utf-8") + except Exception as exc: # noqa: BLE001 + QMessageBox.critical( + self, self._tr("保存失败", "Save Failed"), str(exc) + ) + + # -- PDF tab ------------------------------------------------------------ + def _build_pdf_tab(self) -> None: + tab = QWidget() + v = QVBoxLayout(tab) + self._pdf_scroll = QScrollArea() + self._pdf_scroll.setObjectName("latex_preview_pdf_scroll") + self._pdf_scroll.setWidgetResizable(True) + self._pdf_container = QWidget() + self._pdf_container_layout = QVBoxLayout(self._pdf_container) + self._pdf_container_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self._pdf_scroll.setWidget(self._pdf_container) + # Neutral initial text — NOT "编译 PDF 中…". The compiling text is set only when a + # compile actually starts (render_pdf); a default of "compiling" made a not-yet- + # compiled PDF tab look permanently stuck (user-reported bug). + self._pdf_status = QLabel(self._tr("尚未编译 PDF", "No PDF compiled yet")) + self._pdf_status.setObjectName("latex_preview_pdf_status") + v.addWidget(self._pdf_status) + v.addWidget(self._pdf_scroll, 1) + self._pdf_tab_index = self._tabs.addTab(tab, "PDF") + + def render_pdf(self) -> None: + """Compile the CURRENT tex via tectonic (ASYNC) and rasterize the result into this + dialog's scroll when the compile finishes. + + ``compile_latex_to_pdf`` runs a background QThread; ``last_pdf_path`` is only valid + in the compile-completion callback, NOT synchronously after the call returns. So we + register a one-shot ``_pdf_ready_callback`` on the owner and let it fire + :meth:`_on_pdf_ready` when the PDF exists. + + There is deliberately NO "fall back to the previous ``last_pdf_path``" path: the tex + was just regenerated on demand, so any earlier PDF is STALE — showing it would render + the old tex's PDF (the exact bug this method must not have). When a fresh compile can + NOT be started or completed, the status reports that instead of showing a stale page. + """ + compile_fn = getattr(self._owner, "compile_latex_to_pdf", None) + if not callable(compile_fn): + self._pdf_status.setText( + self._tr("无法编译 PDF(缺少编译入口)。", "Cannot compile PDF (no compiler).") + ) + return + + # A prior compile that never cleared its worker would otherwise block this one + # forever (compile_latex_to_pdf early-returns on a live worker), leaving the dialog + # stuck on "compiling". If no worker is genuinely running, clear the stale handle. + worker = getattr(self._owner, "_latex_compile_worker", None) + if worker is not None and not _worker_is_running(worker): + self._owner._latex_compile_worker = None + + self._pdf_status.setText(self._tr("编译 PDF 中…", "Compiling PDF…")) + # Fire our renderer when the async compile completes. + self._owner._pdf_ready_callback = self._on_pdf_ready + compile_fn() + # If compile did NOT start a worker (engine missing / user declined / nothing to + # persist), the callback will never fire — clear it and report, but do NOT render a + # stale PDF. + if getattr(self._owner, "_latex_compile_worker", None) is None: + self._owner._pdf_ready_callback = None + if self._pdf_status.text() == self._tr("编译 PDF 中…", "Compiling PDF…"): + self._pdf_status.setText( + self._tr("PDF 编译未开始(引擎不可用或已取消)。", + "PDF compile did not start (engine unavailable or canceled).") + ) + + def _on_pdf_ready(self, pdf_path: Any) -> None: + """Rasterize a freshly-compiled PDF into the dialog's own scroll (dialog-owned dpi).""" + from shared.pdf_preview_raster import convert_pdf_to_images + + path = Path(pdf_path) + if not path.exists(): + self._pdf_status.setText( + self._tr("尚无已编译的 PDF。", "No compiled PDF yet.") + ) + return + try: + images = convert_pdf_to_images(path, dpi=_PREVIEW_DPI) + except Exception as exc: # noqa: BLE001 + self._pdf_status.setText( + self._tr(f"PDF 预览失败: {exc}", f"PDF preview failed: {exc}") + ) + return + self._lay_out_pdf_images(images) + + def _lay_out_pdf_images(self, images: list) -> None: + # Clear previous pages. + for i in reversed(range(self._pdf_container_layout.count())): + item = self._pdf_container_layout.takeAt(i) + w = item.widget() + if w is not None: + w.deleteLater() + if not images: + self._pdf_status.setText(self._tr("暂无 PDF 预览", "No PDF preview")) + return + for pil_image in images: + rgba = pil_image.convert("RGBA") + qimage = QImage( + rgba.tobytes("raw", "RGBA"), + rgba.width, + rgba.height, + QImage.Format.Format_RGBA8888, + ) + label = QLabel() + label.setPixmap(QPixmap.fromImage(qimage)) + self._pdf_container_layout.addWidget(label) + self._pdf_status.setText( + self._tr(f"共 {len(images)} 页", f"{len(images)} page(s)") + ) + + # -- open on a tab ------------------------------------------------------ + def show_tab(self, initial_tab: str) -> None: + """Refresh content and select the requested tab, then show/raise.""" + # TeX view mirrors the current source string (reuse, not reparent). + source = "" + editor = getattr(self._owner, "latex_edit", None) + if editor is not None: + source = editor.toPlainText() + self._tex_view.setPlainText(source) + if initial_tab == "pdf": + # Setting the index fires currentChanged → _on_tab_changed → render_pdf when the + # tab actually changes. If we're already on the PDF tab, the signal won't fire, + # so render explicitly. _render_pdf_once guards against a double compile. + if self._tabs.currentIndex() == self._pdf_tab_index: + self._render_pdf_once() + else: + self._tabs.setCurrentIndex(self._pdf_tab_index) + else: + self._tabs.setCurrentIndex(self._tex_tab_index) + self.show() + self.raise_() + self.activateWindow() + + def _on_tab_changed(self, index: int) -> None: + """When the user switches TO the PDF tab, compile + render on demand.""" + if index == self._pdf_tab_index: + self._render_pdf_once() + + def _render_pdf_once(self) -> None: + """Call render_pdf, guarded against re-entrancy so a tab switch that also triggers + an explicit render (show_tab) does not compile twice.""" + if getattr(self, "_rendering_pdf", False): + return + self._rendering_pdf = True + try: + self.render_pdf() + finally: + self._rendering_pdf = False + + def _tr(self, zh: str, en: str) -> str: + tr = getattr(self._owner, "_tr", None) + return tr(zh, en) if callable(tr) else zh + + +def open_latex_preview_dialog(owner: Any, initial_tab: str = "tex") -> LatexPreviewDialog: + """Create-or-reuse the LaTeX preview dialog on ``owner`` and open it on ``initial_tab``.""" + dialog = getattr(owner, "_latex_preview_dialog", None) + if dialog is None or not isinstance(dialog, LatexPreviewDialog): + dialog = LatexPreviewDialog(owner) + owner._latex_preview_dialog = dialog + dialog.show_tab(initial_tab) + return dialog diff --git a/app_desktop/message_dialogs.py b/app_desktop/message_dialogs.py new file mode 100644 index 00000000..86d788e0 --- /dev/null +++ b/app_desktop/message_dialogs.py @@ -0,0 +1,41 @@ +"""Bounded message dialogs. + +A plain ``QMessageBox`` grows its height with the message text, so a very long body (e.g. a +full LaTeX compile log) can push the OK button past the bottom of the screen, leaving it +unreachable (user-reported). ``show_bounded_critical`` keeps the dialog compact: a short +summary line stays in the main area, and the long detail goes into the built-in, SCROLLABLE +"Show Details" pane — so the buttons never move off-screen no matter how long the detail is. +""" + +from __future__ import annotations + +from PySide6.QtWidgets import QMessageBox, QWidget + +# Bodies longer than this (chars or lines) go into the collapsible/scrollable detail pane. +_MAX_INLINE_CHARS = 400 +_MAX_INLINE_LINES = 8 + + +def _is_long(text: str) -> bool: + return len(text) > _MAX_INLINE_CHARS or text.count("\n") + 1 > _MAX_INLINE_LINES + + +def show_bounded_critical( + parent: QWidget | None, title: str, text: str, *, summary: str | None = None +) -> None: + """Show a critical dialog whose OK button never leaves the screen. + + Short ``text`` renders inline as usual. Long ``text`` is moved to the scrollable + "Show Details" pane, with ``summary`` (or a default) shown inline so the user still gets a + one-line explanation without an unbounded dialog. + """ + box = QMessageBox(parent) + box.setIcon(QMessageBox.Icon.Critical) + box.setWindowTitle(title) + if _is_long(text): + box.setText(summary or title) + box.setDetailedText(text) + else: + box.setText(text) + box.setStandardButtons(QMessageBox.StandardButton.Ok) + box.exec() diff --git a/app_desktop/options_dialogs.py b/app_desktop/options_dialogs.py new file mode 100644 index 00000000..5c2bd94f --- /dev/null +++ b/app_desktop/options_dialogs.py @@ -0,0 +1,86 @@ +"""Toolbar options as resizable QDialog windows (计算 / LaTeX). + +Replaces the inline toggle panels (``workbench_options_panel``) with real, resizable, +non-modal dialog windows — per the 2026-07-05 spec (user chose "真独立窗口"). Each dialog +holds the SAME real option controls (reparented ONCE at build time into the dialog), so: + +* the run pipeline keeps reading ``self.mpmath_precision_spin`` / ``self.latex_group_size_spin`` + etc. — unchanged; the controls just live in the dialog now; +* there are NO hidden state-holders and NO mirror widgets (a hidden real would fail the + reachability sweep, which enumerates every schema-keyed input); +* the reachability sweep reaches each control by OPENING the dialog (a QDialog child is + ``isVisibleTo(window)`` only while the dialog is shown), then the control's parent is the + stable dialog content — no reparent-on-open. + +A QDialog is either open or closed; unlike the abandoned QStackedWidget page, it never +"hides a control on the wrong page". Non-modal so the user can keep interacting with the +main window while the options dialog is open. +""" + +from __future__ import annotations + +from typing import Any + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QDialog, QFrame, QVBoxLayout, QWidget + +__all__ = [ + "OptionsDialog", + "build_options_dialog", + "bind_options_button", + "add_separator", +] + + +def add_separator(layout: QVBoxLayout) -> None: + """Add a thin horizontal separator between option groups in a dialog's layout.""" + line = QFrame() + line.setFrameShape(QFrame.Shape.HLine) + line.setFrameShadow(QFrame.Shadow.Sunken) + layout.addWidget(line) + + +class OptionsDialog(QDialog): + """A resizable, non-modal dialog hosting a single content widget. + + The content widget (built by ``panels.py`` from the real option controls) is added to + the dialog's layout once. The dialog is created hidden; :func:`bind_options_button` + wires a toolbar button to open it. + """ + + def __init__(self, parent: QWidget, object_name: str, content: QWidget) -> None: + super().__init__(parent) + self.setObjectName(object_name) + # Non-modal: keep the main window usable while options are open. + self.setModal(False) + self.setWindowModality(Qt.WindowModality.NonModal) + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + layout.addWidget(content) + self._content = content + + def open_dialog(self) -> None: + """Show the dialog and bring it to the front (idempotent).""" + self.show() + self.raise_() + self.activateWindow() + + +def build_options_dialog( + owner: QWidget, object_name: str, title_zh: str, title_en: str, content: QWidget +) -> OptionsDialog: + """Build an :class:`OptionsDialog` parented to ``owner``, hidden until opened.""" + dialog = OptionsDialog(owner, object_name, content) + dialog.setWindowTitle(title_zh) + register = getattr(owner, "_register_text", None) + if callable(register): + register(dialog, title_zh, title_en, "setWindowTitle") + return dialog + + +def bind_options_button(button: Any, dialog: OptionsDialog) -> None: + """Make ``button`` open ``dialog`` on click (not a toggle — a dialog opens/closes on + its own). The button is NOT checkable: clicking always brings the dialog to front.""" + button.setCheckable(False) + button.clicked.connect(lambda _checked=False: dialog.open_dialog()) diff --git a/app_desktop/panels.py b/app_desktop/panels.py index 9b0d4a38..dd4e0d1d 100644 --- a/app_desktop/panels.py +++ b/app_desktop/panels.py @@ -15,6 +15,7 @@ from PySide6.QtCore import Qt, QObject, QEvent from PySide6.QtGui import QAction, QActionGroup, QKeySequence from PySide6.QtWidgets import ( + QAbstractItemView, QApplication, QCheckBox, QComboBox, @@ -35,6 +36,7 @@ QTableWidgetItem, QTabWidget, QTextBrowser, + QToolButton, QVBoxLayout, QWidget, ) @@ -54,10 +56,12 @@ from app_desktop.result_view_titles import result_view_tab_title, result_view_tooltip from app_desktop.shell_layout import build_workbench_bar, update_workbench_status from app_desktop.theme import ( + CARD_PADDING, CONTROL_SPACING, SECTION_SPACING, config_card_style, data_input_card_style, + input_data_tabs_style, is_dark_theme, result_detail_card_style, result_overview_card_style, @@ -65,6 +69,7 @@ result_tab_pane_style, table_style, workbench_section_card_style, + workbench_title_text_style, ) from app_desktop.workbench_layout import ( build_workbench_main_splitter, @@ -91,7 +96,7 @@ populate_variable_workspace_panel, refresh_variable_workspace_panel, ) -from app_desktop.workbench_visual_contract import CONFIG_RAIL_MIN_WIDTH +from app_desktop.workbench_visual_contract import WORKSPACE_CANVAS_MIN_WIDTH from app_desktop.ui_schema_binder import bind_choices, bind_field from app_desktop.ui_schema_runtime import ( bind_schema_command_button, @@ -125,12 +130,16 @@ _LANG_ZH = "zh" _LANG_EN = "en" _LANG_AUTO = "auto" +# Visible result subtabs, in order. TeX/PDF are intentionally NOT here: the on-demand +# LaTeX preview dialog is their viewer now (opened by the result-panel 生成 TeX button; the +# dialog carries both the TeX and PDF tabs). The latex/pdf widgets are still built — hosted off-screen in +# ``_offscreen_result_views`` — so the dialog, workspace round-trip, and compile paths +# keep reading them; see build_right_panel and DESKTOP_RESULT_VIEWS (which keeps all 5 +# view specs for the off-screen widgets + result_view_titles). _RESULT_VIEW_ORDER = ( "result.numeric", "result.image", "result.log", - "result.latex", - "result.pdf", ) @@ -160,6 +169,32 @@ def _result_control_field(view_key: str, control_key: str) -> FormFieldSpec: _STACK_PAGE_TABLE = 0 _STACK_PAGE_TEXT = 1 + +class _FilePathChecked: + """Compatibility stand-in for the removed 使用数据文件 checkbox. + + The data source is now driven purely by whether a file path is entered (file takes precedence + over manual input). Callers still ask ``use_file_checkbox.isChecked()`` / ``_checked(...)``; this + reports ``True`` iff the linked path edit is non-empty. ``setChecked`` is a no-op (the path is the + source of truth), so workspace-restore's ``setChecked(False)`` doesn't fight it. + """ + + class _NoopSignal: + def connect(self, *_args: object, **_kwargs: object) -> None: + return None + + def __init__(self, path_edit: QLineEdit) -> None: + self._path_edit = path_edit + # Callers wire the (former) checkbox's ``toggled`` signal to mark-dirty; the path edit's + # own textChanged already covers that, so this is a no-op sink. + self.toggled = _FilePathChecked._NoopSignal() + + def isChecked(self) -> bool: + return bool(self._path_edit.text().strip()) + + def setChecked(self, _value: bool) -> None: + return None + _MODE_VIEW_BUILDERS: dict[ModeKey, tuple[str, Callable[[object], QGroupBox]]] = { "extrapolation": ("extrap_box", build_extrapolation_mode_view), "error": ("error_box", build_error_mode_view), @@ -202,6 +237,7 @@ def build_menu(self): menubar = self.menuBar() file_menu = menubar.addMenu("文件") + file_menu.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon)) self._register_text(file_menu, "文件", "File", "setTitle") new_workspace_action = QAction("新建工作区", self) @@ -243,10 +279,12 @@ def build_menu(self): self._register_text(save_workspace_as_action, "工作区另存为…", "Save Workspace As…", "setText") examples_menu = menubar.addMenu("示例") + examples_menu.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogListView)) self._register_text(examples_menu, "示例", "Examples", "setTitle") examples_menu.addAction(open_example_workspace_action) lang_menu = menubar.addMenu("语言") + lang_menu.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation)) self._register_text(lang_menu, "语言", "Language", "setTitle") action_lang_auto = QAction("自动", self) action_lang_auto.triggered.connect(lambda: self._on_language_change(0)) @@ -262,6 +300,7 @@ def build_menu(self): self._register_text(action_lang_en, "English", "English", "setText") theme_menu = menubar.addMenu("主题") + theme_menu.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DesktopIcon)) self._register_text(theme_menu, "主题", "Theme", "setTitle") theme_group = QActionGroup(self) theme_group.setExclusive(True) @@ -276,6 +315,7 @@ def build_menu(self): self._register_text(action, zh, en, "setText") help_menu = menubar.addMenu("帮助") + help_menu.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxQuestion)) self._register_text(help_menu, "帮助", "Help", "setTitle") project_action = QAction("项目主页", self) @@ -340,19 +380,55 @@ def build_ui(self): root_layout.addWidget(self.workbench_status_strip) layout.addWidget(self.workbench_root) - self.left_layout = self.workbench_config_layout - self.left_container = self.workbench_config_content - self._left_scroll = self.workbench_config_rail - + # Two-pane layout: the left config sections merge into the workspace pane, so the + # "left" aliases point at the MERGED (workspace) pane — the new left-pane source of + # truth for sizing/scroll. ``workbench_config_*`` survive only as detached + # compatibility attributes (never a splitter pane). + self.left_layout = self.workbench_workspace_layout + self.left_container = self.workbench_workspace_content + self._left_scroll = self.workbench_workspace_canvas + + # The left workspace column is exactly TWO blocks: [输入数据 tabs] (added by _build_left_panel) + # + [one config card]. The config card wraps the per-mode config in a single QGroupBox, ordered + # mode config FIRST (mode_stack — holds the model selector etc.), then the shared formula input, + # then the shared variable mapping. All three are per-mode stacked widgets that switch together; + # the formula/variable panels self-hide in modes that don't use them (no gap). This replaces the + # old three-separate-blocks layout so users "pick the model first, then see its fields". self._build_left_panel() + self.workbench_config_card = QGroupBox() + self.workbench_config_card.setObjectName("workbench_config_card") + self.workbench_config_card.setProperty("datalab_config_card", True) + _config_card_layout = QVBoxLayout(self.workbench_config_card) + _config_card_layout.setSpacing(CONTROL_SPACING) + # NB: inner margins are set by _style_config_card (10px) below — it is the single source of + # the card padding and also runs on theme change, so we don't set margins here. + + # mode_stack (CurrentPageStack) pins its own height to the active page's sizeHint (no gap / no + # clip, review S3); formula/variable panels build per-mode pages and self-hide when unused. + reparent_widget(_config_card_layout, self.mode_stack, stretch=0) self.workbench_formula_panel = build_formula_workspace_panel(self) - self.workbench_workspace_layout.addWidget(self.workbench_formula_panel) + _config_card_layout.addWidget(self.workbench_formula_panel) populate_formula_workspace_panel(self) self.workbench_variable_panel = build_variable_workspace_panel(self) - self.workbench_workspace_layout.addWidget(self.workbench_variable_panel) - reparent_widget(self.workbench_workspace_layout, self.mode_stack, stretch=1) + _config_card_layout.addWidget(self.workbench_variable_panel) populate_variable_workspace_panel(self) + + self.workbench_workspace_layout.addWidget(self.workbench_config_card) + self.workbench_workspace_layout.addStretch(1) + _style_config_card(self.workbench_config_card, dark=is_dark_theme()) + # ``output_setup_section`` and ``run_section`` are no longer added to the layout — the + # first went empty when options moved to the toolbar dialogs, the second when the + # bottom 开始执行 button was removed (4·4c; run is on the toolbar). Both attributes are + # kept for compatibility but never shown. self._build_right_panel(self.workbench_result_layout) + # Part C/D: always-visible result status strip (footer of the result rail) + + # click-to-open overview popover. Both read the shared result-state source and + # create NEW widgets — they never move the existing overview/footer widgets. + from app_desktop.result_status_strip import build_result_status_strip + from app_desktop.result_overview_popover import install_overview_popover_trigger + + self.workbench_result_layout.addWidget(build_result_status_strip(self)) + install_overview_popover_trigger(self) self._bind_workbench_state_roles() self._bind_workbench_spec_schema_keys() _connect_workbench_formula_editors(self) @@ -441,6 +517,12 @@ def _bind_workbench_state_roles(self) -> None: self.implicit_params_table.setObjectName("implicit_params_table") self.root_unknowns_table.setObjectName("root_unknowns_table") self.input_constants_editor.setObjectName("input_constants_editor") + # Register the constants card title for bilingual switching + give it the same weight as the + # data card's "输入数据" title. + _constants_title = getattr(self.input_constants_editor, "title_label", None) + if _constants_title is not None: + self._register_text(_constants_title, "输入常数", "Constants") + _constants_title.setStyleSheet(workbench_title_text_style()) shared_constants_editor = self.input_constants_editor for editor_name in ( "error_constants_editor", @@ -581,48 +663,46 @@ def _clamp_workbench_splitter_sizes(sizes: list[int], minimums: list[int], total def _refresh_main_splitter_left_min_width(self) -> None: - config_content = getattr(self, "workbench_config_content", None) - config_scroll = getattr(self, "workbench_config_rail", None) - if config_content is not None and config_scroll is not None: - _activate_widget_layouts(config_content) - _refresh_visible_table_min_widths(config_content) - workspace_content = getattr(self, "workbench_workspace_content", None) - if workspace_content is not None: - _activate_widget_layouts(workspace_content) - _refresh_visible_table_min_widths(workspace_content) - _activate_widget_layouts(config_content) - + # Two-pane layout: the merged (workspace) pane IS the left pane. Its minimum width + # is derived from the merged content, NOT the detached config rail. Pane 0 = merged + # workspace, pane 1 = result. + merged_content = getattr(self, "workbench_workspace_content", None) + merged_scroll = getattr(self, "workbench_workspace_canvas", None) + if merged_content is not None and merged_scroll is not None: + _activate_widget_layouts(merged_content) + _refresh_visible_table_min_widths(merged_content) + + # The merged pane holds BOTH input and config, so its floor is the workspace + # canvas minimum (wider than the old config-rail minimum). content_min_width = max( - CONFIG_RAIL_MIN_WIDTH, - config_content.minimumSizeHint().width(), + WORKSPACE_CANVAS_MIN_WIDTH, + merged_content.minimumSizeHint().width(), ) - config_content.setMinimumWidth(content_min_width) - left_min_width = content_min_width + scroll_viewport_overhead(config_scroll) + merged_content.setMinimumWidth(content_min_width) + left_min_width = content_min_width + scroll_viewport_overhead(merged_scroll) self._main_splitter_left_min_width = left_min_width - config_scroll.setMinimumWidth(left_min_width) + merged_scroll.setMinimumWidth(left_min_width) splitter = getattr(self, "_main_splitter", None) - workspace_scroll = getattr(self, "workbench_workspace_canvas", None) result_rail = getattr(self, "workbench_result_rail", None) - if splitter is None or splitter.count() < 3 or workspace_scroll is None or result_rail is None: + if splitter is None or splitter.count() < 2 or result_rail is None: return - center_min_width = max(1, workspace_scroll.minimumWidth()) right_min_width = max(1, result_rail.minimumWidth()) sizes = splitter.sizes() - if not sizes or len(sizes) < 3: - splitter.setSizes([left_min_width, center_min_width, right_min_width]) + if not sizes or len(sizes) < 2: + splitter.setSizes([left_min_width, right_min_width]) return - pane_sizes = sizes[:3] - minimums = [left_min_width, center_min_width, right_min_width] + pane_sizes = sizes[:2] + minimums = [left_min_width, right_min_width] if all(size >= minimum for size, minimum in zip(pane_sizes, minimums, strict=True)): return handle_total = splitter.handleWidth() * max(0, splitter.count() - 1) - total = sum(pane_sizes) or max(0, splitter.width() - handle_total - sum(sizes[3:])) + total = sum(pane_sizes) or max(0, splitter.width() - handle_total - sum(sizes[2:])) clamped = _clamp_workbench_splitter_sizes(pane_sizes, minimums, total) if clamped != pane_sizes: - splitter.setSizes(clamped + sizes[3:]) + splitter.setSizes(clamped + sizes[2:]) return @@ -671,13 +751,10 @@ def _table_required_min_width(table: QTableWidget) -> int: def _config_card_sections(self) -> tuple[QWidget, ...]: + # run_section is no longer a visible card (bottom 开始执行 removed in 4·4c); only the + # input section remains a styled config card in the merged pane. sections: list[QWidget] = [] - for attr in ( - "input_section", - "mode_section", - "output_setup_section", - "run_section", - ): + for attr in ("input_section", "workbench_config_card"): section = getattr(self, attr, None) if isinstance(section, QWidget): sections.append(section) @@ -689,7 +766,7 @@ def _style_config_card(section: QWidget, *, dark: bool | None = None) -> None: section.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) layout = section.layout() if layout is not None: - layout.setContentsMargins(10, 10, 10, 10) + layout.setContentsMargins(*CARD_PADDING) section.setStyleSheet(config_card_style(dark=dark)) section.style().unpolish(section) section.style().polish(section) @@ -722,10 +799,15 @@ def build_left_panel(self): refresh_workbench_config_cards(self) - self.left_layout.addWidget(self.mode_section) + # Two-pane layout: the left config sections live in the MERGED workspace pane + # (``left_layout`` is aliased to the workspace layout in build_ui). Only the input + # section is added here, at the TOP; the per-mode config (formula/mode_stack) is + # added by build_ui right after, and ``output_setup_section`` + ``run_section`` are + # appended at the BOTTOM by build_ui (see _append_left_footer_sections). This yields + # the confirmed order: 输入 (top) → 配置 → 输出设置 → 运行. + # ``mode_section``/``mode_box`` are kept as detached compatibility attributes but are + # NOT added to any pane (the mode selector is on the toolbar). self.left_layout.addWidget(self.input_section) - self.left_layout.addWidget(self.output_setup_section) - self.left_layout.addWidget(self.run_section) # Mode selection self.mode_box = QGroupBox("计算模式") @@ -749,52 +831,60 @@ def build_left_panel(self): "setToolTip", ) self.mode_combo.currentIndexChanged.connect(self._on_mode_change) - mode_layout.addWidget(self.mode_combo) - self.mode_section_layout.addWidget(self.mode_box) - - # Data file - self.file_box = QGroupBox("") + # The mode selector now lives on the workbench toolbar, not in the left-rail + # ``mode_box`` card. Insert the SAME ``mode_combo`` widget into the toolbar's + # reserved slot (``_toolbar_mode_slot``, created in build_workbench_toolbar). + # ``mode_box``/``mode_section`` are kept as detached compatibility attributes. + mode_slot = getattr(self, "_toolbar_mode_slot", None) + if mode_slot is not None: + mode_slot.addWidget(self.mode_combo) + else: # pragma: no cover - toolbar always builds first in build_ui + mode_layout.addWidget(self.mode_combo) + + # Data file — label + path edit + Browse all on ONE row. No 使用数据文件 checkbox: the file + # picker sits directly with the data, and a non-empty path takes PRECEDENCE over the manual + # input below (see _active_input_bundle). + # Plain container (matches the 常数 tab's constants_file_row exactly). A little L/R padding so + # the row isn't flush against the tab edge, and the tab layout adds a gap before the card below. + self.file_box = QWidget() file_layout = QHBoxLayout(self.file_box) + file_layout.setContentsMargins(4, 2, 4, 2) file_layout.setSpacing(6) + self._data_file_label = QLabel(self._tr("数据文件:", "Data file:")) + self._register_text(self._data_file_label, "数据文件:", "Data file:") + file_layout.addWidget(self._data_file_label) self.data_file_edit = QLineEdit() + self._register_text( + self.data_file_edit, + "数据文件路径(可选,填写后忽略下方手动输入)", + "Data file path (optional; overrides manual input below)", + "setPlaceholderText", + ) + self._register_text( + self.data_file_edit, + "数据文件路径(可选)。填写后从该文件读取数据,忽略下方手动输入;留空则使用手动输入。", + "Data file path (optional). When set, data is read from this file and the manual input below is ignored; leave blank to use manual input.", + "setToolTip", + ) file_layout.addWidget(self.data_file_edit) browse_btn = QPushButton("浏览…") browse_btn.clicked.connect(self.browse_data_file) self._register_text(browse_btn, "浏览…", "Browse…") file_layout.addWidget(browse_btn) - self.use_file_hint_btn = QPushButton("?") - self.use_file_hint_btn.setFlat(True) - self.use_file_hint_btn.setFixedWidth(22) - self.use_file_hint_btn.setFocusPolicy(Qt.NoFocus) - self.use_file_hint_btn.setToolTip("") - self.use_file_hint_btn.clicked.connect(self._show_data_file_hint) - self.use_file_hint_btn.hide() - file_layout.addWidget(self.use_file_hint_btn) - # 数据来源切换 - self.use_file_checkbox = QCheckBox("使用数据文件") - self.use_file_checkbox.setChecked(False) - self._register_text(self.use_file_checkbox, "使用数据文件", "Use data file") - self._register_text( - self.use_file_checkbox, - "启用后从文件读取数据;关闭后在左侧输入区手动输入数据。", - "Read data from a file when enabled; otherwise use the manual data input in the left input area.", - "setToolTip", - ) - self.use_file_checkbox.toggled.connect(self._on_data_source_toggle) - source_row = QHBoxLayout() - source_row.setSpacing(6) - source_row.addWidget(self.use_file_checkbox) - source_row.addStretch() - self.input_section_layout.addLayout(source_row) - self.input_section_layout.addWidget(self.file_box) - self.file_box.hide() + # (The data-file "?" help now lives on the data card's toolbar as manual_data_help_btn; the old + # permanently-hidden use_file_hint_btn was removed — /simplify.) + # Compatibility shim: many callers read `use_file_checkbox.isChecked()` / _checked(...) to + # decide file-vs-manual. With the checkbox gone, this shim reports checked==(a file path is + # entered), so every existing caller gets file-precedence with no per-caller change. + self.use_file_checkbox = _FilePathChecked(self.data_file_edit) + self.file_box.show() # Manual data — table editor + text fallback self.manual_box = QGroupBox("") self.manual_box.setProperty("datalab_data_card", True) self.manual_box.setStyleSheet(data_input_card_style(dark=is_dark_theme())) manual_layout = QVBoxLayout(self.manual_box) - manual_layout.setContentsMargins(10, 8, 10, 10) + manual_layout.setContentsMargins(*CARD_PADDING) manual_layout.setSpacing(6) data_header = QHBoxLayout() @@ -866,6 +956,25 @@ def build_left_panel(self): table_toolbar.addWidget(clear_btn) table_toolbar.addWidget(self._data_view_toggle) table_toolbar.addStretch() + # ? help button on the right of the data toolbar (mirrors the constants editor's ?). + self.manual_data_help_btn = QPushButton("?") + self.manual_data_help_btn.setFlat(True) + self.manual_data_help_btn.setFixedWidth(24) + self.manual_data_help_btn.setFocusPolicy(Qt.NoFocus) + self.manual_data_help_btn.setToolTip( + self._tr( + "输入数据:每列一个变量,每行一组数据;也可用上方“数据文件”从文件读取。", + "Data input: one variable per column, one sample per row; or read from a file via 数据文件 above.", + ) + ) + self._register_text( + self.manual_data_help_btn, + "输入数据:每列一个变量,每行一组数据;也可用上方“数据文件”从文件读取。", + "Data input: one variable per column, one sample per row; or read from a file via 数据文件 above.", + "setToolTip", + ) + self.manual_data_help_btn.clicked.connect(self._show_data_file_hint) + table_toolbar.addWidget(self.manual_data_help_btn) manual_layout.addLayout(table_toolbar) # Stacked widget: table view (0) / text view (1) @@ -877,6 +986,9 @@ def build_left_panel(self): _apply_equal_column_stretch(self.manual_table) self.manual_table.setAlternatingRowColors(True) self.manual_table.setStyleSheet(view_helpers.get_table_style()) + # Excel-like block selection + copy: select a rectangular range and Ctrl/Cmd+C copies it + # as TSV (paste handled by the same filter). + self.manual_table.setSelectionMode(QAbstractItemView.SelectionMode.ContiguousSelection) self.manual_table.installEventFilter(_TablePasteFilter(self.manual_table, self)) self.manual_table.itemChanged.connect(lambda *_args: _update_data_summary(self)) manual_table_model = self.manual_table.model() @@ -893,12 +1005,92 @@ def build_left_panel(self): self._data_stack.setCurrentIndex(_STACK_PAGE_TABLE) # table view by default manual_layout.addWidget(self._data_stack) - self.input_section_layout.addWidget(self.manual_box) from app_desktop.constants_editor import ConstantsEditor self.input_constants_editor = ConstantsEditor(min_rows=1, checked=False, numeric_mode="uncertainty") self.input_constants_editor.set_embedded_in_workbench(True) - self.input_section_layout.addWidget(self.input_constants_editor) + + # Merge input data + constants into sheet-like tabs (输入数据 / 常数) to reuse space instead + # of stacking two tables. The 常数 tab is added/removed by mode (see _set_constants_tab_ + # visible) — only constant-using modes (error/custom-fit/implicit) show it. + # Each tab is SELF-CONTAINED: the 输入数据 tab holds its own 使用数据文件 checkbox + file + # picker + table, so the data-file toggle can never bleed into the 常数 tab. + self._data_tab = QWidget() + _data_tab_layout = QVBoxLayout(self._data_tab) + _data_tab_layout.setContentsMargins(0, 6, 0, 0) + _data_tab_layout.setSpacing(10) # gap between the file row and the data card below + _data_tab_layout.addWidget(self.file_box) + _data_tab_layout.addWidget(self.manual_box) + + # 常数 tab: its OWN 使用数据文件 checkbox + file picker (independent from the data tab). + # The backend already supports constants-from-file (workers_core reads constants_file_path); + # only the UI was missing. Manual constants table hides when the file source is on. + self._constants_tab = QWidget() + _const_tab_layout = QVBoxLayout(self._constants_tab) + _const_tab_layout.setContentsMargins(0, 6, 0, 0) + _const_tab_layout.setSpacing(10) # gap between the file row and the constants card below + + # Symmetric with the data tab: no checkbox — a non-empty constants-file path takes precedence + # over the manual constants table below. + self.constants_file_row = QWidget() + _const_file_layout = QHBoxLayout(self.constants_file_row) + _const_file_layout.setContentsMargins(4, 2, 4, 2) + _const_file_layout.setSpacing(6) + _const_file_label = QLabel(self._tr("常数文件:", "Constants file:")) + self._register_text(_const_file_label, "常数文件:", "Constants file:") + _const_file_layout.addWidget(_const_file_label) + self.constants_file_edit = QLineEdit() + self._register_text( + self.constants_file_edit, + "常数文件路径(可选,填写后忽略下方手动输入)", + "Constants file path (optional; overrides manual input below)", + "setPlaceholderText", + ) + self._register_text( + self.constants_file_edit, + "常数文件路径(可选)。填写后从该文件读取常数,忽略下方手动输入;留空则使用手动输入。", + "Constants file path (optional). When set, constants are read from this file and the manual input below is ignored; leave blank to use manual input.", + "setToolTip", + ) + _const_file_layout.addWidget(self.constants_file_edit) + _const_browse = QPushButton("浏览…") + _const_browse.clicked.connect(self.browse_constants_file) + self._register_text(_const_browse, "浏览…", "Browse…") + _const_file_layout.addWidget(_const_browse) + self.constants_file_row.show() + self.use_constants_file_checkbox = _FilePathChecked(self.constants_file_edit) + _const_tab_layout.addWidget(self.constants_file_row) + _const_tab_layout.addWidget(self.input_constants_editor) + + self.input_data_tabs = QTabWidget() + self.input_data_tabs.setObjectName("input_data_tabs") + # documentMode=False so the styled pane border (rounded, from input_data_tabs_style) renders. + self.input_data_tabs.setDocumentMode(False) + self.input_data_tabs.setStyleSheet(input_data_tabs_style(dark=is_dark_theme())) + self.input_data_tabs.addTab(self._data_tab, self._tr("输入数据", "Data input")) + self.input_data_tabs.addTab(self._constants_tab, self._tr("常数", "Constants")) + + # Expand/collapse toggle in the tab bar's top-right corner: expands the input area rightward + # (widening the left pane) to show many data columns, then collapses back to the default width. + # Smooth width animation lives on the window (_toggle_input_area_expanded). + self.input_expand_button = QToolButton() + self.input_expand_button.setObjectName("input_expand_button") + self.input_expand_button.setText("⤢") + self.input_expand_button.setCheckable(True) + self.input_expand_button.setCursor(Qt.PointingHandCursor) + self.input_expand_button.setFocusPolicy(Qt.NoFocus) + self.input_expand_button.setAutoRaise(True) + self.input_expand_button.setToolTip(self._tr("展开输入区(显示更多数据列)", "Expand the input area (show more data columns)")) + self._register_text( + self.input_expand_button, + "展开输入区(显示更多数据列)", + "Expand the input area (show more data columns)", + "setToolTip", + ) + self.input_expand_button.clicked.connect(self._toggle_input_area_expanded) + self.input_data_tabs.setCornerWidget(self.input_expand_button, Qt.TopRightCorner) + + self.input_section_layout.addWidget(self.input_data_tabs) self.error_constants_editor = self.input_constants_editor self.custom_constants_editor = self.input_constants_editor @@ -910,6 +1102,9 @@ def build_left_panel(self): self.mode_stack = CurrentPageStack() self.mode_stack.setObjectName("mode_stack") + # CurrentPageStack pins its own fixed height to the ACTIVE page's sizeHint (see + # current_page_stack.py) — this is what prevents both the hollow gap on short modes and the + # clip on modes whose config grows after layout (review S3). No size-policy override needed. _build_mode_stack_pages(self) # Options @@ -931,18 +1126,20 @@ def build_left_panel(self): except Exception: pass - # Uncertainty digits option (always visible, not tied to LaTeX toggle) + # Uncertainty digits option (always visible, not tied to LaTeX toggle). + # The widget itself is created here so the FormFieldSpec binding + reveal system keep + # working, but it is PLACED in the result panel's display-format row (see build_result_*), + # next to 小数位数/科学计数法, so it can be adjusted post-run with live re-render. Its label + # travels with it; we keep a reference for that placement. self.uncertainty_digits_spin = QSpinBox() self.uncertainty_digits_spin.setRange(1, 12) self.uncertainty_digits_spin.setValue(1) unc_label = QLabel("不确定度位数:") self._register_text(unc_label, "不确定度位数:", "Uncertainty digits:") + self.uncertainty_digits_label = unc_label precision_layout.addWidget(label_precision) precision_layout.addWidget(self.mpmath_precision_spin) - precision_layout.addSpacing(16) - precision_layout.addWidget(unc_label) - precision_layout.addWidget(self.uncertainty_digits_spin) precision_layout.addStretch() options_layout.addLayout(precision_layout) @@ -1030,25 +1227,20 @@ def build_left_panel(self): self.parallel_nested_policy_combo.currentIndexChanged.connect( lambda _index: save_current_parallel_config(self) ) - self.generate_latex_checkbox = QCheckBox("生成 LaTeX 文件") - self.generate_latex_checkbox.setChecked(False) - self.generate_latex_checkbox.toggled.connect(self._toggle_latex_options) - self._register_text(self.generate_latex_checkbox, "生成 LaTeX 文件", "Generate LaTeX") - options_layout.addWidget(self.generate_latex_checkbox) - + # The "生成 LaTeX 文件" checkbox was removed (4·4d): the run never writes tex (tex is + # generated on demand from the result), so it gated nothing. The LaTeX options below are + # now always visible in the LaTeX 选项 dialog. self.latex_options_widget = QWidget() latex_layout = QFormLayout(self.latex_options_widget) + # The LaTeX output PATH field is no longer shown in the options — the path is chosen + # at save-time via the TeX window's Save dialog (Module 1). ``output_file_edit`` is + # kept as a DETACHED widget on ``self`` so the save/persist code paths that reference + # ``self.output_file_edit`` keep working; it is simply not placed in the options UI. self.output_file_edit = QLineEdit() out_btn = QPushButton("选择…") out_btn.clicked.connect(self.browse_output_file) self._register_text(out_btn, "选择…", "Browse…") self.output_browse_button = out_btn - output_row = QHBoxLayout() - output_row.addWidget(self.output_file_edit) - output_row.addWidget(out_btn) - lbl_output = QLabel("LaTeX 输出路径:") - self._register_text(lbl_output, "LaTeX 输出路径:", "LaTeX output path:") - latex_layout.addRow(lbl_output, output_row) self.latex_input_precision_spin = QSpinBox() self.latex_input_precision_spin.setRange(6, 200) self.latex_input_precision_spin.setValue(20) @@ -1107,7 +1299,6 @@ def build_left_panel(self): lbl_nested_policy=lbl_nested_policy, parallel_mode_items=parallel_mode_items, nested_policy_items=nested_policy_items, - lbl_output=lbl_output, prec_label=prec_label, group_size_label=group_size_label, ) @@ -1119,35 +1310,82 @@ def build_left_panel(self): # (window_latex_pdf_mixin.compile_latex_to_pdf) keep working # unchanged — they reference ``self.latex_engine_combo``. - self.output_setup_section_layout.addWidget(options_box) - - self.run_button = QPushButton("开始执行") - self.run_button.setObjectName("run_button") - self.run_button.setProperty("datalab_primary_run_button", True) - self.run_button.setProperty("datalab_run_state", "run") - # Ctrl/⌘+Return is the standard "execute" shortcut; a button shortcut fires - # the click, so it runs or stops depending on the button's current state. - self.run_button.setShortcut(QKeySequence("Ctrl+Return")) - # Register the tooltip for retranslation (not a one-shot setToolTip) so it - # switches with the UI language like the button text. - self._register_text(self.run_button, "开始执行 (Ctrl+Return)", "Run (Ctrl+Return)", "setToolTip") - self._register_text(self.run_button, "开始执行", "Run") - self.run_button.clicked.connect(lambda _checked=False: self.run_calculation()) - self.run_section_layout.addWidget(self.run_button) + # Low-frequency options live in two resizable, non-modal QDialog windows (计算 / + # LaTeX), opened from the toolbar buttons (see app_desktop.options_dialogs; user + # chose "真独立窗口"). The REAL controls are reparented — never recreated — into each + # dialog's content widget, so their schema keys, signal wirings, and parallel-prefs + # persistence (all set up above) survive intact. The run pipeline keeps reading + # ``self.`` unchanged; the controls just live in the dialog now. + from app_desktop.options_dialogs import ( + add_separator, + bind_options_button, + build_options_dialog, + ) + + # Detach the already-built groups from options_layout (a layout/widget has one parent + # layout), then re-add to each dialog's content — reparenting the SAME instances. + options_layout.removeItem(precision_layout) + options_layout.removeItem(parallel_layout) + options_layout.removeWidget(self.latex_options_widget) + options_layout.removeWidget(self.generate_plots_checkbox) + options_layout.removeWidget(self.verbose_checkbox) + + compute_content = QWidget() + compute_content.setObjectName("compute_options_content") + compute_layout = QVBoxLayout(compute_content) + compute_layout.addLayout(precision_layout) + compute_layout.addLayout(parallel_layout) + add_separator(compute_layout) + compute_layout.addWidget(self.generate_plots_checkbox) + compute_layout.addWidget(self.verbose_checkbox) + + latex_content = QWidget() + latex_content.setObjectName("latex_options_content") + latex_content_layout = QVBoxLayout(latex_content) + latex_content_layout.addWidget(self.latex_options_widget) + # The engine selector row is built later (with the off-screen latex widgets); keep a + # handle to this layout so it can be appended into the LaTeX 选项 dialog then. + self._latex_options_content_layout = latex_content_layout + + self.compute_options_dialog = build_options_dialog( + self, "compute_options_dialog", "计算选项", "Compute options", compute_content + ) + self.latex_options_dialog = build_options_dialog( + self, "latex_options_dialog", "LaTeX 选项", "LaTeX options", latex_content + ) + bind_options_button(self.workbench_compute_options_button, self.compute_options_dialog) + # latex_options_dialog is opened from the result-panel 「LaTeX 选项」 button + # (result_latex_options_button), bound in build_right_panel after that button exists. + + # The bottom 开始执行 button was removed (4·4c): it duplicated the toolbar 运行 button. + # Run/stop is driven by the toolbar 运行 / 停止 pair (workbench_run_button / + # workbench_stop_button); Ctrl+Return runs via the toolbar run button (shortcut set in + # workbench_toolbar.py). run_section stays an empty compat widget, not added to the + # layout (like output_setup_section). self._update_model_controls() def build_right_panel(self, layout: QVBoxLayout): + # The overview card is built but NOT added to the visible layout: the toolbar status chip + # is the overview entry point now (user-approved). The widget stays alive off-layout so + # refresh_result_overview's writes to its sub-widgets remain valid (mirrors the 4·4b + # "remove from view, keep widget" pattern), and the popover reads the same result state. + # Parent it to the window and hide it so it is not a leaked top-level widget (CodeRabbit). self.workbench_result_overview_panel = build_result_overview(self) - layout.addWidget(self.workbench_result_overview_panel) + self.workbench_result_overview_panel.setParent(self) + self.workbench_result_overview_panel.hide() + # History is opened from a toolbar 历史 button as a popup now (user request), so the panel + # is NOT added to the result layout — it is parented to the window and hidden until the + # popup hosts it (history_popup.toggle_history_popup reparents the real widget in/out). self.workbench_history_panel = build_history_panel(self) - layout.addWidget(self.workbench_history_panel) + self.workbench_history_panel.setParent(self) + self.workbench_history_panel.hide() self.workbench_result_details_panel = QWidget() self.workbench_result_details_panel.setObjectName("workbench_result_details_panel") self.workbench_result_details_panel.setProperty("datalab_result_detail_card", True) self.workbench_result_details_panel.setStyleSheet(result_detail_card_style(dark=is_dark_theme())) details_layout = QVBoxLayout(self.workbench_result_details_panel) - details_layout.setContentsMargins(10, 8, 10, 10) + details_layout.setContentsMargins(*CARD_PADDING) details_layout.setSpacing(6) self.workbench_result_details_title = QLabel(self._tr("结果详情", "Result details")) self.workbench_result_details_title.setObjectName("workbench_result_details_title") @@ -1181,6 +1419,32 @@ def build_right_panel(self, layout: QVBoxLayout): result_layout = QVBoxLayout(result_widget) result_layout.setContentsMargins(0, 0, 0, 0) result_layout.setSpacing(8) + # On-demand LaTeX buttons: 生成 TeX rebuilds the tex from the current result and opens + # the LaTeX preview window. That dialog carries BOTH a TeX and a PDF tab (switch to the + # PDF tab to preview the compiled PDF), so there is no separate 预览 PDF button here — a + # standalone one duplicated the dialog's PDF tab (user-reported). + latex_button_row = QHBoxLayout() + latex_button_row.setContentsMargins(0, 0, 0, 0) + self.result_generate_tex_button = QPushButton("生成 TeX") + self.result_generate_tex_button.setObjectName("result_generate_tex_button") + self._register_text(self.result_generate_tex_button, "生成 TeX", "Generate TeX") + self.result_generate_tex_button.clicked.connect( + lambda _c=False: self.open_latex_preview("tex") + ) + # LaTeX 选项 opens the (existing) latex_options_dialog — the entry moved here from the + # toolbar (user: 工具栏不需要 latex). The dialog is built later in build_left_panel; + # the button→dialog binding happens there once the dialog exists. + self.result_latex_options_button = QPushButton("LaTeX 选项") + self.result_latex_options_button.setObjectName("result_latex_options_button") + self._register_text(self.result_latex_options_button, "LaTeX 选项", "LaTeX options") + from app_desktop.options_dialogs import bind_options_button + + bind_options_button(self.result_latex_options_button, self.latex_options_dialog) + latex_button_row.addWidget(self.result_generate_tex_button) + latex_button_row.addWidget(self.result_latex_options_button) + latex_button_row.addStretch(1) + result_layout.addLayout(latex_button_row) + self.result_tabs = QTabWidget() self.result_tabs.setObjectName("result_detail_tabs") self.result_tabs.setDocumentMode(True) @@ -1237,6 +1501,16 @@ def build_right_panel(self, layout: QVBoxLayout): self.display_digits_spin.setValue(10) self.display_digits_spin.valueChanged.connect(self._on_display_format_changed) fmt_row.addWidget(self.display_digits_spin) + # Uncertainty digits sits alongside 小数位数/科学计数法 so it can be tuned AFTER a run with a + # live re-render (_format_error/extrapolation_display already read _uncertainty_digits_value + # at render time — connecting valueChanged is all that's needed). The widget was created in + # build_left_panel (keeping its FormFieldSpec binding); it is reparented into this row. + if hasattr(self, "uncertainty_digits_spin"): + fmt_row.addSpacing(8) + if hasattr(self, "uncertainty_digits_label"): + fmt_row.addWidget(self.uncertainty_digits_label) + self.uncertainty_digits_spin.valueChanged.connect(self._on_display_format_changed) + fmt_row.addWidget(self.uncertainty_digits_spin) fmt_row.addStretch() numeric_layout.addLayout(fmt_row) @@ -1446,36 +1720,31 @@ def build_right_panel(self, layout: QVBoxLayout): ) latex_controls_row.addWidget(latex_font_spin) + latex_controls_row.addStretch() + latex_layout.addLayout(latex_controls_row) + + latex_layout.addWidget(self.latex_edit) + + # LaTeX ENGINE selector — placed in the LaTeX 选项 dialog (not this off-screen latex tab) + # so the user can actually see + pick it. First item 自动; then the engines actually + # detected on this machine (populate_latex_engine_combo). lbl_engine = QLabel("LaTeX 引擎:") self._register_text(lbl_engine, "LaTeX 引擎:", "LaTeX engine:") - latex_controls_row.addSpacing(16) - latex_controls_row.addWidget(lbl_engine) self.latex_engine_combo = QComboBox() - # ``tectonic`` is offered alongside the traditional engines because - # it auto-downloads (~30 MB single binary) and resolves missing - # LaTeX packages over the net, so users without a local TeX Live - # install can still produce PDFs out of the box. See - # ``shared.latex_engine`` for the resolution + install pipeline. - self.latex_engine_combo.addItems(["pdflatex", "xelatex", "tectonic"]) - # Tectonic is the default: it auto-installs (~30 MB single binary) - # if missing and resolves LaTeX packages over the net per-document, - # so users without a local TeX Live install still get a working - # PDF on first run. ``pdflatex`` / ``xelatex`` remain available - # for power users with a tuned local TeX install. - self.latex_engine_combo.setCurrentText("tectonic") - latex_controls_row.addWidget(self.latex_engine_combo) + populate_latex_engine_combo(self) engine_btn = QPushButton("选择引擎路径…") engine_btn.clicked.connect(self._prompt_engine_selection) self._register_text(engine_btn, "选择引擎路径…", "Select engine path…") self.latex_engine_path_button = engine_btn - latex_controls_row.addWidget(engine_btn) - latex_controls_row.addStretch() - latex_layout.addLayout(latex_controls_row) - - latex_layout.addWidget(self.latex_edit) - latex_spec = DESKTOP_RESULT_VIEWS["result.latex"] - latex_index = self.result_tabs.addTab(latex_widget, result_view_tab_title(latex_spec.key, _LANG_ZH)) - self.result_tabs.setTabToolTip(latex_index, result_view_tooltip(latex_spec.key, _LANG_ZH)) + _engine_row_widget = QWidget() + _engine_row = QHBoxLayout(_engine_row_widget) + _engine_row.setContentsMargins(0, 0, 0, 0) + _engine_row.addWidget(lbl_engine) + _engine_row.addWidget(self.latex_engine_combo) + _engine_row.addWidget(engine_btn) + _engine_row.addStretch() + if getattr(self, "_latex_options_content_layout", None) is not None: + self._latex_options_content_layout.addWidget(_engine_row_widget) # PDF result view pdf_widget = QWidget() @@ -1524,9 +1793,20 @@ def build_right_panel(self, layout: QVBoxLayout): self.pdf_container_layout.setAlignment(Qt.AlignTop) self.pdf_scroll.setWidget(self.pdf_container) pdf_layout.addWidget(self.pdf_scroll) - pdf_spec = DESKTOP_RESULT_VIEWS["result.pdf"] - pdf_index = self.result_tabs.addTab(pdf_widget, result_view_tab_title(pdf_spec.key, _LANG_ZH)) - self.result_tabs.setTabToolTip(pdf_index, result_view_tooltip(pdf_spec.key, _LANG_ZH)) + + # TeX/PDF are NOT added as tabs (the preview dialog is their viewer). The widgets stay + # alive in an off-screen holder — a hidden child of the details panel — so schema-scan + # /findChildren still see them (schema keys + bindings intact) while nothing shows them + # as a tab. latex_edit is read by the preview dialog + workspace + compile; pdf_* by the + # PDF preview mixin. + self._offscreen_result_views = QWidget(self.workbench_result_details_panel) + self._offscreen_result_views.setObjectName("offscreen_result_views") + _offscreen_layout = QVBoxLayout(self._offscreen_result_views) + _offscreen_layout.setContentsMargins(0, 0, 0, 0) + _offscreen_layout.addWidget(latex_widget) + _offscreen_layout.addWidget(pdf_widget) + self._offscreen_result_views.setVisible(False) + _bind_result_latex_pdf_schema_fields( self, lbl_digits=lbl_digits, @@ -1769,6 +2049,46 @@ def _mark_schema_choices(combo: QComboBox) -> None: combo.setProperty("datalab_schema_choices", True) +# Source labels for detected engines, shown after the engine name in the dropdown. +_ENGINE_SOURCE_LABELS = { + "system": ("系统", "system"), + "bundled": ("捆绑", "bundled"), + "auto-tectonic": ("内置", "bundled"), +} + + +def populate_latex_engine_combo(self) -> None: + """Fill ``latex_engine_combo`` with 自动 + the engines actually detected on this machine. + + Item data is ``"auto"`` for the auto entry, or the engine's absolute PATH for a concrete + pick (the compile mixin uses the path directly). The 自动 label retranslates on language + switch; engine names are proper nouns and stay as-is. Called once at build time (and + again by a refresh if the environment changes).""" + from shared.latex_engine import discover_all_engines + + combo = self.latex_engine_combo + current = combo.currentData() + combo.blockSignals(True) + combo.clear() + + lang_en = bool(getattr(self, "_is_en", lambda: False)()) + combo.addItem("Auto" if lang_en else "自动", "auto") + # NOT registered with _register_combo — that generic sweep would rebuild the whole combo + # from a static list and wipe the dynamic engine rows. Instead _apply_language re-runs + # this function (see _refresh_engine_combo_language) so 自动↔Auto retranslates while the + # detected engine rows are preserved. + + for name, choice in discover_all_engines(): + src_zh, src_en = _ENGINE_SOURCE_LABELS.get(choice.source, (choice.source, choice.source)) + label = f"{name} ({src_en if lang_en else src_zh})" + combo.addItem(label, choice.path) + + if current is not None: + idx = combo.findData(current) + combo.setCurrentIndex(idx if idx >= 0 else 0) + combo.blockSignals(False) + + def _bind_global_options_schema_fields( self, *, @@ -1780,7 +2100,6 @@ def _bind_global_options_schema_fields( lbl_nested_policy: QLabel, parallel_mode_items: list[tuple[str, str, str]], nested_policy_items: list[tuple[str, str, str]], - lbl_output: QLabel, prec_label: QLabel, group_size_label: QLabel, ) -> None: @@ -1853,28 +2172,6 @@ def _bind_global_options_schema_fields( for zh, en, data in nested_policy_items ], ) - generate_latex_field = FormFieldSpec( - key="output.latex.enabled", - widget_kind="checkbox", - label=LocalizedText("生成 LaTeX 文件", "Generate LaTeX"), - tooltip=LocalizedText("启用后将计算结果写入 LaTeX 文件。", "When enabled, write calculation results to a LaTeX file."), - required=False, - ) - output_path_field = FormFieldSpec( - key="output.latex.path", - widget_kind="file", - label=LocalizedText("LaTeX 输出路径:", "LaTeX output path:"), - placeholder=LocalizedText("选择 .tex 输出文件", "Choose a .tex output file"), - tooltip=LocalizedText("LaTeX 结果文件的保存路径。", "Save path for the LaTeX result file."), - required=False, - ) - output_browse_field = FormFieldSpec( - key="output.latex.path", - widget_kind="button", - label=LocalizedText("选择 LaTeX 输出路径", "Choose LaTeX output path"), - tooltip=LocalizedText("选择 LaTeX 输出文件路径。", "Choose the LaTeX output file path."), - required=False, - ) input_digits_field = FormFieldSpec( key="output.latex.input_digits", widget_kind="number", @@ -1933,7 +2230,6 @@ def _bind_global_options_schema_fields( (max_workers_field, lbl_parallel_workers, self.parallel_max_workers_spin), (reserve_cores_field, lbl_parallel_reserve, self.parallel_reserve_cores_spin), (nested_policy_field, lbl_nested_policy, self.parallel_nested_policy_combo), - (output_path_field, lbl_output, self.output_file_edit), (input_digits_field, prec_label, self.latex_input_precision_spin), (group_size_field, group_size_label, self.latex_group_size_spin), ] @@ -1945,7 +2241,6 @@ def _bind_global_options_schema_fields( _mark_schema_choices(combo) for field, widget in [ - (generate_latex_field, self.generate_latex_checkbox), (dcolumn_field, self.dcolumn_checkbox), (caption_enabled_field, self.caption_checkbox), (caption_field, self.caption_edit), @@ -1955,13 +2250,11 @@ def _bind_global_options_schema_fields( bind_field(field=field, widget=widget, lang=lang) register_schema_text_refresh(self, field, widget=widget) - bind_schema_command_button( - self, - self.output_browse_button, - field=output_browse_field, - accessible_name=LocalizedText("选择 LaTeX 输出路径", "Choose LaTeX output path"), - lang=lang, - ) + # The LaTeX output-PATH field + its browse button are no longer part of the options + # UI (the save path is chosen at save-time in the TeX window). ``output_file_edit`` / + # ``output_browse_button`` remain as detached widgets on ``self`` for the save/persist + # code paths, but carry NO schema binding (so they are not enumerated as reachable + # config inputs). def _bind_result_latex_pdf_schema_fields( @@ -2148,7 +2441,8 @@ def _clear_table(self): class _TablePasteFilter(QObject): - """Event filter that intercepts Ctrl/Cmd+V on a QTableWidget to handle CSV paste.""" + """Event filter for a QTableWidget: Ctrl/Cmd+V pastes CSV/TSV, Ctrl/Cmd+C copies the + selected cells as TSV (Excel-compatible).""" def __init__(self, table_widget, window): super().__init__(table_widget) @@ -2158,6 +2452,9 @@ def __init__(self, table_widget, window): def eventFilter(self, obj, event): if event.type() == QEvent.Type.KeyPress: from PySide6.QtGui import QKeySequence + if event.matches(QKeySequence.StandardKey.Copy): + if self._copy_selection(): + return True if event.matches(QKeySequence.StandardKey.Paste): clipboard = QApplication.clipboard() text = clipboard.text() @@ -2167,3 +2464,10 @@ def eventFilter(self, obj, event): _load_text_into_table(self._window, text) return True return super().eventFilter(obj, event) + + def _copy_selection(self) -> bool: + """Copy the selected cell block to the clipboard as TSV so it pastes cleanly into + Excel/Sheets (shared with the constants table via table_copy).""" + from app_desktop.table_copy import _copy_selection_as_tsv + + return _copy_selection_as_tsv(self._table) diff --git a/app_desktop/result_overview_popover.py b/app_desktop/result_overview_popover.py new file mode 100644 index 00000000..094d72e2 --- /dev/null +++ b/app_desktop/result_overview_popover.py @@ -0,0 +1,201 @@ +"""Top-level result-overview popover (Part C). + +A NEW popup window that mirrors the compact overview card. It is a standalone +top-level ``QWidget`` with ``Qt.WindowType.Popup`` (Qt auto-closes it on an +outside click / focus-out), positioned near the overview card. It CREATES its own +labels that READ from the same result-state source (``workbench_results._overview_state`` ++ ``_status_badge``); it never reparents or moves the existing overview widgets — +that is what hid controls before. +""" + +from __future__ import annotations + +from typing import Any + +from PySide6.QtCore import QEvent, QObject, Qt +from PySide6.QtWidgets import QGridLayout, QLabel, QVBoxLayout, QWidget + +from app_desktop.workbench_results import _overview_state, _status_badge + + +class _OverviewCardClickFilter(QObject): + """Opens the overview popover when the overview card is clicked. + + An event filter (not a subclass override) keeps the existing card widget + untouched — no reparenting, no method injection on the card instance. + """ + + def __init__(self, owner: Any) -> None: + super().__init__(owner) + self._owner = owner + + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + if ( + event.type() == QEvent.Type.MouseButtonRelease + and event.button() == Qt.MouseButton.LeftButton + ): + open_result_overview_popover(self._owner) + return False + + +def install_overview_popover_trigger(owner: Any) -> None: + """Install a click filter on the toolbar status chip (the sole overview entry point now + that the left-rail card is removed from the visible layout). Idempotent.""" + chip = getattr(owner, "job_status_label", None) + if chip is None: + return + if getattr(owner, "_result_overview_popover_filter", None) is not None: + return + click_filter = _OverviewCardClickFilter(owner) + chip.installEventFilter(click_filter) + owner._result_overview_popover_filter = click_filter + chip.setCursor(Qt.CursorShape.PointingHandCursor) + + +def _tr(owner: Any, zh: str, en: str) -> str: + tr = getattr(owner, "_tr", None) + return tr(zh, en) if callable(tr) else zh + + +def _method_label(owner: Any) -> str: + for attr in ("method_combo", "mode_combo"): + combo = getattr(owner, attr, None) + if combo is not None: + try: + text = combo.currentText() + except (RuntimeError, AttributeError): + text = "" + if text: + return text + return _tr(owner, "—", "—") + + +def _elapsed_label(owner: Any) -> str: + # No elapsed is currently tracked on the window; surface a neutral placeholder + # rather than fabricate a duration. Reads the attribute if a future path adds + # ``_last_result_elapsed`` so this stays a single source of truth. + elapsed = getattr(owner, "_last_result_elapsed", None) + if isinstance(elapsed, (int, float)) and elapsed >= 0: + return f"{elapsed:.3f} s" + return _tr(owner, "—", "—") + + +def build_result_overview_popover(owner: Any) -> QWidget: + """Create (or refresh) the top-level popover widget and return it.""" + popover = getattr(owner, "_result_overview_popover", None) + if popover is None: + popover = QWidget(owner, Qt.WindowType.Popup) + popover.setObjectName("result_overview_popover") + layout = QVBoxLayout(popover) + layout.setContentsMargins(12, 10, 12, 10) + layout.setSpacing(6) + + title = QLabel() + title.setObjectName("result_overview_popover_title") + layout.addWidget(title) + + grid = QGridLayout() + grid.setHorizontalSpacing(12) + grid.setVerticalSpacing(4) + fields = ( + ("method", "方法", "Method"), + ("value", "结果值", "Value"), + ("uncertainty", "不确定度", "Uncertainty"), + ("elapsed", "用时", "Elapsed"), + ("points", "点数", "Points"), + ) + value_labels: dict[str, QLabel] = {} + for row, (key, zh, en) in enumerate(fields): + name_label = QLabel() + name_label.setObjectName(f"result_overview_popover_{key}_name") + name_label.setProperty("_zh", zh) + name_label.setProperty("_en", en) + value_label = QLabel() + value_label.setObjectName(f"result_overview_popover_{key}_value") + grid.addWidget(name_label, row, 0) + grid.addWidget(value_label, row, 1) + value_labels[key] = value_label + layout.addLayout(grid) + + popover._datalab_title = title + popover._datalab_value_labels = value_labels + owner._result_overview_popover = popover + + _refresh_popover_contents(owner, popover) + return popover + + +def _refresh_popover_contents(owner: Any, popover: QWidget) -> None: + state = _overview_state(owner) + status, status_label = _status_badge(owner, state) + title = popover._datalab_title + title.setText(_tr(owner, "结果概览", "Result overview") + f" · {status_label}") + + # Refresh the bilingual field name labels. + for label in popover.findChildren(QLabel): + zh = label.property("_zh") + en = label.property("_en") + if zh is not None and en is not None: + label.setText(_tr(owner, str(zh), str(en)) + ":") + + rows = state.total_rows if state.kind == "tabular" else 0 + columns = len(state.headers) if state.kind == "tabular" else 0 + values = popover._datalab_value_labels + values["method"].setText(_method_label(owner)) + values["value"].setText(_value_summary(owner, state, status)) + values["uncertainty"].setText(_uncertainty_summary(owner, state)) + values["elapsed"].setText(_elapsed_label(owner)) + # A tabular result always shows its actual row count (including 0). The column-count + # fallback is only for non-tabular states — otherwise an empty 0-row/N-col table + # would misreport N points. + if state.kind == "tabular": + values["points"].setText(str(rows)) + else: + values["points"].setText(_points_fallback(owner, state, columns)) + + +def _value_summary(owner: Any, state: Any, status: str) -> str: + if state.kind == "tabular": + return _tr(owner, f"{state.total_rows} 行表格", f"{state.total_rows}-row table") + if state.has_plot and state.has_text: + return _tr(owner, "图片 + 文本", "Plot + text") + if state.has_plot: + return _tr(owner, "图片", "Plot") + if state.has_text: + return _tr(owner, "文本", "Text") + if status == "running": + return _tr(owner, "计算中", "Running") + if status == "failed": + return _tr(owner, "失败", "Failed") + return _tr(owner, "—", "—") + + +def _uncertainty_summary(owner: Any, state: Any) -> str: + if state.kind == "tabular": + return _tr(owner, f"{len(state.headers)} 列", f"{len(state.headers)} columns") + return _tr(owner, "—", "—") + + +def _points_fallback(owner: Any, state: Any, columns: int) -> str: + if columns: + return str(columns) + return _tr(owner, "0", "0") + + +def open_result_overview_popover(owner: Any) -> QWidget: + """Build/refresh the popover, position it near the overview card, and show it.""" + popover = build_result_overview_popover(owner) + # Anchor to the toolbar status chip (the entry point); fall back to the (off-layout) card. + anchor = getattr(owner, "job_status_label", None) or getattr( + owner, "workbench_result_overview_panel", None + ) + if anchor is not None: + try: + global_pos = anchor.mapToGlobal(anchor.rect().bottomLeft()) + popover.move(global_pos) + except (RuntimeError, AttributeError): + pass + popover.adjustSize() + popover.show() + popover.raise_() + return popover diff --git a/app_desktop/result_status_strip.py b/app_desktop/result_status_strip.py new file mode 100644 index 00000000..c8b943a8 --- /dev/null +++ b/app_desktop/result_status_strip.py @@ -0,0 +1,93 @@ +"""Minimal always-visible result status strip (Part D). + +A small footer strip (status badge + method + elapsed) that is always visible so +the calculation status is judgable even when panels collapse. It is built from NEW +widgets and reads the SAME result-state source as the overview card +(``workbench_results._overview_state`` + ``_status_badge``); it does not move or +reuse the pre-existing shell footer (``workbench_status_strip``) or the overview +card's badge. +""" + +from __future__ import annotations + +from typing import Any + +from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget + +from app_desktop.workbench_results import _overview_state, _status_badge + + +def _tr(owner: Any, zh: str, en: str) -> str: + tr = getattr(owner, "_tr", None) + return tr(zh, en) if callable(tr) else zh + + +def _method_label(owner: Any) -> str: + for attr in ("method_combo", "mode_combo"): + combo = getattr(owner, attr, None) + if combo is not None: + try: + text = combo.currentText() + except (RuntimeError, AttributeError): + text = "" + if text: + return text + return "—" + + +def _elapsed_label(owner: Any) -> str: + elapsed = getattr(owner, "_last_result_elapsed", None) + if isinstance(elapsed, (int, float)) and elapsed >= 0: + return f"{elapsed:.3f} s" + return "—" + + +def build_result_status_strip(owner: Any) -> QWidget: + """Create the strip and stash its labels on ``owner``. Returns the strip.""" + strip = QFrame() + strip.setObjectName("result_status_strip") + layout = QHBoxLayout(strip) + layout.setContentsMargins(8, 2, 8, 2) + layout.setSpacing(10) + + status = QLabel() + status.setObjectName("result_status_strip_status") + status.setProperty("datalab_result_status", "waiting") + method = QLabel() + method.setObjectName("result_status_strip_method") + elapsed = QLabel() + elapsed.setObjectName("result_status_strip_elapsed") + + layout.addWidget(status) + layout.addStretch(1) + layout.addWidget(method) + layout.addWidget(elapsed) + + owner._result_status_strip = strip + owner._result_status_strip_status = status + owner._result_status_strip_method = method + owner._result_status_strip_elapsed = elapsed + + refresh_result_status_strip(owner) + return strip + + +def refresh_result_status_strip(owner: Any) -> None: + """Refresh the strip from the shared result-state source.""" + status_label = getattr(owner, "_result_status_strip_status", None) + if status_label is None: + return + state = _overview_state(owner) + status, label = _status_badge(owner, state) + status_label.setText(label) + status_label.setProperty("datalab_result_status", status) + style = status_label.style() + style.unpolish(status_label) + style.polish(status_label) + + method_label = getattr(owner, "_result_status_strip_method", None) + if method_label is not None: + method_label.setText(_tr(owner, "方法:", "Method: ") + _method_label(owner)) + elapsed_label = getattr(owner, "_result_status_strip_elapsed", None) + if elapsed_label is not None: + elapsed_label.setText(_tr(owner, "用时:", "Elapsed: ") + _elapsed_label(owner)) diff --git a/app_desktop/root_latex_writer.py b/app_desktop/root_latex_writer.py index 89632542..bde71e51 100644 --- a/app_desktop/root_latex_writer.py +++ b/app_desktop/root_latex_writer.py @@ -19,6 +19,7 @@ def write_root_latex( include_dcolumn: bool = False, language: str = "zh", root_units: Mapping[str, str] | None = None, + native_group_width: bool = True, ) -> Path: path = Path(output_path).expanduser() path.parent.mkdir(parents=True, exist_ok=True) @@ -32,6 +33,7 @@ def write_root_latex( include_dcolumn=include_dcolumn, language=language, root_units=root_units, + native_group_width=native_group_width, ), encoding="utf-8", ) diff --git a/app_desktop/shell_layout.py b/app_desktop/shell_layout.py index d8aa3e7f..77010dd6 100644 --- a/app_desktop/shell_layout.py +++ b/app_desktop/shell_layout.py @@ -32,6 +32,13 @@ def update_workbench_status(owner: object) -> None: def set_workbench_job_status(owner: object, *, running: bool) -> None: + # Prefer the rich status chip (5-state word + one-line summary) so this run/stop signal + # doesn't clobber it back to a bare 运行中/就绪. The chip reads the shared result state, + # which already reports "running" during a job. + refresh_chip = getattr(owner, "_refresh_toolbar_status_chip", None) + if callable(refresh_chip): + refresh_chip(running=running) + return job_label = getattr(owner, "job_status_label", None) if job_label is not None: job_label.setText( diff --git a/app_desktop/table_copy.py b/app_desktop/table_copy.py new file mode 100644 index 00000000..43006cdf --- /dev/null +++ b/app_desktop/table_copy.py @@ -0,0 +1,51 @@ +"""Excel-like cell copy for QTableWidgets. + +Selecting a rectangular block and pressing Ctrl/Cmd+C copies it to the clipboard as TSV +(tab-separated columns, newline-separated rows) so it pastes cleanly into Excel/Sheets. This is +copy-only and self-contained (no paste/window coupling), so any table can opt in with one call. +""" + +from __future__ import annotations + +from PySide6.QtCore import QEvent, QObject +from PySide6.QtGui import QKeySequence +from PySide6.QtWidgets import QApplication, QTableWidget + + +class _CellCopyFilter(QObject): + def __init__(self, table: QTableWidget) -> None: + super().__init__(table) + self._table = table + + def eventFilter(self, obj: QObject, event: QEvent) -> bool: + if event.type() == QEvent.Type.KeyPress and event.matches(QKeySequence.StandardKey.Copy): + if _copy_selection_as_tsv(self._table): + return True + return super().eventFilter(obj, event) + + +def _copy_selection_as_tsv(table: QTableWidget) -> bool: + ranges = table.selectedRanges() + if not ranges: + return False + top = min(r.topRow() for r in ranges) + bottom = max(r.bottomRow() for r in ranges) + left = min(r.leftColumn() for r in ranges) + right = max(r.rightColumn() for r in ranges) + lines = [] + for row in range(top, bottom + 1): + cells = [] + for col in range(left, right + 1): + item = table.item(row, col) + cells.append(item.text() if item is not None else "") + lines.append("\t".join(cells)) + QApplication.clipboard().setText("\n".join(lines)) + return True + + +def install_cell_copy(table: QTableWidget) -> None: + """Give ``table`` Excel-like block copy (Ctrl/Cmd+C → TSV). Idempotent per table.""" + if getattr(table, "_datalab_cell_copy_installed", False): + return + table.installEventFilter(_CellCopyFilter(table)) + table._datalab_cell_copy_installed = True diff --git a/app_desktop/theme.py b/app_desktop/theme.py index d65b2896..dcc23ed9 100644 --- a/app_desktop/theme.py +++ b/app_desktop/theme.py @@ -16,9 +16,14 @@ # Inner titled-box content margin (replaces ad-hoc 8,8,8,8). INNER_BOX_MARGIN = SPACE_MD # 8 -# Card content margins (replaces ad-hoc 12,10,12,12). +# Card content margins (legacy — kept as aliases; no card actually used (12,10,12,12)). CARD_MARGIN_H = SPACE_LG # 12 CARD_MARGIN_V = PANEL_MARGIN # 10 +# The ONE canonical card content padding (design review R2/R3). Every titled card content layout +# uses this so the six cards stop each hard-coding a slightly different tuple +# ((10,8,10,10)/(10,8,10,8)/(10,10,10,10)/(8,8,8,8)). Order: (left, top, right, bottom) — a hair +# less on top for the title baseline. +CARD_PADDING = (PANEL_MARGIN, SPACE_MD, PANEL_MARGIN, PANEL_MARGIN) # (10, 8, 10, 10) # Vertical space a *styled* QGroupBox (one whose QSS sets a border) must reserve # above its content so the title band never overlaps the first control. Must be @@ -35,7 +40,15 @@ CONFIG_RAIL_WIDTH = 320 RESULT_RAIL_WIDTH = 380 WORKSPACE_GUTTER = 12 +# --- Radius scale (design review R2) --- +# Two tiers instead of the previous 3/4/5/6/8 drift: cards/panes/tab-panes/status-chips = CARD (8), +# buttons + small controls + preview surfaces = CONTROL (6). (Scrollbar handle 3px and the tutorial +# overlay 10px stay bespoke; embedded constants stays 0px on purpose.) +# REGION_RADIUS is the historical card-radius name still used across the codebase; RADIUS_CARD is +# an alias for it (one value, two names) so the two never drift. REGION_RADIUS = 8 +RADIUS_CARD = REGION_RADIUS +RADIUS_CONTROL = 6 WORKBENCH_FORMULA_PANEL_SINGLE_MAX_HEIGHT = 268 WORKBENCH_FORMULA_PANEL_MULTI_MAX_HEIGHT = 392 WORKBENCH_FORMULA_TITLE_ROW_MAX_HEIGHT = 42 @@ -49,6 +62,30 @@ def is_dark_theme() -> bool: return app.palette().window().color().lightness() < 128 +# --- Semantic color tokens (single source of truth per role) --- +# Each role had 3–4 near-duplicate hexes scattered across the *_style functions (design review P1). +# These collapse them to one value per (role, theme). Style functions resolve through _tok() so a +# role's color is defined exactly once. Values chosen to match the previous dominant hex per role. +_TOKENS: dict[str, tuple[str, str]] = { + # role: (light, dark) + "text_primary": ("#0f172a", "#e5e7eb"), # titles + primary body (was #1f2328/#111827/#111111/#dfe1e5/#f8fafc) + "text_muted": ("#64748b", "#9aa4b2"), # secondary/caption text (was #4b5563/#475569/#57606a/#a5b4c3/#bfc1c5) + "border": ("#d8dee8", "rgba(255, 255, 255, 0.10)"), # card border (was #d0d7de/#cbd5e1/#e5e7eb/.14/.16) + "card_bg": ("#ffffff", "#20242b"), # base card background + "card_bg_muted": ("#f8fafc", "#20242b"), # inset/muted card background + "region_bg": ("#f3f5f7", "#181a1f"), # app/region background + "surface_raised": ("#f8fafc", "#262b34"), # buttons / tab base (was #303746/#2b313a/#2a313c/#222833 dark) + "surface_hover": ("#eef2f7", "#303746"), # button/tab hover +} + + +def _tok(name: str, dark: bool | None = None) -> str: + """Resolve a semantic color token for the active (or forced) theme.""" + dark = is_dark_theme() if dark is None else bool(dark) + light_value, dark_value = _TOKENS[name] + return dark_value if dark else light_value + + def scrollbar_style(*, dark: bool | None = None) -> str: dark = is_dark_theme() if dark is None else bool(dark) handle = "rgba(255, 255, 255, 0.12)" if dark else "rgba(0, 0, 0, 0.12)" @@ -159,9 +196,7 @@ def workbench_title_text_style() -> str: def workbench_muted_text_style(*, dark: bool | None = None) -> str: - dark = is_dark_theme() if dark is None else bool(dark) - color = "#9aa4b2" if dark else "#4b5563" - return f"color: {color};" + return f"color: {_tok('text_muted', dark)};" def workbench_warning_text_style(*, dark: bool | None = None) -> str: @@ -185,26 +220,20 @@ def workbench_message_surface_style( background = "#431407" if dark else "#fff7ed" border = "#9a3412" if dark else "#fed7aa" elif kind == "description": - color = "#9aa4b2" if dark else "#4b5563" - background = "#20242b" if dark else "#f9fafb" - border = "rgba(255, 255, 255, 0.10)" if dark else "#e5e7eb" + color = _tok("text_muted", dark) + background = _tok("card_bg", dark) if dark else "#f9fafb" + border = _tok("border", dark) else: raise ValueError(f"Unknown workbench message surface kind: {kind}") - return f"color: {color}; background: {background}; border: 1px solid {border}; border-radius: 6px; padding: 6px;" + return f"color: {color}; background: {background}; border: 1px solid {border}; border-radius: {RADIUS_CONTROL}px; padding: 6px;" def workbench_section_card_style(*, dark: bool | None = None) -> str: dark = is_dark_theme() if dark is None else bool(dark) - if dark: - card_bg = "#20242b" - border = "rgba(255, 255, 255, 0.10)" - title_fg = "#e5e7eb" - muted_fg = "#a5b4c3" - else: - card_bg = "#ffffff" - border = "#d8dee8" - title_fg = "#0f172a" - muted_fg = "#64748b" + card_bg = _tok("card_bg", dark) + border = _tok("border", dark) + title_fg = _tok("text_primary", dark) + muted_fg = _tok("text_muted", dark) return f""" QGroupBox[datalab_workbench_section_host="true"] {{ border: none; @@ -228,10 +257,10 @@ def workbench_section_card_style(*, dark: bool | None = None) -> str: def formula_preview_surface_style(*, dark: bool | None = None) -> str: dark = is_dark_theme() if dark is None else bool(dark) - background = "#1f2328" if dark else "#ffffff" - color = "#f8fafc" if dark else "#111111" - border = "rgba(255, 255, 255, 0.16)" if dark else "#d0d7de" - return f"background: {background}; color: {color}; border: 1px solid {border}; border-radius: 4px; padding: 12px;" + background = "#1f2328" if dark else _tok("card_bg", dark) + color = _tok("text_primary", dark) + border = _tok("border", dark) + return f"background: {background}; color: {color}; border: 1px solid {border}; border-radius: {RADIUS_CONTROL}px; padding: 12px;" def formula_preview_error_surface_style(*, dark: bool | None = None) -> str: @@ -239,23 +268,23 @@ def formula_preview_error_surface_style(*, dark: bool | None = None) -> str: background = "#431407" if dark else "#fff4f2" color = "#fed7aa" if dark else "#8a1c13" border = "#9a3412" if dark else "#f2b8b5" - return f"background: {background}; color: {color}; border: 1px solid {border}; border-radius: 4px; padding: 8px;" + return f"background: {background}; color: {color}; border: 1px solid {border}; border-radius: {RADIUS_CONTROL}px; padding: 8px;" def formula_preview_source_edit_style(*, dark: bool | None = None) -> str: dark = is_dark_theme() if dark is None else bool(dark) - background = "#1f2328" if dark else "#ffffff" - color = "#f8fafc" if dark else "#111111" - border = "rgba(255, 255, 255, 0.16)" if dark else "#d0d7de" + background = "#1f2328" if dark else _tok("card_bg", dark) + color = _tok("text_primary", dark) + border = _tok("border", dark) return f"background: {background}; color: {color}; border: 1px solid {border};" def formula_inline_preview_style(*, dark: bool | None = None) -> str: dark = is_dark_theme() if dark is None else bool(dark) - background = "#20242b" if dark else "#f8fafc" - color = "#f8fafc" if dark else "#111827" - border = "rgba(255, 255, 255, 0.14)" if dark else "#cbd5e1" - return f"background: {background}; color: {color}; border: 1px solid {border}; border-radius: 6px; padding: 12px;" + background = _tok("card_bg_muted", dark) + color = _tok("text_primary", dark) + border = _tok("border", dark) + return f"background: {background}; color: {color}; border: 1px solid {border}; border-radius: {RADIUS_CONTROL}px; padding: 12px;" def pdf_preview_viewport_style(*, inverted: bool = False) -> str: @@ -280,11 +309,16 @@ def tutorial_overlay_style() -> str: def tutorial_overlay_title_style() -> str: - return "font-size: 16pt; font-weight: 600;" + # The tutorial card is always white — pin the title to dark text (like the body) so it stays + # readable when the OS is in dark mode (otherwise it inherits the near-white default). (G-1.) + return f"font-size: 16pt; font-weight: 600; color: {_tok('text_primary', False)};" def tutorial_overlay_body_style() -> str: - return "font-size: 11pt; color: #333;" + # The tutorial card is ALWAYS white (single-theme by design), so body text must be DARK in both + # themes — use the light-theme primary text, not the theme-following token (which would be the + # near-white dark-theme value on a white card → unreadable). (Codex review R-2.) + return f"font-size: 11pt; color: {_tok('text_primary', False)};" def result_tab_pane_style() -> str: @@ -297,14 +331,9 @@ def result_tab_pane_style() -> str: def config_card_style(*, dark: bool | None = None) -> str: dark = is_dark_theme() if dark is None else bool(dark) - if dark: - panel_bg = "#20242b" - border = "rgba(255, 255, 255, 0.10)" - title_fg = "#e5e7eb" - else: - panel_bg = "#ffffff" - border = "#d8dee8" - title_fg = "#1f2328" + panel_bg = _tok("card_bg", dark) + border = _tok("border", dark) + title_fg = _tok("text_primary", dark) return f""" QWidget[datalab_config_card="true"] {{ background: {panel_bg}; @@ -326,50 +355,19 @@ def config_card_style(*, dark: bool | None = None) -> str: top: 0px; padding: 0px; }} -QWidget[datalab_config_card="true"] QPushButton[datalab_primary_run_button="true"] {{ - min-height: 28px; - padding: 4px 10px; - color: #ffffff; - background: #2563eb; - border: 1px solid #2563eb; - border-radius: 6px; - font-weight: 600; -}} -QWidget[datalab_config_card="true"] QPushButton[datalab_primary_run_button="true"]:hover {{ - background: #1d4ed8; - border-color: #1d4ed8; -}} -QWidget[datalab_config_card="true"] QPushButton[datalab_primary_run_button="true"][datalab_run_state="stop"] {{ - background: #dc2626; - border-color: #dc2626; -}} -QWidget[datalab_config_card="true"] QPushButton[datalab_primary_run_button="true"][datalab_run_state="stop"]:hover {{ - background: #b91c1c; - border-color: #b91c1c; -}} """ def result_detail_card_style(*, dark: bool | None = None) -> str: dark = is_dark_theme() if dark is None else bool(dark) - if dark: - panel_bg = "#20242b" - tab_bg = "#262b34" - tab_hover = "#303746" - selected_bg = "#1f2937" - border = "rgba(255, 255, 255, 0.10)" - title_fg = "#e5e7eb" - muted_fg = "#a5b4c3" - selected_fg = "#f8fafc" - else: - panel_bg = "#ffffff" - tab_bg = "#f6f8fb" - tab_hover = "#eef2f7" - selected_bg = "#ffffff" - border = "#d8dee8" - title_fg = "#0f172a" - muted_fg = "#64748b" - selected_fg = "#0f172a" + panel_bg = _tok("card_bg", dark) + border = _tok("border", dark) + title_fg = _tok("text_primary", dark) + muted_fg = _tok("text_muted", dark) + selected_fg = _tok("text_primary", dark) + tab_bg = _tok("surface_raised", dark) + tab_hover = _tok("surface_hover", dark) + selected_bg = "#1f2937" if dark else "#ffffff" return f""" QWidget#workbench_result_details_panel {{ background: {panel_bg}; @@ -389,7 +387,7 @@ def result_detail_card_style(*, dark: bool | None = None) -> str: }} QTabWidget#result_detail_tabs::pane {{ border: 1px solid {border}; - border-radius: 6px; + border-radius: {RADIUS_CARD}px; background: {panel_bg}; top: -1px; }} @@ -416,15 +414,60 @@ def result_detail_card_style(*, dark: bool | None = None) -> str: """ +def input_data_tabs_style(*, dark: bool | None = None) -> str: + """Rounded, modern styling for the 输入数据 / 常数 sheet tabs (input_data_tabs). Mirrors the + result-detail tab chrome so the input area matches the rest of the workbench.""" + dark = is_dark_theme() if dark is None else bool(dark) + # Resolve every surface through the design tokens so the 输入数据/常数 tab strip matches the + # result-detail tab strip across the splitter (this function's whole point) — the P1 token pass + # missed these four hardcoded dark hexes, so the two mirror-intended tab strips diverged in dark + # mode (audit B5). Mirrors result_detail_card_style's token mapping exactly. + border = _tok("border", dark) + selected_fg = _tok("text_primary", dark) + muted_fg = _tok("text_muted", dark) + panel_bg = _tok("card_bg", dark) + tab_bg = _tok("surface_raised", dark) + tab_hover = _tok("surface_hover", dark) + selected_bg = "#1f2937" if dark else "#ffffff" + return f""" +QTabWidget#input_data_tabs::pane {{ + border: 1px solid {border}; + border-radius: {RADIUS_CARD}px; + background: {panel_bg}; + top: -1px; +}} +QTabWidget#input_data_tabs QTabBar::tab {{ + min-width: 60px; + padding: 6px 14px; + font-size: 13px; + color: {muted_fg}; + background: {tab_bg}; + border: 1px solid {border}; + border-bottom: none; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + margin-right: 2px; +}} +QTabWidget#input_data_tabs QTabBar::tab:selected {{ + color: {selected_fg}; + background: {selected_bg}; + font-weight: 600; +}} +QTabWidget#input_data_tabs QTabBar::tab:hover {{ + background: {tab_hover}; +}} +""" + + def result_overview_card_style(*, dark: bool | None = None) -> str: dark = is_dark_theme() if dark is None else bool(dark) + panel_bg = _tok("card_bg", dark) + border = _tok("border", dark) + title_fg = _tok("text_primary", dark) + body_fg = _tok("text_primary", dark) + muted_fg = _tok("text_muted", dark) + summary_bg = _tok("surface_raised", dark) if dark: - panel_bg = "#20242b" - summary_bg = "#262b34" - border = "rgba(255, 255, 255, 0.10)" - title_fg = "#e5e7eb" - body_fg = "#f8fafc" - muted_fg = "#a5b4c3" waiting_bg = "#334155" waiting_fg = "#cbd5e1" running_bg = "#1e3a8a" @@ -436,12 +479,6 @@ def result_overview_card_style(*, dark: bool | None = None) -> str: complete_bg = "#78350f" complete_fg = "#fde68a" else: - panel_bg = "#ffffff" - summary_bg = "#f8fafc" - border = "#d0d7de" - title_fg = "#0f172a" - body_fg = "#111827" - muted_fg = "#64748b" waiting_bg = "#f1f5f9" waiting_fg = "#475569" running_bg = "#dbeafe" @@ -462,29 +499,35 @@ def result_overview_card_style(*, dark: bool | None = None) -> str: color: {title_fg}; font-weight: 600; }} -QLabel#workbench_result_status_badge {{ - border-radius: 8px; +QLabel#workbench_result_status_badge, +QLabel#result_status_strip_status {{ + border-radius: {RADIUS_CARD}px; font-size: 11px; font-weight: 600; padding: 2px 7px; }} -QLabel#workbench_result_status_badge[datalab_result_status="waiting"] {{ +QLabel#workbench_result_status_badge[datalab_result_status="waiting"], +QLabel#result_status_strip_status[datalab_result_status="waiting"] {{ background: {waiting_bg}; color: {waiting_fg}; }} -QLabel#workbench_result_status_badge[datalab_result_status="running"] {{ +QLabel#workbench_result_status_badge[datalab_result_status="running"], +QLabel#result_status_strip_status[datalab_result_status="running"] {{ background: {running_bg}; color: {running_fg}; }} -QLabel#workbench_result_status_badge[datalab_result_status="ready"] {{ +QLabel#workbench_result_status_badge[datalab_result_status="ready"], +QLabel#result_status_strip_status[datalab_result_status="ready"] {{ background: {ready_bg}; color: {ready_fg}; }} -QLabel#workbench_result_status_badge[datalab_result_status="failed"] {{ +QLabel#workbench_result_status_badge[datalab_result_status="failed"], +QLabel#result_status_strip_status[datalab_result_status="failed"] {{ background: {failed_bg}; color: {failed_fg}; }} -QLabel#workbench_result_status_badge[datalab_result_status="complete"] {{ +QLabel#workbench_result_status_badge[datalab_result_status="complete"], +QLabel#result_status_strip_status[datalab_result_status="complete"] {{ background: {complete_bg}; color: {complete_fg}; }} @@ -497,7 +540,7 @@ def result_overview_card_style(*, dark: bool | None = None) -> str: QWidget#workbench_result_summary_grid {{ background: {summary_bg}; border: 1px solid {border}; - border-radius: 5px; + border-radius: {RADIUS_CONTROL}px; }} QLabel[datalab_result_summary_label="true"] {{ color: {muted_fg}; @@ -512,22 +555,13 @@ def result_overview_card_style(*, dark: bool | None = None) -> str: def data_input_card_style(*, dark: bool | None = None) -> str: dark = is_dark_theme() if dark is None else bool(dark) - if dark: - panel_bg = "#20242b" - button_bg = "#262b34" - button_hover = "#303746" - border = "rgba(255, 255, 255, 0.10)" - title_fg = "#e5e7eb" - muted_fg = "#a5b4c3" - button_fg = "#e5e7eb" - else: - panel_bg = "#ffffff" - button_bg = "#f8fafc" - button_hover = "#eef2f7" - border = "#d8dee8" - title_fg = "#0f172a" - muted_fg = "#64748b" - button_fg = "#1f2328" + panel_bg = _tok("card_bg", dark) + border = _tok("border", dark) + title_fg = _tok("text_primary", dark) + muted_fg = _tok("text_muted", dark) + button_fg = _tok("text_primary", dark) + button_bg = _tok("surface_raised", dark) + button_hover = _tok("surface_hover", dark) return f""" QGroupBox#manual_box {{ background: {panel_bg}; @@ -548,7 +582,7 @@ def data_input_card_style(*, dark: bool | None = None) -> str: color: {button_fg}; background: {button_bg}; border: 1px solid {border}; - border-radius: 5px; + border-radius: {RADIUS_CONTROL}px; }} QPushButton[datalab_data_toolbar_button="true"]:hover {{ background: {button_hover}; @@ -558,24 +592,18 @@ def data_input_card_style(*, dark: bool | None = None) -> str: def variable_panel_style(*, dark: bool | None = None) -> str: dark = is_dark_theme() if dark is None else bool(dark) - if dark: - panel_bg = "#20242b" - card_bg = "#20242b" - button_bg = "#262b34" - button_hover = "#303746" - border = "rgba(255, 255, 255, 0.10)" - title_fg = "#e5e7eb" - muted_fg = "#a5b4c3" - button_fg = "#e5e7eb" - else: - panel_bg = "#f3f5f7" - card_bg = "#ffffff" - button_bg = "#f8fafc" - button_hover = "#eef2f7" - border = "#d8dee8" - title_fg = "#0f172a" - muted_fg = "#64748b" - button_fg = "#1f2328" + border = _tok("border", dark) + title_fg = _tok("text_primary", dark) + muted_fg = _tok("text_muted", dark) + button_fg = _tok("text_primary", dark) + panel_bg = _tok("card_bg", dark) if dark else _tok("region_bg", dark) + button_bg = _tok("surface_raised", dark) + button_hover = _tok("surface_hover", dark) + # Section cards get a complete border like the data/constants cards (a subtle inset bg keeps + # them distinct from the config card they sit in). A full 1px border reads as a clean, complete + # card — preferred over the borderless-inset variant which left the inner table's native frame + # edge exposed (incomplete-looking top border). + section_bg = _tok("surface_raised", dark) if dark else _tok("card_bg_muted", dark) return f""" QWidget#workbench_variable_panel {{ background: {panel_bg}; @@ -585,9 +613,9 @@ def variable_panel_style(*, dark: bool | None = None) -> str: font-weight: 600; }} QFrame[datalab_variable_section_card="true"] {{ - background: {card_bg}; + background: {section_bg}; border: 1px solid {border}; - border-radius: {REGION_RADIUS}px; + border-radius: {RADIUS_CARD}px; }} QFrame[datalab_variable_section_card="true"] QLabel {{ color: {title_fg}; @@ -602,7 +630,7 @@ def variable_panel_style(*, dark: bool | None = None) -> str: color: {button_fg}; background: {button_bg}; border: 1px solid {border}; - border-radius: 5px; + border-radius: {RADIUS_CONTROL}px; }} QPushButton[datalab_variable_toolbar_button="true"]:hover {{ background: {button_hover}; @@ -616,24 +644,17 @@ def constants_editor_style( dark: bool | None = None, ) -> str: dark = is_dark_theme() if dark is None else bool(dark) - if dark: - card_bg = "#20242b" - button_bg = "#262b34" - button_hover = "#303746" - border = "rgba(255, 255, 255, 0.10)" - button_fg = "#e5e7eb" - else: - card_bg = "#f8fafc" - button_bg = "#f8fafc" - button_hover = "#eef2f7" - border = "#d8dee8" - button_fg = "#1f2328" + card_bg = _tok("card_bg_muted", dark) + border = _tok("border", dark) + button_fg = _tok("text_primary", dark) + button_bg = _tok("surface_raised", dark) + button_hover = _tok("surface_hover", dark) if embedded: return f""" QWidget[datalab_constants_card="true"] {{ - background: transparent; - border: none; - border-radius: 0px; + background: {card_bg}; + border: 1px solid {border}; + border-radius: {RADIUS_CARD}px; }} QWidget[datalab_constants_card="true"] QCheckBox {{ font-weight: 600; @@ -644,7 +665,7 @@ def constants_editor_style( color: {button_fg}; background: {button_bg}; border: 1px solid {border}; - border-radius: 5px; + border-radius: {RADIUS_CONTROL}px; }} QWidget[datalab_constants_card="true"] QPushButton:hover {{ background: {button_hover}; @@ -654,7 +675,7 @@ def constants_editor_style( QWidget[datalab_constants_card="true"] {{ background: {card_bg}; border: 1px solid {border}; - border-radius: 6px; + border-radius: {RADIUS_CONTROL}px; }} QWidget[datalab_constants_card="true"] QCheckBox {{ font-weight: 600; @@ -672,7 +693,7 @@ def workbench_toolbar_style(*, dark: bool | None = None) -> str: border = "rgba(255, 255, 255, 0.10)" if dark else "rgba(31, 35, 40, 0.12)" bg = "#20242b" if dark else "#f8fafc" fg = "#e5e7eb" if dark else "#1f2328" - hover = "#2b313a" if dark else "#eef2f7" + hover = _tok("surface_hover", dark) active = "#2563eb" if dark else "#2563eb" return f""" QFrame#workbench_toolbar {{ @@ -687,7 +708,7 @@ def workbench_toolbar_style(*, dark: bool | None = None) -> str: min-height: 34px; padding: 4px 8px; border: 1px solid transparent; - border-radius: 6px; + border-radius: {RADIUS_CONTROL}px; color: {fg}; }} QFrame#workbench_toolbar QToolButton:hover, @@ -705,10 +726,10 @@ def workbench_toolbar_style(*, dark: bool | None = None) -> str: def workbench_region_style(*, dark: bool | None = None) -> str: dark = is_dark_theme() if dark is None else bool(dark) - app_bg = "#181a1f" if dark else "#f3f5f7" - panel_bg = "#20242b" if dark else "#ffffff" - border = "rgba(255, 255, 255, 0.10)" if dark else "#d8dee8" - fg = "#e5e7eb" if dark else "#1f2328" + app_bg = _tok("region_bg", dark) + panel_bg = _tok("card_bg", dark) + border = _tok("border", dark) + fg = _tok("text_primary", dark) return f""" QWidget#workbench_root {{ background: {app_bg}; @@ -763,6 +784,8 @@ def compact_button_style() -> str: def round_icon_button_style() -> str: + # Plain (non-f) QSS string with literal braces elsewhere — keep the radius literal (6 = CONTROL) + # rather than convert the whole block to an f-string just for one value. return """ QPushButton { border-radius: 6px; diff --git a/app_desktop/views/error.py b/app_desktop/views/error.py index 60d24079..bdecf83b 100644 --- a/app_desktop/views/error.py +++ b/app_desktop/views/error.py @@ -4,7 +4,6 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QCheckBox, QComboBox, QFormLayout, QGroupBox, @@ -14,11 +13,9 @@ QPlainTextEdit, QPushButton, QSpinBox, - QVBoxLayout, QWidget, ) -from app_desktop.constants_editor import ConstantsEditor from app_desktop.schema_widgets import make_editor_header from app_desktop.ui_schema_binder import bind_choices, bind_field from app_desktop.ui_schema_runtime import register_schema_text_refresh @@ -89,101 +86,6 @@ def build_error_mode_view(owner: Any) -> QGroupBox: owner.error_constants_editor.table_view.setMinimumHeight(160) owner.error_constants_editor.text_view.setMinimumHeight(160) - owner.error_units_box = QGroupBox(owner._tr("单位标注", "Units")) - owner._register_text(owner.error_units_box, "单位标注", "Units", "setTitle") - units_layout = QVBoxLayout(owner.error_units_box) - units_layout.setContentsMargins(8, 8, 8, 8) - units_layout.setSpacing(6) - - units_header = QHBoxLayout() - owner.error_units_enabled_checkbox = QCheckBox(owner._tr("启用单位标注", "Enable units")) - owner._register_text(owner.error_units_enabled_checkbox, "启用单位标注", "Enable units") - owner.error_units_enabled_checkbox.setProperty("datalab_schema_key", "error.units.enabled") - # Register tooltips/label for retranslation (not one-shot _tr) so they switch - # with the UI language. - owner._register_text( - owner.error_units_enabled_checkbox, - "启用后,运行误差传递时会保存并可选验证输入、常数和输出单位。", - "When enabled, error propagation stores and can validate input, constant, and output units.", - "setToolTip", - ) - units_header.addWidget(owner.error_units_enabled_checkbox) - error_units_mode_label = QLabel(owner._tr("模式:", "Mode:")) - owner._register_text(error_units_mode_label, "模式:", "Mode:") - units_header.addWidget(error_units_mode_label) - owner.error_units_mode_combo = QComboBox() - units_mode_items = [ - ("仅显示", "Display only", "display_only"), - ("验证公式", "Validate expression", "validate_expression"), - ] - for zh, _en, data in units_mode_items: - owner.error_units_mode_combo.addItem(zh, data) - owner._register_combo(owner.error_units_mode_combo, units_mode_items) - owner.error_units_mode_combo.setProperty("datalab_schema_key", "error.units.mode") - owner._register_text( - owner.error_units_mode_combo, - "仅显示只保存/渲染单位;验证公式会在数值计算前检查量纲兼容性。", - "Display only stores/renders units; validate expression checks dimensional compatibility before numeric evaluation.", - "setToolTip", - ) - units_header.addWidget(owner.error_units_mode_combo) - units_header.addStretch() - units_layout.addLayout(units_header) - - owner.error_units_body = QWidget() - units_body_layout = QVBoxLayout(owner.error_units_body) - units_body_layout.setContentsMargins(0, 0, 0, 0) - units_body_layout.setSpacing(6) - owner.error_units_inputs_editor = ConstantsEditor(min_rows=2, checked=True, checkbox_text="") - owner.error_units_inputs_editor.setObjectName("error_units_inputs_editor") - owner.error_units_inputs_editor.set_table_headers(owner._tr("符号", "Symbol"), owner._tr("单位", "Unit")) - owner.error_units_inputs_editor.setToolTip( - owner._tr( - "输入列的单位。符号使用公式中的列名或规范化后的变量名,例如 A 或 distance。", - "Units for input columns. Symbols use formula column names or canonical variable names, such as A or distance.", - ) - ) - owner.error_units_inputs_editor.setProperty("datalab_schema_key", "error.units.inputs") - owner.error_units_constants_editor = ConstantsEditor(min_rows=2, checked=True, checkbox_text="") - owner.error_units_constants_editor.setObjectName("error_units_constants_editor") - owner.error_units_constants_editor.set_table_headers(owner._tr("符号", "Symbol"), owner._tr("单位", "Unit")) - owner.error_units_constants_editor.setToolTip( - owner._tr( - "常数的单位。符号必须与左侧常数表中的常数名一致。", - "Units for constants. Symbols must match names in the left constants table.", - ) - ) - owner.error_units_constants_editor.setProperty("datalab_schema_key", "error.units.constants") - output_row = QHBoxLayout() - output_row.addWidget(QLabel(owner._tr("输出 result 单位:", "Output result unit:"))) - owner.error_units_output_edit = QLineEdit() - owner.error_units_output_edit.setPlaceholderText(owner._tr("例如 m", "e.g. m")) - owner.error_units_output_edit.setProperty("datalab_schema_key", "error.units.outputs.result") - owner.error_units_output_edit.setToolTip( - owner._tr( - "可选。验证模式下,公式结果单位必须与这里填写的 result 单位完全一致。", - "Optional. In validate mode, the formula result unit must exactly match this result unit.", - ) - ) - output_row.addWidget(owner.error_units_output_edit) - units_body_layout.addWidget(QLabel(owner._tr("输入单位:", "Input units:"))) - units_body_layout.addWidget(owner.error_units_inputs_editor) - units_body_layout.addWidget(QLabel(owner._tr("常数单位:", "Constant units:"))) - units_body_layout.addWidget(owner.error_units_constants_editor) - units_body_layout.addLayout(output_row) - units_layout.addWidget(owner.error_units_body) - error_layout.addWidget(owner.error_units_box) - - def _update_error_units_controls() -> None: - enabled = owner.error_units_enabled_checkbox.isChecked() - owner.error_units_mode_combo.setEnabled(enabled) - owner.error_units_body.setVisible(enabled) - owner.error_units_body.setEnabled(enabled) - - owner._update_error_units_controls = _update_error_units_controls - owner.error_units_enabled_checkbox.toggled.connect(lambda *_args: owner._update_error_units_controls()) - owner._update_error_units_controls() - method_row = QHBoxLayout() lbl_err_method = QLabel("方法:") owner._register_text(lbl_err_method, "方法:", "Method:") diff --git a/app_desktop/views/fitting.py b/app_desktop/views/fitting.py index 4774a182..dc4d0417 100644 --- a/app_desktop/views/fitting.py +++ b/app_desktop/views/fitting.py @@ -563,25 +563,6 @@ def build_fitting_mode_view(owner: Any) -> QGroupBox: ) weight_row.addWidget(owner.fit_weighted_checkbox) fit_layout.addLayout(weight_row) - fit_layout.addWidget( - view_helpers.make_display_unit_controls( - owner, - attr_prefix="fit", - schema_prefix="fitting", - input_tooltip_zh="拟合输入列的单位。符号使用变量映射中的数据列名,例如 A。", - input_tooltip_en="Units for fitting input columns. Symbols use data column names from the variable mapping, such as A.", - include_constants=True, - constants_tooltip_zh="拟合常数的单位。符号必须与自定义或隐式常数名一致。", - constants_tooltip_en="Units for fitting constants. Symbols must match custom or implicit constant names.", - include_parameters=True, - parameters_tooltip_zh="拟合参数的单位。符号必须与参数列表中的参数名一致。", - parameters_tooltip_en="Units for fitting parameters. Symbols must match parameter-table names.", - output_label_zh="目标 result 单位:", - output_label_en="Target result unit:", - output_tooltip_zh="可选。用于拟合结果、残差、LaTeX 和图中的单位显示;不改变优化算法。", - output_tooltip_en="Optional. Used for fit results, residuals, LaTeX, and plots; it does not change optimization.", - ) - ) owner.inverse_min_spin.valueChanged.connect(owner._on_model_settings_changed) owner.inverse_max_spin.valueChanged.connect(owner._on_model_settings_changed) diff --git a/app_desktop/views/helpers.py b/app_desktop/views/helpers.py index e4c101dd..c17253ed 100644 --- a/app_desktop/views/helpers.py +++ b/app_desktop/views/helpers.py @@ -5,28 +5,21 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QCheckBox, QFrame, QGroupBox, QHeaderView, - QHBoxLayout, QLabel, - QLineEdit, QPushButton, QSizePolicy, QTableWidget, QVBoxLayout, - QWidget, ) -from app_desktop.constants_editor import ConstantsEditor from app_desktop.formula_preview import open_formula_preview_dialog from app_desktop.theme import ( CARD_MARGIN_H, CARD_MARGIN_V, - INNER_BOX_MARGIN, SPACE_MD, - SPACE_SM, table_style, workbench_section_card_style, ) @@ -107,172 +100,6 @@ def make_small_help_button() -> QPushButton: return button -def _unit_editor( - owner: Any, - *, - schema_key: str, - tooltip_zh: str, - tooltip_en: str, -) -> ConstantsEditor: - editor = ConstantsEditor(min_rows=2, checked=True, checkbox_text="") - register_constant_headers( - owner, - editor.set_table_headers, - zh_headers=("符号", "单位"), - en_headers=("Symbol", "Unit"), - ) - editor.setToolTip(_translate_owner(owner, tooltip_zh, tooltip_en)) - editor.setProperty("datalab_schema_key", schema_key) - owner._register_text(editor, tooltip_zh, tooltip_en, "setToolTip") - apply_equal_column_stretch(editor.table_view) - editor.table_view.setStyleSheet(get_table_style()) - fit_table_height_to_contents(editor.table_view, min_rows=2, max_rows=5) - return editor - - -def make_display_unit_controls( - owner: Any, - *, - attr_prefix: str, - schema_prefix: str, - title_zh: str = "单位标注", - title_en: str = "Units", - input_label_zh: str = "输入单位:", - input_label_en: str = "Input units:", - input_tooltip_zh: str = "输入列的单位。符号使用数据列名或规范化后的变量名。", - input_tooltip_en: str = "Units for input columns. Symbols use data column names or canonical variable names.", - include_constants: bool = False, - constants_label_zh: str = "常数单位:", - constants_label_en: str = "Constant units:", - constants_tooltip_zh: str = "常数的单位。符号必须与常数表中的常数名一致。", - constants_tooltip_en: str = "Units for constants. Symbols must match names in the constants table.", - include_parameters: bool = False, - parameters_label_zh: str = "参数单位:", - parameters_label_en: str = "Parameter units:", - parameters_tooltip_zh: str = "拟合参数的单位。符号必须与参数列表中的参数名一致。", - parameters_tooltip_en: str = "Units for fitting parameters. Symbols must match names in the parameter table.", - output_label_zh: str = "输出 result 单位:", - output_label_en: str = "Output result unit:", - output_tooltip_zh: str = "可选。只用于结果、LaTeX 和图片中的单位显示,不改变数值计算。", - output_tooltip_en: str = "Optional. Used only for result, LaTeX, and plot labels; it does not change numeric computation.", -) -> QGroupBox: - """Create shared display-only unit annotation controls. - - Error propagation still owns its validate-expression UI. Other families use - this display-only control so unit labels stay metadata instead of changing - calculation semantics. - """ - - box = QGroupBox(_translate_owner(owner, title_zh, title_en)) - owner._register_text(box, title_zh, title_en, "setTitle") - layout = QVBoxLayout(box) - layout.setContentsMargins(INNER_BOX_MARGIN, INNER_BOX_MARGIN, INNER_BOX_MARGIN, INNER_BOX_MARGIN) - layout.setSpacing(SPACE_SM) - - checkbox = QCheckBox(_translate_owner(owner, "启用单位标注", "Enable units")) - checkbox.setProperty("datalab_schema_key", f"{schema_prefix}.units.enabled") - checkbox.setToolTip( - _translate_owner( - owner, - "启用后,仅保存并渲染单位标注;不会改变数值计算或执行量纲校验。", - "When enabled, unit annotations are stored and rendered only; numeric computation and dimensional validation are unchanged.", - ) - ) - owner._register_text(checkbox, "启用单位标注", "Enable units") - owner._register_text( - checkbox, - "启用后,仅保存并渲染单位标注;不会改变数值计算或执行量纲校验。", - "When enabled, unit annotations are stored and rendered only; numeric computation and dimensional validation are unchanged.", - "setToolTip", - ) - - header = QHBoxLayout() - header.addWidget(checkbox) - header.addStretch() - layout.addLayout(header) - - body = QWidget() - body_layout = QVBoxLayout(body) - body_layout.setContentsMargins(0, 0, 0, 0) - body_layout.setSpacing(6) - - input_label = QLabel(_translate_owner(owner, input_label_zh, input_label_en)) - owner._register_text(input_label, input_label_zh, input_label_en) - inputs_editor = _unit_editor( - owner, - schema_key=f"{schema_prefix}.units.inputs", - tooltip_zh=input_tooltip_zh, - tooltip_en=input_tooltip_en, - ) - body_layout.addWidget(input_label) - body_layout.addWidget(inputs_editor) - - constants_editor = None - if include_constants: - constants_label = QLabel(_translate_owner(owner, constants_label_zh, constants_label_en)) - owner._register_text(constants_label, constants_label_zh, constants_label_en) - constants_editor = _unit_editor( - owner, - schema_key=f"{schema_prefix}.units.constants", - tooltip_zh=constants_tooltip_zh, - tooltip_en=constants_tooltip_en, - ) - body_layout.addWidget(constants_label) - body_layout.addWidget(constants_editor) - - parameters_editor = None - if include_parameters: - parameters_label = QLabel(_translate_owner(owner, parameters_label_zh, parameters_label_en)) - owner._register_text(parameters_label, parameters_label_zh, parameters_label_en) - parameters_editor = _unit_editor( - owner, - schema_key=f"{schema_prefix}.units.parameters", - tooltip_zh=parameters_tooltip_zh, - tooltip_en=parameters_tooltip_en, - ) - body_layout.addWidget(parameters_label) - body_layout.addWidget(parameters_editor) - - output_row = QHBoxLayout() - output_label = QLabel(_translate_owner(owner, output_label_zh, output_label_en)) - owner._register_text(output_label, output_label_zh, output_label_en) - output_row.addWidget(output_label) - output_edit = QLineEdit() - output_edit.setPlaceholderText(_translate_owner(owner, "例如 m", "e.g. m")) - output_edit.setProperty("datalab_schema_key", f"{schema_prefix}.units.outputs.result") - output_edit.setToolTip(_translate_owner(owner, output_tooltip_zh, output_tooltip_en)) - owner._register_text(output_edit, "例如 m", "e.g. m", "setPlaceholderText") - owner._register_text(output_edit, output_tooltip_zh, output_tooltip_en, "setToolTip") - output_row.addWidget(output_edit) - body_layout.addLayout(output_row) - - layout.addWidget(body) - - setattr(owner, f"{attr_prefix}_units_box", box) - setattr(owner, f"{attr_prefix}_units_enabled_checkbox", checkbox) - setattr(owner, f"{attr_prefix}_units_body", body) - # Name the editors after their owner attribute (mirrors input_constants_editor) so - # the GUI schema scanner recognizes them as expected state owners rather than - # flagging them as unexpected ConstantsEditor instances mounted with no objectName. - inputs_editor.setObjectName(f"{attr_prefix}_units_inputs_editor") - setattr(owner, f"{attr_prefix}_units_inputs_editor", inputs_editor) - if constants_editor is not None: - constants_editor.setObjectName(f"{attr_prefix}_units_constants_editor") - setattr(owner, f"{attr_prefix}_units_constants_editor", constants_editor) - if parameters_editor is not None: - parameters_editor.setObjectName(f"{attr_prefix}_units_parameters_editor") - setattr(owner, f"{attr_prefix}_units_parameters_editor", parameters_editor) - setattr(owner, f"{attr_prefix}_units_output_edit", output_edit) - - def update_controls() -> None: - enabled = checkbox.isChecked() - body.setVisible(enabled) - body.setEnabled(enabled) - - setattr(owner, f"_update_{attr_prefix}_units_controls", update_controls) - checkbox.toggled.connect(lambda *_args: update_controls()) - update_controls() - return box def make_workbench_section_card_view( @@ -449,7 +276,6 @@ def fit_table_height_to_contents(table: QTableWidget, min_rows: int = 1, max_row "fit_table_height_to_contents", "get_table_style", "make_formula_preview_button", - "make_display_unit_controls", "make_small_help_button", "make_workbench_section_card_view", "open_formula_preview", diff --git a/app_desktop/views/root_solving.py b/app_desktop/views/root_solving.py index c16e1371..8d14f813 100644 --- a/app_desktop/views/root_solving.py +++ b/app_desktop/views/root_solving.py @@ -147,22 +147,6 @@ def build_root_solving_mode_view(owner: Any) -> QGroupBox: view_helpers.apply_equal_column_stretch(owner.root_constants_editor.table_view) owner.root_constants_editor.table_view.setStyleSheet(view_helpers.get_table_style()) owner.root_constants_editor.table_view.setMinimumHeight(120) - root_layout.addWidget( - view_helpers.make_display_unit_controls( - owner, - attr_prefix="root", - schema_prefix="root_solving", - input_tooltip_zh="输入数据列的单位。符号使用批处理数据列名,例如 A。", - input_tooltip_en="Units for input data columns. Symbols use batch data column names, such as A.", - include_constants=True, - constants_tooltip_zh="求根常数的单位。符号必须与输入常数名一致。", - constants_tooltip_en="Units for root-solving constants. Symbols must match input constant names.", - output_label_zh="根 result 单位:", - output_label_en="Root result unit:", - output_tooltip_zh="可选。用于根结果、LaTeX 和根图中的单位显示;不改变求解算法。", - output_tooltip_en="Optional. Used for root result, LaTeX, and root plot labels; it does not change solving.", - ) - ) _bind_root_schema_fields(owner, lbl_root_equations, lbl_root_mode, lbl_root_unknowns, root_mode_items) refresh_root_field_help(owner) diff --git a/app_desktop/views/statistics.py b/app_desktop/views/statistics.py index ce704e3d..17bd0f7f 100644 --- a/app_desktop/views/statistics.py +++ b/app_desktop/views/statistics.py @@ -319,19 +319,6 @@ def build_statistics_mode_view(owner: Any) -> QGroupBox: owner.stats_trim_fraction_label = lbl_trim_fraction stats_layout.addRow(lbl_trim_fraction, owner.stats_trim_fraction_edit) card_layout.addLayout(stats_layout) - card_layout.addWidget( - view_helpers.make_display_unit_controls( - owner, - attr_prefix="stats", - schema_prefix="statistics", - input_tooltip_zh="统计输入列的单位。符号使用数值列名,例如 A 或 B。", - input_tooltip_en="Units for statistics input columns. Symbols use value column names, such as A or B.", - output_label_zh="统计 result 单位:", - output_label_en="Statistics result unit:", - output_tooltip_zh="可选。用于统计结果、LaTeX 和图中的单位显示;不改变统计计算。", - output_tooltip_en="Optional. Used for statistics results, LaTeX, and plots; it does not change statistics.", - ) - ) _bind_statistics_schema_fields( owner, diff --git a/app_desktop/window.py b/app_desktop/window.py index 4d22c67d..ec165f6b 100644 --- a/app_desktop/window.py +++ b/app_desktop/window.py @@ -477,7 +477,9 @@ class ExtrapolationWindow( def __init__(self): super().__init__() self.setWindowTitle("DataLab") - self.resize(1280, 760) + # Open wider so the left config/data pane (min ~528px) + the result pane form a + # result-heavy ~1:3 split without cramping either (see build_workbench_main_splitter). + self.resize(1680, 900) self._window_icon = None self._apply_window_icon() # OS light/dark preference, detected cross-platform (Qt colorScheme on @@ -566,7 +568,6 @@ def __init__(self): self._initialize_workspace_tracking() self._init_theme_tracking() self._update_method_state() - self._toggle_latex_options(self.generate_latex_checkbox.isChecked()) self._apply_language(self._system_lang if self._lang_mode == _LANG_AUTO else self._lang_mode) self._update_workspace_window_title() QTimer.singleShot(500, self._update_controller.maybe_show_startup_update_notice) @@ -594,6 +595,66 @@ def _refresh_main_splitter_left_min_width(self) -> None: from . import panels as _panels _panels._refresh_main_splitter_left_min_width(self) + def showEvent(self, event): # type: ignore[no-untyped-def] + super().showEvent(event) + # Apply the default result-heavy ~1:3 pane ratio ONCE, at the real shown width (build-time + # width is stale). The left pane is clamped to its content minimum, so on narrow windows + # the ratio widens toward the left; on wide windows it approaches 1:3. + if getattr(self, "_pane_ratio_applied", False): + return + splitter = getattr(self, "_main_splitter", None) + if splitter is None or splitter.count() < 2: + return + self._pane_ratio_applied = True + total = max(1, splitter.width()) + left_min = splitter.widget(0).minimumWidth() + left = max(left_min, total // 4) + splitter.setSizes([left, total - left]) + + def _toggle_input_area_expanded(self) -> None: + """Expand the input area rightward to show many data columns, or collapse it back — with a + smooth width animation on the main splitter. Toggling always returns to the previous + (collapsed) width, so it never "expands and stays expanded".""" + from PySide6.QtCore import QEasingCurve, QVariantAnimation + + splitter = getattr(self, "_main_splitter", None) + button = getattr(self, "input_expand_button", None) + if splitter is None or splitter.count() < 2: + return + total = max(1, sum(splitter.sizes()[:2])) + expanding = button.isChecked() if button is not None else True + if expanding: + # Remember the current (collapsed) left width to restore on collapse. + self._input_collapsed_left = splitter.sizes()[0] + left_min = splitter.widget(0).minimumWidth() + # Expand to ~72% of the width (leave the result pane usable), never below the min. + target_left = max(left_min, int(total * 0.72)) + else: + # Collapse only ever runs after an expand recorded the pre-expand width; if it somehow + # runs first, fall back to the current width (a no-op) rather than re-deriving a ratio. + target_left = getattr(self, "_input_collapsed_left", None) + if target_left is None: + target_left = splitter.sizes()[0] + target_left = min(target_left, total - splitter.widget(1).minimumWidth()) + if button is not None: + button.setText("⤡" if expanding else "⤢") + + # Stop any in-flight animation first — otherwise a rapid re-click leaves the previous + # animation running and BOTH drive the splitter, so they fight (visible stutter). + previous = getattr(self, "_input_expand_anim", None) + if previous is not None: + previous.stop() + start_left = splitter.sizes()[0] + anim = QVariantAnimation(self) + anim.setDuration(220) + anim.setEasingCurve(QEasingCurve.Type.InOutCubic) + anim.setStartValue(int(start_left)) + anim.setEndValue(int(target_left)) + anim.valueChanged.connect(lambda v: splitter.setSizes([int(v), total - int(v)])) + # Keep a reference so the animation isn't garbage-collected mid-flight. + self._input_expand_anim = anim + anim.start() + def _bind_workbench_spec_schema_keys(self) -> None: from . import panels as _panels _panels._bind_workbench_spec_schema_keys(self) @@ -654,12 +715,16 @@ def _apply_language(self, lang: str): # the default "Run" text even mid-run. Re-run the state-specific setter so # a running (Stop) button keeps its Stop label and state in the new # language, and the shortcut is restored either way. - run_state = self.run_button.property("datalab_run_state") if hasattr(self, "run_button") else None + # The bottom 开始执行 toggle was removed (4·4c); run-state now lives on the + # _datalab_run_state attribute, reflected by the toolbar 运行/停止 pair. Replay + # the state-specific setter so a running (stop) toolbar state survives a language + # switch, and re-install the Ctrl+Return shortcut on the toolbar run button. + run_state = getattr(self, "_datalab_run_state", "run") if run_state == "stop" and hasattr(self, "_set_button_to_stop_mode"): self._set_button_to_stop_mode() - elif run_state == "run" and hasattr(self, "_set_button_to_run_mode"): + elif hasattr(self, "_set_button_to_run_mode"): self._set_button_to_run_mode() - elif hasattr(self, "_reapply_run_button_shortcut"): + if hasattr(self, "_reapply_run_button_shortcut"): self._reapply_run_button_shortcut() if hasattr(self, "_update_constants_visibility"): self._update_constants_visibility() @@ -756,7 +821,6 @@ def _initialize_workspace_tracking(self) -> None: getattr(self, "stats_hypothesis_alpha_edit", None), getattr(self, "stats_time_series_time_column_edit", None), getattr(self, "stats_time_series_ewma_value_edit", None), - getattr(self, "error_units_output_edit", None), getattr(self, "output_file_edit", None), getattr(self, "caption_edit", None), getattr(self, "latex_edit", None), @@ -783,8 +847,6 @@ def _initialize_workspace_tracking(self) -> None: "custom_constants_editor", "implicit_constants_editor", "root_constants_editor", - "error_units_inputs_editor", - "error_units_constants_editor", ): editor = getattr(self, editor_name, None) if editor is None or id(editor) in connected_constant_editors: @@ -798,7 +860,6 @@ def _initialize_workspace_tracking(self) -> None: "method_combo", "levin_variant_combo", "error_method_combo", - "error_units_mode_combo", "stats_workflow_combo", "stats_mode_combo", "stats_bootstrap_target_combo", @@ -822,10 +883,8 @@ def _initialize_workspace_tracking(self) -> None: for check_name in ( "use_file_checkbox", "use_constants_file_checkbox", - "generate_latex_checkbox", "generate_plots_checkbox", "verbose_checkbox", - "error_units_enabled_checkbox", "scientific_checkbox", "dcolumn_checkbox", "caption_checkbox", @@ -868,6 +927,32 @@ def _initialize_workspace_tracking(self) -> None: if spin is not None: spin.valueChanged.connect(self._mark_workspace_dirty) + # File-precedence feedback (Codex/Claude review): entering a data/constants file path makes + # the manual editor below inactive at run time, so reflect that live — grey the data card and + # refresh constants visibility whenever the file path changes. + data_file_edit = getattr(self, "data_file_edit", None) + if data_file_edit is not None: + data_file_edit.textChanged.connect(lambda *_a: self._update_data_source_visibility()) + self._update_data_source_visibility() + constants_file_edit = getattr(self, "constants_file_edit", None) + if constants_file_edit is not None and hasattr(self, "_update_constants_visibility"): + self._constants_file_was_empty = not constants_file_edit.text().strip() + constants_file_edit.textChanged.connect(self._on_constants_file_path_changed) + + def _on_constants_file_path_changed(self, *_args) -> None: + # _update_constants_visibility is a full-panel refresh (QSS reparse, column re-stretch, i18n + # labels) — only the manual-inputs enabled state depends on the path here, and that only + # changes when the path flips between empty and non-empty. Gate to that transition so typing + # a path doesn't re-run the whole refresh per keystroke (efficiency review). + edit = getattr(self, "constants_file_edit", None) + if edit is None: + return + is_empty = not edit.text().strip() + if is_empty == getattr(self, "_constants_file_was_empty", True): + return + self._constants_file_was_empty = is_empty + self._update_constants_visibility() + def _workspace_guard_running(self) -> bool: if self._has_running_worker(): QMessageBox.information( @@ -922,6 +1007,7 @@ def new_workspace(self, _checked: bool = False) -> bool: self.result_plot_label.setText(self._tr("尚无图片", "No image yet")) self._last_result_kind = None self._last_result_payloads = {} + self._last_latex_inputs = {} finally: self._workspace_restoring = False self._update_workspace_window_title() @@ -1073,6 +1159,28 @@ def _open_workspace_from_path(self, path: Path, *, as_template: bool = False) -> self._workspace_snapshot_only = False self._workspace_snapshot_stale = False self._set_snapshot_controls_enabled(True) + if as_template: + # Example workspaces are shipped in "file" data-source mode, whose + # source_path is a RELATIVE path into the bundled examples/ dir. When + # the app happens to run from the repo root that path resolves, so the + # file-precedence rule greys out the manual table + its +列/-列/+行/-行 + # toolbar — the user opens an example and the add/remove buttons are + # dead (user-reported). The example rows are already inlined into the + # table/editor by the restore, so drop the bundled paths and fall back + # to the editable manual inputs. Clearing the path edits re-enables the + # manual data box / constants editor (via _update_data_source_visibility + # and the constants file-path textChanged handler). Applies to BOTH the + # data table and the constants editor (e.g. error-propagation ships its + # constants in file mode too). + data_file_edit = getattr(self, "data_file_edit", None) + if data_file_edit is not None and data_file_edit.text().strip(): + data_file_edit.clear() + self._update_data_source_visibility() + constants_file_edit = getattr(self, "constants_file_edit", None) + if constants_file_edit is not None and constants_file_edit.text().strip(): + constants_file_edit.clear() + if hasattr(self, "_update_constants_visibility"): + self._update_constants_visibility() except Exception as exc: # noqa: BLE001 self._workspace_restoring = False QMessageBox.critical(self, self._tr("打开失败", "Open failed"), str(exc)) @@ -1275,11 +1383,6 @@ def _show_about(self): lang = "en" if self._is_en() else "zh" show_about_dialog(parent=self, lang=lang) - def _toggle_latex_options(self, checked: bool): - self.latex_options_widget.setVisible(checked) - # Sync caption row visibility when LaTeX toggle changes - self._toggle_caption_input(self.caption_checkbox.isChecked() if hasattr(self, "caption_checkbox") else False) - def _toggle_caption_input(self, checked: bool): if hasattr(self, "caption_edit"): self.caption_edit.setVisible(bool(checked)) @@ -1316,15 +1419,15 @@ def _update_error_propagation_controls(self): if hasattr(self, "error_mc_seed_edit"): self.error_mc_seed_edit.setEnabled(is_mc) - def _on_data_source_toggle(self, checked: bool): - if hasattr(self, "file_box"): - self.file_box.setVisible(checked) - if hasattr(self, "manual_box"): - self.manual_box.setVisible(not checked) - if hasattr(self, "use_file_hint_btn"): - hint_text = getattr(self, "_current_example_text", "") or self.manual_data_edit.placeholderText() - self.use_file_hint_btn.setToolTip(hint_text) - self.use_file_hint_btn.setVisible(checked) + def _update_data_source_visibility(self): + """File-precedence feedback (Codex/Claude review): when a data-file path is entered the + manual table below is ignored at run time, so DISABLE it (grey, non-editable) rather than + leave it looking active. Disabling — not hiding — avoids a layout jump while typing.""" + manual_box = getattr(self, "manual_box", None) + file_edit = getattr(self, "data_file_edit", None) + if manual_box is None or file_edit is None: + return + manual_box.setEnabled(not bool(file_edit.text().strip())) def _on_stats_mode_change(self): workflow = ( @@ -2170,11 +2273,40 @@ def _apply_desktop_theme(self) -> None: self.refresh_workbench_result_details_card() if hasattr(self, "refresh_workbench_variable_panel"): self.refresh_workbench_variable_panel() + # Re-apply theme-dependent styles that are otherwise set once at construction, so a live + # light↔dark toggle updates them too (Codex review P2/P3): + # - the formula rendered-preview surface (restyled inside refresh_workbench_formula_panel); + # - the input_data_tabs rounded chrome. + if hasattr(self, "refresh_workbench_formula_panel"): + self.refresh_workbench_formula_panel() + input_tabs = getattr(self, "input_data_tabs", None) + if input_tabs is not None: + from app_desktop.theme import input_data_tabs_style + + input_tabs.setStyleSheet(input_data_tabs_style(dark=new_dark)) + # The constants editor's style (incl. theme-varying button colors) is set once at + # construction/embedding — refresh it too so its buttons follow a live theme toggle + # (same class as the formula-preview/tabs stale-style fix, Claude self-review C-G). + constants_editor = getattr(self, "input_constants_editor", None) + if constants_editor is not None and hasattr(constants_editor, "refresh_theme_style"): + constants_editor.refresh_theme_style() if hasattr(self, "_refresh_main_splitter_left_min_width"): self._refresh_main_splitter_left_min_width() def _update_theme_from_palette(self, *args): - self._apply_desktop_theme() + # This is a Qt slot wired to the app-global ``paletteChanged`` signal, so it must never let + # an exception escape into the event loop. A palette change is purely cosmetic; a stale + # formula-population failure cached from an earlier (unrelated) population attempt must not + # crash a theme restyle. Log and move on — the real population error is surfaced at its own + # call site, not here. + try: + self._apply_desktop_theme() + except Exception: + import logging + + logging.getLogger(__name__).debug( + "Theme restyle from palette change skipped after an error", exc_info=True + ) def _on_mode_change(self): mode = self.mode_combo.currentData() @@ -2207,6 +2339,25 @@ def _on_mode_change(self): self._refresh_main_splitter_left_min_width() self._update_constants_visibility() + def _set_constants_tab_visible(self, visible: bool) -> None: + """Add or remove the 常数 sheet tab from the input-data tabs so it only appears in + constant-using modes. The constants editor widget is reused (added/removed, not + rebuilt), so its state + serialization are untouched.""" + tabs = getattr(self, "input_data_tabs", None) + const_tab = getattr(self, "_constants_tab", None) + if tabs is None or const_tab is None: + return + index = tabs.indexOf(const_tab) + if visible and index == -1: + tabs.addTab(const_tab, self._tr("常数", "Constants")) + elif not visible and index != -1: + tabs.removeTab(index) # removeTab does not delete the widget; state is preserved + data_tab = getattr(self, "_data_tab", None) + if tabs.currentWidget() is not data_tab: + data_index = tabs.indexOf(data_tab) + if data_index != -1: + tabs.setCurrentIndex(data_index) + def _update_constants_visibility(self): if not hasattr(self, "input_constants_editor") or self.input_constants_editor is None: return @@ -2223,7 +2374,11 @@ def _update_constants_visibility(self): and self.use_constants_file_checkbox.isChecked() ) - self.input_constants_editor.setVisible(visible) + # Constants now live in a sheet tab (输入数据 / 常数). The TAB's presence controls + # visibility — do NOT also call editor.setVisible(), which fought the tab hosting and + # made the hidden editor render over the active tab (overlap regression). The 常数 tab + # shows only in constant-using modes; other modes show just 输入数据. + self._set_constants_tab_visible(visible) self.input_constants_editor.set_inputs_visible(inputs_visible) self.input_constants_editor.set_control_labels( add_row=self._tr("+ 行", "+ Row"), @@ -2418,8 +2573,6 @@ def _update_manual_placeholder(self, mode: str | None): self._current_data_help_text = base.strip() placeholder = base + example self.manual_data_edit.setPlaceholderText(placeholder) - if hasattr(self, "use_file_hint_btn"): - self.use_file_hint_btn.setToolTip(base + example) # 根据行数动态调整高度,保证示例完整可见 line_count = placeholder.count("\n") + 1 target_height = max(120, int(line_count * 18 + 40)) @@ -2513,6 +2666,9 @@ def _set_result_text(self, text: str, *, final_result: bool = False): else: self.result_edit.setPlainText(text) text_format = "plain" + # setMarkdown/setPlainText reset the document's default font to the app default, so the + # user's chosen font size would be lost on every new result — re-apply it. + self._reapply_result_font_size() self._last_result_text = text self._last_result_text_format = text_format self._last_result_rendered_text = self.result_edit.toPlainText() @@ -2552,6 +2708,13 @@ def _add_font_control_row(self, parent_layout: QVBoxLayout, editor, label: str): "setToolTip", ) spin.valueChanged.connect(lambda value, target=editor: self._apply_editor_font_size(target, value)) + # Remember which spin drives which editor so the size can be re-applied after content + # is re-rendered (setMarkdown resets the effective font — see _set_result_text). + registry = getattr(self, "_editor_font_spins", None) + if registry is None: + registry = {} + self._editor_font_spins = registry + registry[id(editor)] = (editor, spin) control_layout.addWidget(spin) control_layout.addStretch() parent_layout.addLayout(control_layout) @@ -2560,6 +2723,22 @@ def _apply_editor_font_size(self, editor, size: int): font = editor.font() font.setPointSize(size) editor.setFont(font) + # setMarkdown renders through the DOCUMENT's default font; set that too so the size + # takes effect on already-rendered markdown content, not just future plain text. + doc = editor.document() if hasattr(editor, "document") else None + if doc is not None: + doc.setDefaultFont(font) + + def _reapply_result_font_size(self): + """Re-apply the user's chosen font size to the result editor after its content was + re-rendered (setMarkdown resets the document's default font to the app default).""" + registry = getattr(self, "_editor_font_spins", None) + if not registry: + return + entry = registry.get(id(self.result_edit)) if hasattr(self, "result_edit") else None + if entry is not None: + _editor, spin = entry + self._apply_editor_font_size(self.result_edit, spin.value()) # Display formatting helpers (only affect presentation; core calculations remain at mpmath precision) def _display_digits_limit(self) -> int: @@ -2786,6 +2965,7 @@ def _reset_csv_data( self._last_result_rendered_text = "" self._last_result_kind = None self._last_result_payloads = {} + self._last_latex_inputs = {} self._last_result_semantic_snapshot = None self._last_result_semantic_snapshot_kind = None self.result_plot_bytes = None @@ -2844,9 +3024,50 @@ def refresh_workbench_result_rail(self) -> None: if hasattr(self, "workbench_result_overview"): refresh_result_overview(self) + if hasattr(self, "_result_status_strip_status"): + from app_desktop.result_status_strip import refresh_result_status_strip + + refresh_result_status_strip(self) history_panel = getattr(self, "workbench_history_panel", None) if history_panel is not None: history_panel.refresh() + self._refresh_toolbar_status_chip() + + def _refresh_toolbar_status_chip(self, *, running: bool | None = None) -> None: + """Drive the toolbar status chip from the shared result state: a rich 5-state word + (已就绪/计算中/失败/完成/等待) + a one-line summary (· N 行表格). The chip is the sole + result-overview entry point now that the left-rail card is gone. + + ``running`` forces the running state for the run/stop button-mode path, which fires + before the worker-state source reflects the transition.""" + chip = getattr(self, "job_status_label", None) + if chip is None: + return + from app_desktop.workbench_results import _overview_state, _status_badge + from app_desktop.result_overview_popover import _value_summary + + if running: + chip.setText(self._tr("运行中", "Running")) + return + state = _overview_state(self) + _status, label = _status_badge(self, state) + summary = _value_summary(self, state, _status) + # Drop the summary when it is empty, a dash, or identical to the status word — otherwise + # failed/running states rendered "Failed · Failed" / "Running · Running" (review S5). + show_summary = bool(summary) and summary != "—" and summary != label + chip.setText(f"{label} · {summary}" if show_summary else label) + + def _open_result_overview_from_toolbar(self) -> None: + """Open the (existing) result-overview popover, anchored to the toolbar status chip.""" + from app_desktop.result_overview_popover import open_result_overview_popover + + open_result_overview_popover(self) + + def _toggle_history_popup(self) -> None: + """Open/close the history panel in a toolbar-anchored popup (moved off the result rail).""" + from app_desktop.history_popup import toggle_history_popup + + toggle_history_popup(self) def _export_csv_data(self): if not getattr(self, "_csv_rows", None): @@ -2887,6 +3108,88 @@ def _export_csv_data(self): except Exception as exc: # noqa: BLE001 QMessageBox.critical(self, self._tr("导出失败", "Export failed"), str(exc)) + def remember_latex_inputs(self, kind: str, latex_inputs: dict[str, object]) -> None: + """Stash the RESULT-DATA needed to rebuild LaTeX tex on demand for ``kind``. + + Kept in a SEPARATE store from ``_last_result_payloads`` on purpose: the latter is + splatted into the per-mode display formatter by ``_refresh_display_format`` + (``formatter(**payload)``), which rejects unexpected keys — so tex-rebuild data must + never live there. This store is never splatted; the on-demand tex builder reads + result-data from here and format options live from widgets. + """ + store = getattr(self, "_last_latex_inputs", None) + if not isinstance(store, dict): + store = {} + self._last_latex_inputs = store + store[kind] = latex_inputs + + def generate_latex_for_current_result(self) -> str | None: + """Rebuild the LaTeX tex for the CURRENT result ON DEMAND, routing to the per-mode + builder. Returns the tex path, or None if there is no rebuildable result stashed. + + Dispatch prefers the current result kind (``_last_result_kind``); if that has no + stash it falls back to whichever mode's data IS stashed. The per-mode builders each + read their own ``_last_latex_inputs`` entry + live format widgets. + """ + # stash-key -> builder method name + builders = { + "root_solving": "generate_root_latex_on_demand", + "extrapolation": "generate_extrapolation_latex_on_demand", + "error": "generate_error_latex_on_demand", + "statistics": "generate_statistics_latex_on_demand", + "fit_single": "generate_fitting_latex_on_demand", + "fit_batches": "generate_fitting_batches_latex_on_demand", + "fitting_comparison": "generate_fitting_comparison_latex_on_demand", + } + store = getattr(self, "_last_latex_inputs", {}) or {} + # Map the current result kind to its stash/builder key. Statistics result kinds are + # granular (statistics_single/_batches/_grouped/…) but share the single "statistics" + # builder + stash, so collapse them; other kinds already equal their builder key. + current = getattr(self, "_last_result_kind", None) + if isinstance(current, str) and current.startswith("statistics"): + current = "statistics" + order = [] + if current in builders: + order.append(current) + for key in builders: + if key not in order: + order.append(key) + for key in order: + if key in store: + method = getattr(self, builders[key], None) + if callable(method): + return method() + return None + + def open_latex_preview(self, initial_tab: str = "tex") -> None: + """Rebuild the current result's LaTeX tex on demand, then open the preview window on + the requested tab. If there is no rebuildable result, inform the user instead of + opening an empty window.""" + try: + tex_path = self.generate_latex_for_current_result() + except (ValueError, OSError, RuntimeError) as exc: + # A builder can fail on a bad live format value or a temp-file write error — surface it + # as a dialog rather than letting the exception escape the button slot (CodeRabbit). + QMessageBox.warning( + self, + self._tr("生成 LaTeX 失败", "LaTeX generation failed"), + self._localize_text(str(exc)), + ) + return + if tex_path is None: + QMessageBox.information( + self, + self._tr("暂无结果", "No result"), + self._tr( + "请先运行一次计算,然后再生成 LaTeX。", + "Run a calculation first, then generate LaTeX.", + ), + ) + return + from app_desktop.latex_preview_dialog import open_latex_preview_dialog + + open_latex_preview_dialog(self, initial_tab=initial_tab) + def _remember_last_result(self, kind: str, payload: dict[str, object]): """Cache the most recent result payload so we can reformat without recomputation.""" self._last_result_kind = kind diff --git a/app_desktop/window_data_mixin.py b/app_desktop/window_data_mixin.py index d42719c6..96d3c025 100644 --- a/app_desktop/window_data_mixin.py +++ b/app_desktop/window_data_mixin.py @@ -443,8 +443,11 @@ def _active_input_bundle( manual_content: str | None = None, source_kind: str | None = None, ) -> InputBundle: - _cb = getattr(self, "use_file_checkbox", None) - use_file = _cb.isChecked() if _cb is not None else False + # No checkbox any more: a non-empty data-file path takes PRECEDENCE over the manual input + # (user request — fill the file field and the manual table/text below is ignored). + _file_edit = getattr(self, "data_file_edit", None) + _file_path_text = _file_edit.text().strip() if _file_edit is not None else "" + use_file = bool(_file_path_text) if data_path is not None or manual_content is not None: return self._input_bundle_from_source( data_path=data_path, @@ -456,8 +459,7 @@ def _active_input_bundle( active_manual_content = "" active_source_kind = "manual_table" if use_file: - data_path_text = self.data_file_edit.text().strip() - active_data_path = _safe_resolve_path(data_path_text) if data_path_text else None + active_data_path = _safe_resolve_path(_file_path_text) active_source_kind = "file" else: # Read from table view if active, otherwise from text view diff --git a/app_desktop/window_extrapolation_mixin.py b/app_desktop/window_extrapolation_mixin.py index 671c3d72..1c47cd6c 100644 --- a/app_desktop/window_extrapolation_mixin.py +++ b/app_desktop/window_extrapolation_mixin.py @@ -44,28 +44,6 @@ def _combo_current_data(owner, attr_name: str, default: str) -> str: return default -def _unit_rows_to_map(owner, editor_attr: str, label_zh: str, label_en: str) -> dict[str, str]: - editor = getattr(owner, editor_attr, None) - if editor is None: - return {} - rows_func = getattr(editor, "rows", None) - rows = rows_func() if callable(rows_func) else [] - values: dict[str, str] = {} - for row in rows: - if not isinstance(row, Mapping): - continue - name = str(row.get("name") or "").strip() - unit = str(row.get("value") or "").strip() - if not name and not unit: - continue - if not name or not unit: - raise ValueError(owner._tr(f"{label_zh}的符号和单位都需要填写。", f"{label_en} requires both symbol and unit.")) - if name in values: - raise ValueError(owner._tr(f"{label_zh}重复:{name}", f"Duplicate {label_en}: {name}")) - values[name] = unit - return values - - def _error_output_unit(units: object) -> str: if not isinstance(units, Mapping): return "" @@ -119,38 +97,41 @@ def _has_running_worker(self) -> bool: ) def _reapply_run_button_shortcut(self): - """Re-apply the run button's execute shortcut. + """Re-apply the toolbar run button's execute shortcut. - QPushButton.setText() clears an explicitly-set shortcut in PySide6, so - every retranslation or run/stop text swap silently drops Ctrl/⌘+Return - (it only survived when the new text equalled the old). Re-apply it after - any text change to keep the shortcut installed in every language. + QPushButton.setText() clears an explicitly-set shortcut in PySide6, so any + retranslation could silently drop Ctrl/⌘+Return. Re-apply it after any text + change to keep the shortcut installed in every language. The bottom 开始执行 + button was removed (4·4c) — Ctrl+Return now runs via the toolbar 运行 button. """ - button = getattr(self, "run_button", None) + button = getattr(self, "workbench_run_button", None) if button is not None: from PySide6.QtGui import QKeySequence button.setShortcut(QKeySequence("Ctrl+Return")) def _set_button_to_stop_mode(self): - """Change the run button to stop mode (red color, stop text).""" - if hasattr(self, "run_button"): - self.run_button.setText(self._tr("停止", "Stop")) - self._reapply_run_button_shortcut() - self.run_button.setStyleSheet("") - self.run_button.setProperty("datalab_run_state", "stop") - self.run_button.style().unpolish(self.run_button) - self.run_button.style().polish(self.run_button) + """Reflect a running job on the toolbar: disable 运行, enable 停止. + + The bottom 开始执行 toggle was removed (4·4c); the toolbar's dedicated 运行 / + 停止 pair + the job-status label carry run-state feedback now.""" + self._datalab_run_state = "stop" + run_button = getattr(self, "workbench_run_button", None) + if run_button is not None: + run_button.setEnabled(False) + stop_button = getattr(self, "workbench_stop_button", None) + if stop_button is not None: + stop_button.setEnabled(True) def _set_button_to_run_mode(self): - """Restore the run button to normal run mode.""" - if hasattr(self, "run_button"): - self.run_button.setText(self._tr("开始执行", "Run")) - self._reapply_run_button_shortcut() - self.run_button.setStyleSheet("") - self.run_button.setProperty("datalab_run_state", "run") - self.run_button.style().unpolish(self.run_button) - self.run_button.style().polish(self.run_button) + """Restore the idle toolbar state: enable 运行, disable 停止.""" + self._datalab_run_state = "run" + run_button = getattr(self, "workbench_run_button", None) + if run_button is not None: + run_button.setEnabled(True) + stop_button = getattr(self, "workbench_stop_button", None) + if stop_button is not None: + stop_button.setEnabled(False) def _stop_current_worker(self): """Request all running workers to stop.""" @@ -191,18 +172,10 @@ def run_calculation(self): return data_path, manual_content = input_bundle.data_path, input_bundle.data_text if mode == "root_solving": - generate_latex = self.generate_latex_checkbox.isChecked() - output_path = "" - if generate_latex: - output_path_text = self.output_file_edit.text().strip() - if not output_path_text: - QMessageBox.critical( - self, - self._tr("错误", "Error"), - self._tr("请在「选项」中设置 LaTeX 输出路径。", "Please set LaTeX output path in Options."), - ) - return - output_path = str(_safe_resolve_path(output_path_text)) + # On-demand LaTeX: the run does not write tex (it stashes the rebuild data); + # the user generates tex on demand via 生成 TeX. + generate_latex = False + output_path = self.latex_output_path_for_run(generate_latex) self._run_root_solving_mode( data_path=data_path, manual_content=manual_content, @@ -239,35 +212,19 @@ def run_calculation(self): ) return - generate_latex = self.generate_latex_checkbox.isChecked() + # On-demand LaTeX: the run no longer writes tex — it only computes and stashes the + # tex-rebuild data (ungated). The user generates tex on demand via 生成 TeX. So we + # never gate the run on a checkbox. + generate_latex = False generate_plots = self.generate_plots_checkbox.isChecked() if hasattr(self, "generate_plots_checkbox") else True try: caption = self._caption_value(require=generate_latex) except ValueError as exc: QMessageBox.critical(self, self._tr("错误", "Error"), self._localize_text(str(exc))) return - output_path_text = self.output_file_edit.text().strip() - if generate_latex: - if not output_path_text: - QMessageBox.critical( - self, - self._tr("错误", "Error"), - self._tr("请在「选项」中设置 LaTeX 输出路径。", "Please set LaTeX output path in Options."), - ) - return - output_candidate = _safe_resolve_path(output_path_text) - if not output_candidate.parent.exists(): - msg_zh = f"输出目录不存在: {output_candidate.parent}" - msg_en = f"Output directory does not exist: {output_candidate.parent}" - QMessageBox.critical( - self, - self._tr("错误", "Error"), - self._tr(msg_zh, msg_en), - ) - return - output_path = str(output_candidate) - else: - output_path = "" + # The tex is written to a per-run temp path (no user output-path field); the user + # saves to a chosen location later via the TeX window. + output_path = self.latex_output_path_for_run(generate_latex) use_dcolumn = self.dcolumn_checkbox.isChecked() verbose = self.verbose_checkbox.isChecked() @@ -542,6 +499,17 @@ def _on_calc_finished(self, result: CalcResult): plot_bytes_list=plot_bytes, render_plots=render_plots, ) + # Stash tex-rebuild DATA (incl. table_segments, which the display path drops) + # so 生成 TeX can rebuild on demand from live format widgets, no recompute. + self.remember_latex_inputs( + "extrapolation", + { + "headers": headers, + "data_rows": data_rows, + "results": results, + "table_segments": result.payload.get("table_segments"), + }, + ) elif result.mode == "error": headers = result.payload.get("headers", []) parsed = result.payload.get("parsed_data", []) @@ -560,6 +528,21 @@ def _on_calc_finished(self, result: CalcResult): propagation=result.payload.get("propagation"), units=result.payload.get("units"), ) + # Stash tex-rebuild DATA (table_segments/constants/used_columns are dropped + # or local-only in the display path) so 生成 TeX rebuilds on demand. + self.remember_latex_inputs( + "error", + { + "headers": headers, + "parsed_data": parsed, + "results": results, + "constants": result.payload.get("constants") or {}, + "used_columns": result.payload.get("used_columns"), + "table_segments": result.payload.get("table_segments"), + "formula": formula, + "units": result.payload.get("units"), + }, + ) breakdown = result.payload.get("contribution_breakdown") plot_bytes = result.payload.get("contribution_plot") row_plots = result.payload.get("row_contribution_plots") @@ -701,6 +684,12 @@ def _on_root_solving_finished(self, payload: dict[str, object]): self._reset_csv_data() self._write_root_latex_if_requested(payload) self._remember_last_result("root_solving", dict(payload)) + # Stash the tex-rebuild DATA (raw_rows + units) so 生成 TeX can rebuild on demand + # without recomputing. Format options are read live from widgets at generate time. + self.remember_latex_inputs( + "root_solving", + {"raw_rows": payload.get("raw_rows"), "units": payload.get("units")}, + ) QMessageBox.information( self, self._tr("完成", "Done"), @@ -736,6 +725,146 @@ def _write_root_latex_if_requested(self, payload: dict[str, object]) -> None: except Exception as exc: # noqa: BLE001 QMessageBox.warning(self, self._tr("写入失败", "Write Failed"), str(exc)) + def generate_root_latex_on_demand(self) -> str | None: + """Rebuild the root-solving LaTeX tex ON DEMAND from the stashed result-data + + LIVE format-option widgets — no recompute, no run-time intent flags. + + Reads ``raw_rows``/``units`` from ``self._last_latex_inputs['root_solving']`` and the + format options (caption/digits/uncertainty/group_size/dcolumn/language) from the + current widget values, then writes tex to a per-run temp path and returns it (or + ``None`` if there is no stashed root result to rebuild from). + """ + store = getattr(self, "_last_latex_inputs", {}) or {} + latex_inputs = store.get("root_solving") + if not isinstance(latex_inputs, dict): + return None + raw_rows = latex_inputs.get("raw_rows") + if not isinstance(raw_rows, list): + return None + from .root_latex_writer import write_root_latex + + output_path = self.latex_output_path_for_run(True, reuse=True) + caption = self._caption_value() if hasattr(self, "_caption_value") else "" + tex_path = write_root_latex( + output_path=output_path, + rows=raw_rows, + caption=caption, + digits=self.latex_input_precision_spin.value() + if hasattr(self, "latex_input_precision_spin") + else 16, + uncertainty_digits=self._uncertainty_digits_value(), + group_size=self.latex_group_size_spin.value() + if hasattr(self, "latex_group_size_spin") + else 3, + include_dcolumn=self.dcolumn_checkbox.isChecked() + if hasattr(self, "dcolumn_checkbox") + else False, + language="en" if self._is_en() else "zh", + root_units=_root_units_for_rows(raw_rows, latex_inputs.get("units")), + native_group_width=self._engine_supports_group_width() + if hasattr(self, "_engine_supports_group_width") + else True, + ) + self._load_latex_into_editor(tex_path) + return str(tex_path) + + def generate_extrapolation_latex_on_demand(self) -> str | None: + """Rebuild the extrapolation LaTeX tex ON DEMAND from the stashed result-data + (headers/data_rows/results/table_segments) + LIVE format widgets — no recompute.""" + store = getattr(self, "_last_latex_inputs", {}) or {} + latex_inputs = store.get("extrapolation") + if not isinstance(latex_inputs, dict): + return None + headers = latex_inputs.get("headers") + data_rows = latex_inputs.get("data_rows") + results = latex_inputs.get("results") + if headers is None or data_rows is None or results is None: + return None + from datalab_latex.latex_tables_extrapolation import generate_latex_table + + output_path = self.latex_output_path_for_run(True, reuse=True) + caption = self._caption_value() if hasattr(self, "_caption_value") else None + generate_latex_table( + headers, + data_rows, + results, + output_path, + caption=caption, + precision=self.latex_input_precision_spin.value() + if hasattr(self, "latex_input_precision_spin") + else None, + verbose=self.verbose_checkbox.isChecked() + if hasattr(self, "verbose_checkbox") + else False, + use_dcolumn=self.dcolumn_checkbox.isChecked() + if hasattr(self, "dcolumn_checkbox") + else False, + table_segments=latex_inputs.get("table_segments"), + result_uncertainty_digits=self._uncertainty_digits_value(), + latex_group_size=self.latex_group_size_spin.value() + if hasattr(self, "latex_group_size_spin") + else 3, + native_group_width=self._engine_supports_group_width() + if hasattr(self, "_engine_supports_group_width") + else True, + ) + self._load_latex_into_editor(output_path) + return str(output_path) + + def generate_error_latex_on_demand(self) -> str | None: + """Rebuild the error-propagation LaTeX tex ON DEMAND from the stashed result-data + (headers/parsed_data/results/constants/used_columns/table_segments/formula/units) + + LIVE format widgets — no recompute.""" + store = getattr(self, "_last_latex_inputs", {}) or {} + latex_inputs = store.get("error") + if not isinstance(latex_inputs, dict): + return None + headers = latex_inputs.get("headers") + parsed_data = latex_inputs.get("parsed_data") + results = latex_inputs.get("results") + if headers is None or parsed_data is None or results is None: + return None + from datalab_latex.latex_tables_error_propagation import ( + generate_error_propagation_table, + ) + + from .workers_core import _input_units_for_headers, _result_unit_from_units + + units_payload = latex_inputs.get("units") + output_path = self.latex_output_path_for_run(True, reuse=True) + caption = self._caption_value() if hasattr(self, "_caption_value") else None + generate_error_propagation_table( + headers, + parsed_data, + results, + latex_inputs.get("constants") or {}, + str(latex_inputs.get("formula") or ""), + output_path, + caption=caption, + verbose=self.verbose_checkbox.isChecked() + if hasattr(self, "verbose_checkbox") + else False, + use_dcolumn=self.dcolumn_checkbox.isChecked() + if hasattr(self, "dcolumn_checkbox") + else False, + table_segments=latex_inputs.get("table_segments"), + precision=self.latex_input_precision_spin.value() + if hasattr(self, "latex_input_precision_spin") + else None, + result_uncertainty_digits=self._uncertainty_digits_value(), + used_columns=latex_inputs.get("used_columns"), + latex_group_size=self.latex_group_size_spin.value() + if hasattr(self, "latex_group_size_spin") + else 3, + input_units=_input_units_for_headers(headers, units_payload), + result_unit=_result_unit_from_units(units_payload), + native_group_width=self._engine_supports_group_width() + if hasattr(self, "_engine_supports_group_width") + else True, + ) + self._load_latex_into_editor(output_path) + return str(output_path) + def _on_root_solving_failed(self, message: str): self._mark_workbench_result_failed() localized = self._localize_text(message) @@ -1033,93 +1162,21 @@ def _show_error_results( payload["units"] = units self._remember_last_result("error", payload) + # The units feature (启用单位标注 + per-variable unit tables) was removed — it was not general + # enough and duplicated the data/constants symbol columns. The run/compute/LaTeX paths are all + # None-safe for units, so every mode now passes units_config=None. These collectors return None + # for any legacy caller that still asks. def _collect_error_units_config(self): - checkbox = getattr(self, "error_units_enabled_checkbox", None) - if checkbox is None: - units = getattr(self, "error_units_config", None) - return units if isinstance(units, Mapping) else None - if not checkbox.isChecked(): - return None - units: dict[str, object] = { - "enabled": True, - "mode": _combo_current_data(self, "error_units_mode_combo", "display_only"), - "inputs": _unit_rows_to_map(self, "error_units_inputs_editor", "输入单位", "input units"), - "constants": _unit_rows_to_map(self, "error_units_constants_editor", "常数单位", "constant units"), - } - output_edit = getattr(self, "error_units_output_edit", None) - output_unit = output_edit.text().strip() if output_edit is not None else "" - if output_unit: - units["outputs"] = {"result": output_unit} - return units - - def _collect_display_units_config( - self, - attr_prefix: str, - *, - label_zh: str, - label_en: str, - include_constants: bool = False, - include_parameters: bool = False, - ): - checkbox = getattr(self, f"{attr_prefix}_units_enabled_checkbox", None) - if checkbox is None: - units = getattr(self, f"{attr_prefix}_units_config", None) - return units if isinstance(units, Mapping) else None - if not checkbox.isChecked(): - return None - units: dict[str, object] = { - "enabled": True, - "mode": "display_only", - "inputs": _unit_rows_to_map( - self, - f"{attr_prefix}_units_inputs_editor", - f"{label_zh}输入单位", - f"{label_en} input units", - ), - } - if include_constants: - units["constants"] = _unit_rows_to_map( - self, - f"{attr_prefix}_units_constants_editor", - f"{label_zh}常数单位", - f"{label_en} constant units", - ) - if include_parameters: - units["parameters"] = _unit_rows_to_map( - self, - f"{attr_prefix}_units_parameters_editor", - f"{label_zh}参数单位", - f"{label_en} parameter units", - ) - output_edit = getattr(self, f"{attr_prefix}_units_output_edit", None) - output_unit = output_edit.text().strip() if output_edit is not None else "" - if output_unit: - units["outputs"] = {"result": output_unit} - return units + return None def _collect_root_units_config(self): - return self._collect_display_units_config( - "root", - label_zh="求根", - label_en="root-solving", - include_constants=True, - ) + return None def _collect_statistics_units_config(self): - return self._collect_display_units_config( - "stats", - label_zh="统计", - label_en="statistics", - ) + return None def _collect_fitting_units_config(self): - return self._collect_display_units_config( - "fit", - label_zh="拟合", - label_en="fitting", - include_constants=True, - include_parameters=True, - ) + return None def _split_extrapolation_result(self, result): return split_extrapolation_result(result) diff --git a/app_desktop/window_fitting_formatters_mixin.py b/app_desktop/window_fitting_formatters_mixin.py index fda67097..44678754 100644 --- a/app_desktop/window_fitting_formatters_mixin.py +++ b/app_desktop/window_fitting_formatters_mixin.py @@ -119,7 +119,14 @@ def _fit_csv_headers(self, rows: list[dict[str, object]]) -> list[str]: headers.append("note") return headers - def _build_substituted_expression(self, expression: str, params: dict[str, mp.mpf], digits: int | None = None) -> str: + def _build_substituted_expression( + self, + expression: str, + params: dict[str, mp.mpf], + digits: int | None = None, + *, + use_display_format: bool = False, + ) -> str: if not expression: return "" @@ -133,6 +140,10 @@ def repl(match: re.Match[str]) -> str: mp_value = mp.mpf(params[name]) if mp.isnan(mp_value) or mp.isinf(mp_value): return str(mp_value) + # use_display_format → honour the live 小数位数/有效位数 + 科学计数法 toggles so + # the on-screen model line updates with them (LaTeX/CSV paths keep nstr). + if use_display_format and hasattr(self, "_format_display_value"): + return self._format_display_value(mp_value) return mp.nstr(mp_value, precision) return name @@ -352,6 +363,14 @@ def _format_fit_result_text( def _format_fit_display(self, fit_result: FitResult, expression: str | None, substituted: str | None, batch_idx: int = 1, units: Mapping[str, Any] | None = None, **_ignored) -> tuple[str, list[dict[str, object]]]: """Return formatted fit summary text/CSV rows (numbers only; LaTeX unaffected).""" + # Re-derive the substituted model line from the live display digits + scientific toggle + # so the numbers OUTSIDE the table (the model expression) respond to those controls too + # (user-reported: they were frozen at the fit's output digits). Fall back to the passed + # substituted if we can't rebuild (e.g. no expression/params). + if expression and fit_result.params: + substituted = self._build_substituted_expression( + expression, fit_result.params, use_display_format=True + ) text = self._format_fit_result_text(fit_result, expression, substituted, units=units) csv_rows = self._build_fit_csv_rows(fit_result, expression or "", batch_idx=batch_idx, units=units) return text, csv_rows @@ -502,11 +521,18 @@ def _fmt(val) -> str: def _latex_escape(self, text: str) -> str: return _fit_latex_writer.latex_escape(text) + def _fit_native_group_width(self) -> bool: + """Whether the compile engine's siunitx honours digit-group-size (native S-column + variable-width grouping). False → the writer pre-groups the cells app-side.""" + probe = getattr(self, "_engine_supports_group_width", None) + return bool(probe()) if callable(probe) else True + def _fit_latex_preamble(self, use_dcolumn: bool, digits: int, latex_group_size: int) -> list[str]: return _fit_latex_writer.build_fit_latex_preamble( use_dcolumn=use_dcolumn, digits=digits, latex_group_size=latex_group_size, + native_group_width=self._fit_native_group_width(), ) def _fit_latex_block( @@ -524,13 +550,26 @@ def _fit_latex_block( latex_group_size: int = 3, batch_index: int | None = None, units: Mapping[str, Any] | None = None, + target_column: str | None = None, + variable_pairs: list[tuple[str, str]] | None = None, + default_uncertainty_digits: int | None = None, ) -> list[str]: - default_unc_digits = self._uncertainty_digits_value() - target_column = self.fit_target_edit.text().strip() - try: - variable_pairs = self._ordered_variable_pairs(headers) - except Exception: - variable_pairs = [] + # target_column / variable_pairs / default_uncertainty_digits default to the LIVE + # widget values (run-time path), but the on-demand rebuild passes the RUN's values + # (from job.target_column / job.variable_map / job.uncertainty_digits) so the tex is + # reproduced faithfully regardless of subsequent widget edits. + default_unc_digits = ( + default_uncertainty_digits + if default_uncertainty_digits is not None + else self._uncertainty_digits_value() + ) + if target_column is None: + target_column = self.fit_target_edit.text().strip() + if variable_pairs is None: + try: + variable_pairs = self._ordered_variable_pairs(headers) + except Exception: + variable_pairs = [] caption_base = self._caption_value() if hasattr(self, "_caption_value") else None if expression and fit_result.params: @@ -558,4 +597,5 @@ def _fit_latex_block( default_uncertainty_digits=default_unc_digits, cleaned_substituted=cleaned_sub, units=units, + native_group_width=self._fit_native_group_width(), ) diff --git a/app_desktop/window_fitting_models_mixin.py b/app_desktop/window_fitting_models_mixin.py index b9e311cc..fcd8737a 100644 --- a/app_desktop/window_fitting_models_mixin.py +++ b/app_desktop/window_fitting_models_mixin.py @@ -460,6 +460,10 @@ def _prepare_fit_job(self, dataset, generate_latex: bool, output_path: str, verb verbose=verbose, render_plots=render_plots, latex_digits=self.latex_input_precision_spin.value(), + latex_group_size=self.latex_group_size_spin.value() + if hasattr(self, "latex_group_size_spin") + else 3, + uncertainty_digits=self._uncertainty_digits_value(), weighted=self.fit_weighted_checkbox.isChecked(), label=label, is_multidim=is_multidim, diff --git a/app_desktop/window_fitting_residuals_mixin.py b/app_desktop/window_fitting_residuals_mixin.py index 597dbcd1..c51b52a0 100644 --- a/app_desktop/window_fitting_residuals_mixin.py +++ b/app_desktop/window_fitting_residuals_mixin.py @@ -187,7 +187,7 @@ def _write_fitting_latex_batches( ) -> Path | None: digits = self.latex_input_precision_spin.value() if hasattr(self, "latex_input_precision_spin") else 16 if latex_group_size is not None: - group_size = max(1, int(latex_group_size)) + group_size = max(0, int(latex_group_size)) # 0 = 不分组 must survive (F1) else: group_size = self.latex_group_size_spin.value() if hasattr(self, "latex_group_size_spin") else 3 tex_path = Path(output_path).expanduser() @@ -207,6 +207,12 @@ def _write_fitting_latex_batches( latex_group_size=group_size, batch_index=entry.get("index"), units=entry.get("units"), + # Pass the run's snapshotted target/variable/uncertainty so on-demand TeX + # ignores later live-widget edits (Codex adversarial-review finding). None + # entries (run-time write path) keep the old live-widget fallback. + target_column=entry.get("target_column"), + variable_pairs=entry.get("variable_pairs"), + default_uncertainty_digits=entry.get("uncertainty_digits"), ) ) lines.append("\\end{document}") @@ -230,7 +236,8 @@ def _write_fitting_comparison_latex( job: FittingComparisonJob, ) -> Path | None: digits = int(getattr(job, "latex_digits", 16) or 16) - group_size = int(getattr(job, "latex_group_size", 3) or 3) + _gs = getattr(job, "latex_group_size", 3) + group_size = int(_gs) if _gs is not None else 3 # 0 = 不分组 must survive (not `or 3`) tex_path = Path(job.output_path).expanduser() try: comparison_rows = build_comparison_table_rows_from_payload(payload) @@ -247,6 +254,10 @@ def _write_fitting_comparison_latex( comparison_rows, use_dcolumn=job.use_dcolumn, caption_text=job.caption or self._tr("选定拟合比较", "Selected fit comparison"), + latex_group_size=group_size, + native_group_width=self._fit_native_group_width() + if hasattr(self, "_fit_native_group_width") + else True, ) ) lines.append("\\end{document}") @@ -431,6 +442,12 @@ def _on_fit_batches_finished(self, entries: list[FitBatchResultEntry]): "substituted": substituted or "", "figure_path": fig_path, "units": payload.units, + # Snapshot the run's target column + variable mapping + uncertainty + # digits so on-demand TeX stays faithful even if the user edits the + # live fit widgets afterwards (Codex adversarial-review finding). + "target_column": job.target_column, + "variable_pairs": list(job.variable_map.items()), + "uncertainty_digits": getattr(job, "uncertainty_digits", None), } ) csv_rows.extend( @@ -445,6 +462,20 @@ def _on_fit_batches_finished(self, entries: list[FitBatchResultEntry]): batch_texts.append(header + "\n" + self._tr("未获得该批次结果。", "No result for this batch.")) combined = "\n\n".join(batch_texts) self._set_result_text(combined, final_result=True) + # Stash the tex-rebuild data so 生成 TeX works on demand for batch fits too + # (previously only single-fit + comparison stashed → batch 生成 TeX returned None). + self.remember_latex_inputs( + "fit_batches", + { + "latex_batches": latex_batches, + "use_dcolumn": use_dcolumn, + "latex_group_size": ( + int(ctx["latex_group_size"]) + if ctx.get("latex_group_size") is not None + else 3 + ), + }, + ) self._set_image_list("fit", figure_paths) if csv_rows: self._set_csv_data( @@ -467,7 +498,11 @@ def _on_fit_batches_finished(self, entries: list[FitBatchResultEntry]): latex_batches, output_path, use_dcolumn, - latex_group_size=int(ctx.get("latex_group_size", 3) or 3), + latex_group_size=( + int(ctx["latex_group_size"]) + if ctx.get("latex_group_size") is not None + else 3 + ), # 0 = 不分组 must survive (not `or 3`) ) self.tabs.setCurrentIndex(self.result_tab_index) QMessageBox.information(self, self._tr("完成", "Done"), self._tr("批量拟合完成。", "Batch fitting completed.")) @@ -480,6 +515,144 @@ def _on_fit_batches_finished(self, entries: list[FitBatchResultEntry]): self._fit_batch_context = None return True + def generate_fitting_latex_on_demand(self) -> str | None: + """Rebuild the single-fit LaTeX tex ON DEMAND from the stashed result-data + the + RUN's target_column/variable_pairs/group_size/uncertainty_digits (NOT edited + widgets) + LIVE dcolumn/digits — reproducing the run-time tex without recompute.""" + store = getattr(self, "_last_latex_inputs", {}) or {} + latex_inputs = store.get("fit_single") + if not isinstance(latex_inputs, dict): + return None + fit_result = latex_inputs.get("fit_result") + if fit_result is None: + return None + headers = latex_inputs.get("headers") or [] + rows = latex_inputs.get("rows") or [] + sigma_rows = latex_inputs.get("sigma_rows") or [] + # Format options: dcolumn + digits are read LIVE (options); group_size + + # uncertainty_digits come from the RUN (stash) so the table layout matches. + use_dcolumn = ( + self.dcolumn_checkbox.isChecked() + if hasattr(self, "dcolumn_checkbox") + else bool(latex_inputs.get("use_dcolumn")) + ) + digits = ( + self.latex_input_precision_spin.value() + if hasattr(self, "latex_input_precision_spin") + else int(latex_inputs.get("latex_digits") or 16) + ) + _gs = latex_inputs.get("latex_group_size") + group_size = int(_gs) if _gs is not None else 3 # 0 = 不分组 must survive (not `or 3`) + output_path = self.latex_output_path_for_run(True, reuse=True) + lines = self._fit_latex_preamble(use_dcolumn, digits, group_size) + lines.extend( + self._fit_latex_block( + headers, + rows, + sigma_rows, + fit_result, + str(latex_inputs.get("expression") or ""), + str(latex_inputs.get("substituted") or ""), + None, # image_path — no image embedded + use_dcolumn, + digits, + latex_group_size=group_size, + units=latex_inputs.get("units"), + target_column=latex_inputs.get("target_column"), + variable_pairs=latex_inputs.get("variable_pairs"), + default_uncertainty_digits=latex_inputs.get("uncertainty_digits"), + ) + ) + lines.append("\\end{document}") + from pathlib import Path + + tex_path = Path(output_path).expanduser() + try: + tex_path.write_text("\n".join(lines), encoding="utf-8") + except Exception as exc: # noqa: BLE001 + self._append_log(self._tr(f"拟合 LaTeX 写入失败: {exc}", f"Fit LaTeX write failed: {exc}")) + return None + self._load_latex_into_editor(tex_path) + return str(tex_path) + + def generate_fitting_comparison_latex_on_demand(self) -> str | None: + """Rebuild the fitting-comparison LaTeX tex ON DEMAND from the stashed payload + + LIVE dcolumn/digits (group_size/caption from the run) — no recompute.""" + store = getattr(self, "_last_latex_inputs", {}) or {} + latex_inputs = store.get("fitting_comparison") + if not isinstance(latex_inputs, dict): + return None + payload = latex_inputs.get("payload") + if not isinstance(payload, dict): + return None + use_dcolumn = ( + self.dcolumn_checkbox.isChecked() + if hasattr(self, "dcolumn_checkbox") + else bool(latex_inputs.get("use_dcolumn")) + ) + digits = ( + self.latex_input_precision_spin.value() + if hasattr(self, "latex_input_precision_spin") + else int(latex_inputs.get("latex_digits") or 16) + ) + _gs = latex_inputs.get("latex_group_size") + group_size = int(_gs) if _gs is not None else 3 # 0 = 不分组 must survive (not `or 3`) + try: + comparison_rows = build_comparison_table_rows_from_payload(payload) + except ValueError: + return None + lines = self._fit_latex_preamble(use_dcolumn, digits, group_size) + lines.extend( + build_fitting_comparison_latex_block( + comparison_rows, + use_dcolumn=use_dcolumn, + caption_text=latex_inputs.get("caption") + or self._tr("选定拟合比较", "Selected fit comparison"), + latex_group_size=group_size, + native_group_width=self._fit_native_group_width() + if hasattr(self, "_fit_native_group_width") + else True, + ) + ) + lines.append("\\end{document}") + output_path = self.latex_output_path_for_run(True, reuse=True) + from pathlib import Path + + tex_path = Path(output_path).expanduser() + try: + tex_path.write_text("\n".join(lines), encoding="utf-8") + except Exception as exc: # noqa: BLE001 + self._append_log(self._tr(f"拟合比较 LaTeX 写入失败: {exc}", f"Fit comparison LaTeX write failed: {exc}")) + return None + self._load_latex_into_editor(tex_path) + return str(tex_path) + + def generate_fitting_batches_latex_on_demand(self) -> str | None: + """Rebuild the batch-fit LaTeX tex ON DEMAND from the stashed batches + LIVE dcolumn/ + digits — no recompute. Mirrors _write_fitting_latex_batches but targets a temp path so + 生成 TeX works for batch fits (previously only single-fit + comparison stashed).""" + store = getattr(self, "_last_latex_inputs", {}) or {} + latex_inputs = store.get("fit_batches") + if not isinstance(latex_inputs, dict): + return None + batches = latex_inputs.get("latex_batches") + if not isinstance(batches, list) or not batches: + return None + use_dcolumn = ( + self.dcolumn_checkbox.isChecked() + if hasattr(self, "dcolumn_checkbox") + else bool(latex_inputs.get("use_dcolumn")) + ) + _gs = latex_inputs.get("latex_group_size") + group_size = int(_gs) if _gs is not None else 3 # 0 = 不分组 must survive (not `or 3`) + output_path = self.latex_output_path_for_run(True, reuse=True) + tex_path = self._write_fitting_latex_batches( + batches, output_path, use_dcolumn, latex_group_size=group_size + ) + # Return None (not a fake path) on write failure so the caller doesn't try to load a + # file that was never written — matches generate_fitting_comparison_latex_on_demand. + return str(tex_path) if tex_path is not None else None + def _on_fit_finished(self, payload: FitResultPayload): try: job = payload.job @@ -539,6 +712,28 @@ def _on_fit_finished(self, payload: FitResultPayload): "fit_single", {"fit_result": fit_result, "expression": expression, "substituted": substituted, "job": job, "units": units}, ) + # Stash tex-rebuild DATA from the RUN (not edited widgets): target_column + + # ORDERED variable_pairs + group_size + uncertainty_digits come from the job so + # 生成 TeX reproduces the run-time tex even after widget edits. + self.remember_latex_inputs( + "fit_single", + { + "headers": job.headers, + "rows": job.data_rows, + "sigma_rows": job.sigma_rows, + "fit_result": fit_result, + "expression": expression or "", + "substituted": substituted or "", + "units": units, + "target_column": job.target_column, + "variable_pairs": list(job.variable_map.items()), + "latex_group_size": job.latex_group_size, + "uncertainty_digits": job.uncertainty_digits, + "latex_digits": job.latex_digits, + "use_dcolumn": job.use_dcolumn, + "caption": job.caption, + }, + ) QMessageBox.information(self, self._tr("完成", "Done"), self._tr("拟合完成。", "Fit completed.")) except Exception as exc: # noqa: BLE001 self._append_log(traceback.format_exc()) @@ -585,6 +780,22 @@ def _on_fitting_comparison_finished(self, payload: FittingComparisonResultPayloa self._write_fitting_comparison_latex(payload.payload, job) self.tabs.setCurrentIndex(self.result_tab_index) self._remember_last_result("fitting_comparison", dict(payload.payload)) + # Stash tex-rebuild DATA (payload + the run's format opts) so 生成 TeX rebuilds + # the comparison table on demand without recompute. + self.remember_latex_inputs( + "fitting_comparison", + { + "payload": dict(payload.payload), + "latex_digits": int(getattr(job, "latex_digits", 16) or 16), + "latex_group_size": ( + int(getattr(job, "latex_group_size", 3)) + if getattr(job, "latex_group_size", 3) is not None + else 3 + ), # 0 = 不分组 must survive (not `or 3`) + "use_dcolumn": bool(getattr(job, "use_dcolumn", True)), + "caption": getattr(job, "caption", None), + }, + ) QMessageBox.information( self, self._tr("完成", "Done"), diff --git a/app_desktop/window_i18n_mixin.py b/app_desktop/window_i18n_mixin.py index d6ae9c46..72c96a9e 100644 --- a/app_desktop/window_i18n_mixin.py +++ b/app_desktop/window_i18n_mixin.py @@ -350,6 +350,13 @@ def _apply_language(self, lang: str): if idx >= 0: combo.setCurrentIndex(idx) combo.blockSignals(False) + # The engine combo is dynamically populated (自动 + detected engines), so it is NOT + # in _combo_translations; re-run its populate to retranslate the 自动/Auto label while + # preserving the detected engine rows + current selection. + if hasattr(self, "latex_engine_combo"): + from app_desktop.panels import populate_latex_engine_combo + + populate_latex_engine_combo(self) # 更新占位文本 if hasattr(self, "mode_combo"): self._update_manual_placeholder(self.mode_combo.currentData()) @@ -367,6 +374,16 @@ def _apply_language(self, lang: str): self.result_tabs.setTabToolTip(index, result_view_tooltip(view_key, effective_lang)) if hasattr(self, "main_tabs_indices"): self.tabs.setTabText(self.main_tabs_indices["result"], "结果" if effective_lang == _LANG_ZH else "Result") + # Input-data sheet tabs (输入数据 / 常数) — retranslate by matching the hosted widget, + # since the 常数 tab is added/removed by mode so its index is not fixed. + input_tabs = getattr(self, "input_data_tabs", None) + if input_tabs is not None: + for index in range(input_tabs.count()): + widget = input_tabs.widget(index) + if widget is getattr(self, "_data_tab", None): + input_tabs.setTabText(index, "输入数据" if effective_lang == _LANG_ZH else "Data input") + elif widget is getattr(self, "_constants_tab", None): + input_tabs.setTabText(index, "常数" if effective_lang == _LANG_ZH else "Constants") if hasattr(self, "latex_edit"): self.latex_edit.setPlaceholderText( "% LaTeX 内容将在此显示…" if effective_lang == _LANG_ZH else "% LaTeX content will appear here…" diff --git a/app_desktop/window_latex_compile_mixin.py b/app_desktop/window_latex_compile_mixin.py index 47ec3a94..b57967c7 100644 --- a/app_desktop/window_latex_compile_mixin.py +++ b/app_desktop/window_latex_compile_mixin.py @@ -46,8 +46,12 @@ UnsupportedPlatformError, find_app_root, resolve_engine, + resolve_engine_for_mode, + siunitx_supports_digit_group_size, ) +import tempfile + from .resources import _ensure_default_path_augmented from .workers_core import _safe_read_text, _safe_resolve_path from .workers_qt import ( @@ -60,6 +64,40 @@ class WindowLatexCompileMixin: # ----------------------------------------------------------- LaTeX ops -- + def latex_output_path_for_run(self, generate_latex: bool, *, reuse: bool = False) -> str: + """Return the path the run should write the generated tex to. + + The LaTeX output PATH is no longer a user-facing option — the user chooses a save + location only via the TeX window's Save button. So when ``generate_latex`` is on + we materialize the tex into a per-run TEMP ``.tex`` file (retained so the editor / + PDF preview can read it back); when off, no tex is written (empty path). This + decouples "generate + preview" from "save to a user path". + + ``reuse=True`` (on-demand regeneration) returns ONE stable temp path per session and + overwrites it, so repeatedly toggling options + regenerating doesn't leak a new temp + file each time (CodeRabbit). ``reuse=False`` (a real run) allocates a fresh path. + """ + if not generate_latex: + return "" + if reuse: + path = getattr(self, "_on_demand_latex_temp_path", None) + if path: + return path + tmp = tempfile.NamedTemporaryFile( + prefix="datalab_", suffix=".tex", delete=False + ) + tmp.close() + path = tmp.name + # Track for cleanup on window close (best-effort). + paths = getattr(self, "_run_latex_temp_paths", None) + if paths is None: + paths = [] + self._run_latex_temp_paths = paths + paths.append(path) + if reuse: + self._on_demand_latex_temp_path = path + return path + def open_latex_file(self): filename, _ = QFileDialog.getOpenFileName( self, @@ -91,6 +129,40 @@ def reload_latex_editor(self, show_message: bool = False): return self._load_latex_into_editor(self.current_latex_path, show_message=show_message) + def _latex_engine_selection(self): + """The combo's current data: 'auto' (or 'bundled'/'local' legacy modes), or an + absolute engine PATH for a concrete pick. None if the combo is absent.""" + combo = getattr(self, "latex_engine_combo", None) + return combo.currentData() if combo is not None else None + + def _latex_engine_mode(self) -> str: + """Back-compat mode accessor: 'auto' | 'bundled' | 'local'. A concrete-path selection + counts as 'auto' for callers that only care about the mode.""" + data = self._latex_engine_selection() + return str(data) if data in {"auto", "bundled", "local"} else "auto" + + def _resolve_compile_engine(self) -> "EngineChoice | None": + """Resolve the compile engine for the current selection (no install prompt here). + + A concrete-engine pick (the combo data is an absolute path) is used directly; the + 'auto'/'bundled'/'local' modes go through resolve_engine_for_mode.""" + data = self._latex_engine_selection() + if isinstance(data, str) and data not in {"auto", "bundled", "local"} and data: + candidate = Path(data) + if candidate.exists(): + source = "auto-tectonic" if candidate.stem.lower().endswith("tectonic") else "system" + return EngineChoice(path=str(candidate), source=source) + return resolve_engine_for_mode(self._latex_engine_mode(), bundle_root=find_app_root()) + + def _engine_supports_group_width(self) -> bool: + """True iff the engine that will compile honours siunitx digit-group-size — i.e. the + tex writers can use S-column native variable-width grouping. False → app-side text + grouping. Resolved + probed (cached in shared.latex_engine).""" + choice = self._resolve_compile_engine() + if choice is None or not choice.path: + return False + return siunitx_supports_digit_group_size(choice.path) + def compile_latex_to_pdf(self): if getattr(self, "_latex_compile_worker", None) is not None: QMessageBox.information( @@ -102,46 +174,46 @@ def compile_latex_to_pdf(self): target = self._persist_latex_editor(silent=True) if not target: return - requested_engine = self.latex_engine_combo.currentText() - engine = requested_engine - used_default_engine_fallback = False - is_default_tectonic = requested_engine.strip().lower() == "tectonic" - if is_default_tectonic: - engine_exec = self._resolve_latex_engine_no_prompt(engine) - else: - engine_exec = self._ensure_latex_engine(engine) - if not engine_exec and is_default_tectonic: - for fallback_engine in self._latex_compile_fallback_candidates(requested_engine): - fallback_exec = self._resolve_latex_engine_no_prompt(fallback_engine) - fallback_path = _safe_resolve_path(fallback_exec) if fallback_exec else None - if fallback_path is not None and fallback_path.exists(): - engine = fallback_engine - engine_exec = str(fallback_path) - used_default_engine_fallback = True - self._append_log( - self._tr( - f"请求的 LaTeX 引擎 {requested_engine} 不可用,改用 {engine}: {fallback_path}", - f"Requested LaTeX engine {requested_engine} is unavailable; using {engine}: {fallback_path}", - ) - ) - break - if not engine_exec and is_default_tectonic: + # Engine per the user's mode (auto/bundled/local). Auto prefers a capable local TeX + # (native S-column variable-width grouping) and falls back to the bundled/auto- + # installed Tectonic. If nothing resolves and the mode allows Tectonic, offer the + # one-shot Tectonic install as the guaranteed fallback. + choice = self._resolve_compile_engine() + if choice is not None and choice.path and Path(choice.path).exists(): + engine = Path(choice.path).stem + engine_exec = choice.path + elif self._latex_engine_mode() != "local": + # Only auto/bundled may fall back to the Tectonic auto-install. "local" mode is + # an explicit user choice of a local TeX — never prompt a 30 MB Tectonic download + # behind their back (CodeRabbit CR-1). + engine = "tectonic" engine_exec = self._ensure_latex_engine(engine) + else: + engine = "local" + engine_exec = None if not engine_exec: - msg_zh = f"未找到 {requested_engine},请安装或指定路径。" - msg_en = f"{requested_engine} not found. Please install it or specify the path." QMessageBox.critical( self, self._tr("缺少 LaTeX 引擎", "Missing LaTeX Engine"), - self._tr(msg_zh, msg_en), + self._tr( + "未找到可用的 LaTeX 引擎。请安装本地 TeX,或切换到内置 Tectonic" + "(自动下载约 30 MB)。", + "No usable LaTeX engine found. Install a local TeX, or switch to the " + "bundled Tectonic (auto-downloads ~30 MB).", + ), ) return - engine_path = _safe_resolve_path(engine_exec) + # Do NOT resolve() the engine binary: TeX Live dispatches the LaTeX format by the + # invocation name (argv[0]). ``xelatex``/``pdflatex``/``lualatex`` are typically + # symlinks to a shared ``xetex``/``pdftex`` binary; following the symlink would call + # it as ``xetex`` and load the PLAIN-TeX format, making \documentclass undefined. + # Expand ~ only; keep the name that selects the format. + engine_path = Path(engine_exec).expanduser() if not engine_path.exists(): QMessageBox.critical( self, self._tr("缺少 LaTeX 引擎", "Missing LaTeX Engine"), - self._tr("指定的 LaTeX 引擎不可用。", "Specified LaTeX engine is not available."), + self._tr("LaTeX 引擎不可用。", "The LaTeX engine is not available."), ) return self._append_log( @@ -152,21 +224,6 @@ def compile_latex_to_pdf(self): ) pdf_dir = target.parent pdf_path = pdf_dir / (target.stem + ".pdf") - fallback: str | None = None - fallback_path: Path | None = None - if used_default_engine_fallback: - fallback = "xelatex" if engine.lower() == "pdflatex" else "pdflatex" - alt_exec = self._resolve_latex_engine_no_prompt(fallback) - fallback_path = _safe_resolve_path(alt_exec) if alt_exec else None - if fallback_path is not None and not fallback_path.exists(): - fallback_path = None - if fallback_path is not None: - self._append_log( - self._tr( - f"LaTeX 备用引擎: {fallback} ({fallback_path})", - f"LaTeX fallback engine: {fallback} ({fallback_path})", - ) - ) progress = QProgressDialog( self._tr("正在编译 LaTeX…", "Compiling LaTeX…"), @@ -186,8 +243,8 @@ def compile_latex_to_pdf(self): engine_name=engine, engine_path=engine_path, pdf_path=pdf_path, - fallback_name=fallback if fallback_path is not None else None, - fallback_path=fallback_path, + fallback_name=None, + fallback_path=None, parent=self, ) self._latex_compile_worker = worker @@ -200,11 +257,6 @@ def compile_latex_to_pdf(self): progress.show() worker.start() - def _latex_compile_fallback_candidates(self, requested_engine: str) -> tuple[str, ...]: - requested = (requested_engine or "").strip().lower() - candidates = ("xelatex", "pdflatex", "tectonic") - return tuple(candidate for candidate in candidates if candidate != requested) - def _on_latex_compile_completed(self, outcome: _LatexCompileOutcome) -> None: progress = getattr(self, "_latex_compile_progress", None) if progress is not None: @@ -241,7 +293,19 @@ def _on_latex_compile_completed(self, outcome: _LatexCompileOutcome) -> None: if outcome.error: self._append_log(outcome.error) - QMessageBox.critical(self, self._tr("编译失败", "Compilation Failed"), outcome.error) + # The compile error can be a full LaTeX log (many lines) — a plain critical box + # would grow past the screen and hide OK. Put the log in the scrollable detail pane. + from app_desktop.message_dialogs import show_bounded_critical + + show_bounded_critical( + self, + self._tr("编译失败", "Compilation Failed"), + outcome.error, + summary=self._tr( + "LaTeX 编译失败。点击“显示详细信息”查看完整日志。", + "LaTeX compilation failed. Click “Show Details” for the full log.", + ), + ) return if outcome.succeeded: @@ -254,6 +318,16 @@ def _on_latex_compile_completed(self, outcome: _LatexCompileOutcome) -> None: ) return self.last_pdf_path = pdf_path + # One-shot completion callback: the LaTeX preview dialog registers this before + # triggering a compile so it can render the freshly-compiled PDF in ITS OWN + # scroll when the async worker finishes (compile is a QThread — last_pdf_path is + # only valid HERE, not synchronously after compile_latex_to_pdf() returns). When + # set, the dialog owns the display, so skip the main-window preview + popup. + callback = getattr(self, "_pdf_ready_callback", None) + if callable(callback): + self._pdf_ready_callback = None + callback(pdf_path) + return if self._render_pdf_preview(pdf_path, force_reload=True): QMessageBox.information( self, @@ -358,7 +432,11 @@ def _load_latex_into_editor(self, path, show_message: bool = False): # -------------------------------------------------------- Engine resolve -- def _prompt_engine_selection(self): - engine = self.latex_engine_combo.currentText() + # Manual override: point at a specific LaTeX engine binary. The combo now holds an + # engine MODE (auto/bundled/local), so resolve the concrete engine the current mode + # would use and cache the picked path against that engine name. + choice = self._resolve_compile_engine() + engine = Path(choice.path).stem if choice is not None and choice.path else "tectonic" selected, _ = QFileDialog.getOpenFileName( self, self._tr(f"选择 {engine} 可执行文件", f"Select {engine} Executable"), @@ -420,18 +498,6 @@ def _ensure_latex_engine(self, engine: str): self._prompt_engine_selection() return self._latex_engine_paths.get(engine) - def _resolve_latex_engine_no_prompt(self, engine: str) -> str | None: - """Resolve an optional fallback engine without showing dialogs.""" - _ensure_default_path_augmented() - cached = self._latex_engine_paths.get(engine) - if cached and Path(cached).exists(): - return cached - choice = resolve_engine(engine, bundle_root=find_app_root()) - if choice is None: - return None - self._latex_engine_paths[engine] = choice.path - return choice.path - def _offer_tectonic_install(self) -> "EngineChoice | None": """Ask the user before downloading Tectonic. diff --git a/app_desktop/window_statistics_mixin.py b/app_desktop/window_statistics_mixin.py index 88d36eae..b8475ba2 100644 --- a/app_desktop/window_statistics_mixin.py +++ b/app_desktop/window_statistics_mixin.py @@ -285,6 +285,68 @@ def _append_statistics_warning_logs(self, result: dict, *, prefix: str = "") -> message = f"{prefix}{warning}" if prefix else warning self._append_log(message) + def generate_statistics_latex_on_demand(self) -> str | None: + """Rebuild the statistics LaTeX tex ON DEMAND from the stashed result-data + (rows/sigma_rows/display_batches) + LIVE format widgets — no recompute. Mirrors the + run-time single-batch vs batches split.""" + store = getattr(self, "_last_latex_inputs", {}) or {} + latex_inputs = store.get("statistics") + if not isinstance(latex_inputs, dict): + return None + display_batches = latex_inputs.get("display_batches") + rows = latex_inputs.get("rows") + sigma_rows = latex_inputs.get("sigma_rows") + if not isinstance(display_batches, list) or not display_batches: + return None + output_path = self.latex_output_path_for_run(True, reuse=True) + digits = ( + self.latex_input_precision_spin.value() + if hasattr(self, "latex_input_precision_spin") + else 16 + ) + group_size = ( + self.latex_group_size_spin.value() + if hasattr(self, "latex_group_size_spin") + else 3 + ) + use_dcolumn = ( + self.dcolumn_checkbox.isChecked() if hasattr(self, "dcolumn_checkbox") else False + ) + # Engine-adaptive grouping: if the compile engine's siunitx honours digit-group-size + # (local TeX) use native S-column variable-width grouping; otherwise (bundled + # Tectonic) the writer pre-groups the cells itself. + native = self._engine_supports_group_width() if hasattr(self, "_engine_supports_group_width") else True + if len(display_batches) == 1: + entry = display_batches[0] + generate_statistics_latex( + str(entry["value_col"]), + rows, + sigma_rows, + entry["result"], + digits, + output_path, + use_dcolumn, + uncertainty_digits=self._uncertainty_digits_value(), + caption=self._caption_value(), + latex_group_size=group_size, + units=entry.get("units") if isinstance(entry.get("units"), Mapping) else None, + native_group_width=native, + ) + else: + generate_statistics_latex_batches( + str(latex_inputs.get("value_col_joined") or ""), + display_batches, + digits, + output_path, + use_dcolumn, + caption=self._caption_value(), + uncertainty_digits=self._uncertainty_digits_value(), + latex_group_size=group_size, + native_group_width=native, + ) + self._load_latex_into_editor(output_path) + return str(output_path) + def _run_statistics_mode(self, generate_latex: bool, output_path: str): precision = self._read_precision() with _mp_precision_guard(precision): @@ -427,6 +489,18 @@ def _run_statistics_mode(self, generate_latex: bool, output_path: str): self._display_statistics_batches(display_batches, ", ".join(value_columns), render_plots=render_plots) self._append_log(self._tr("统计平均计算完成。", "Statistics completed.")) + # Stash tex-rebuild DATA (rows/sigma_rows/display_batches) so 生成 TeX rebuilds on + # demand from live format widgets — the plain-stats display payload never carries + # rows/sigma_rows, so they must be retained here. + self.remember_latex_inputs( + "statistics", + { + "rows": rows, + "sigma_rows": sigma_rows, + "display_batches": display_batches, + "value_col_joined": ", ".join(group.value_col for group in column_groups), + }, + ) if generate_latex and output_path: digits = self.latex_input_precision_spin.value() if hasattr(self, "latex_input_precision_spin") else 16 if len(display_batches) == 1: @@ -442,6 +516,7 @@ def _run_statistics_mode(self, generate_latex: bool, output_path: str): uncertainty_digits=self._uncertainty_digits_value(), caption=self._caption_value(), latex_group_size=self.latex_group_size_spin.value() if hasattr(self, "latex_group_size_spin") else 3, + native_group_width=self._engine_supports_group_width() if hasattr(self, "_engine_supports_group_width") else True, units=entry.get("units") if isinstance(entry.get("units"), Mapping) else None, ) else: @@ -454,6 +529,7 @@ def _run_statistics_mode(self, generate_latex: bool, output_path: str): caption=self._caption_value(), uncertainty_digits=self._uncertainty_digits_value(), latex_group_size=self.latex_group_size_spin.value() if hasattr(self, "latex_group_size_spin") else 3, + native_group_width=self._engine_supports_group_width() if hasattr(self, "_engine_supports_group_width") else True, ) self._append_log(f"统计平均 LaTeX 已写入: {output_path}") self._load_latex_into_editor(output_path) @@ -562,6 +638,7 @@ def _run_statistics_grouped_mode( digits=self.latex_input_precision_spin.value() if hasattr(self, "latex_input_precision_spin") else 16, uncertainty_digits=self._uncertainty_digits_value(), latex_group_size=self.latex_group_size_spin.value() if hasattr(self, "latex_group_size_spin") else 3, + native_group_width=self._engine_supports_group_width() if hasattr(self, "_engine_supports_group_width") else True, units=snapshot.get("units") if isinstance(snapshot.get("units"), Mapping) else None, ) self._append_log(f"分组统计 LaTeX 已写入: {output_path}") @@ -663,6 +740,7 @@ def _run_statistics_matrix_mode( caption_text=self._caption_value(), use_dcolumn=self.dcolumn_checkbox.isChecked(), latex_group_size=self.latex_group_size_spin.value() if hasattr(self, "latex_group_size_spin") else 3, + native_group_width=self._engine_supports_group_width() if hasattr(self, "_engine_supports_group_width") else True, units=snapshot.get("units") if isinstance(snapshot.get("units"), Mapping) else None, ) self._append_log(f"协方差/相关矩阵 LaTeX 已写入: {output_path}") @@ -977,6 +1055,7 @@ def _run_statistics_time_series_mode( caption=self._caption_value(), uncertainty_digits=self._uncertainty_digits_value(), latex_group_size=self.latex_group_size_spin.value() if hasattr(self, "latex_group_size_spin") else 3, + native_group_width=self._engine_supports_group_width() if hasattr(self, "_engine_supports_group_width") else True, ) self._append_log(f"时间序列统计 LaTeX 已写入: {output_path}") self._load_latex_into_editor(output_path) @@ -1207,6 +1286,7 @@ def _run_statistics_hypothesis_mode( caption=self._caption_value(), uncertainty_digits=self._uncertainty_digits_value(), latex_group_size=self.latex_group_size_spin.value() if hasattr(self, "latex_group_size_spin") else 3, + native_group_width=self._engine_supports_group_width() if hasattr(self, "_engine_supports_group_width") else True, ) self._append_log(f"假设检验 LaTeX 已写入: {output_path}") self._load_latex_into_editor(output_path) @@ -1341,6 +1421,7 @@ def _run_statistics_bootstrap_mode( caption=self._caption_value(), uncertainty_digits=self._uncertainty_digits_value(), latex_group_size=self.latex_group_size_spin.value() if hasattr(self, "latex_group_size_spin") else 3, + native_group_width=self._engine_supports_group_width() if hasattr(self, "_engine_supports_group_width") else True, ) self._append_log(f"Bootstrap 统计 LaTeX 已写入: {output_path}") self._load_latex_into_editor(output_path) diff --git a/app_desktop/workbench_formula_panel.py b/app_desktop/workbench_formula_panel.py index 268deae6..2f2bbcb9 100644 --- a/app_desktop/workbench_formula_panel.py +++ b/app_desktop/workbench_formula_panel.py @@ -409,6 +409,13 @@ def refresh_formula_workspace_panel(owner: Any) -> None: label = getattr(owner, "workbench_formula_preview_label", None) if label is None: return + # Re-apply the preview surface style for the CURRENT theme. It was set once at construction + # (before the theme was applied), so a dark session kept the light-theme box → a light, + # near-invisible border in the dark UI (looked like no/incomplete rounded border). This + # refresh runs on theme + mode change. + from app_desktop.theme import formula_inline_preview_style, is_dark_theme + + label.setStyleSheet(formula_inline_preview_style(dark=is_dark_theme())) if not bool(getattr(owner, "_workbench_formula_populated", False)): populate_formula_workspace_panel(owner) panel = getattr(owner, "workbench_formula_panel", None) diff --git a/app_desktop/workbench_layout.py b/app_desktop/workbench_layout.py index ee8650ef..a815ac9b 100644 --- a/app_desktop/workbench_layout.py +++ b/app_desktop/workbench_layout.py @@ -13,7 +13,6 @@ ) from app_desktop.theme import ( - CONFIG_RAIL_WIDTH, RESULT_RAIL_WIDTH, STATUS_STRIP_HEIGHT, WORKSPACE_GUTTER, @@ -127,25 +126,29 @@ def build_workbench_main_splitter(owner: object) -> QSplitter: owner.workbench_result_rail = result_frame owner.workbench_result_layout = result_layout - splitter.addWidget(config_scroll) + # Two-pane layout: the config rail merged into the workspace canvas, so the + # splitter holds only [merged workspace pane | result pane]. ``config_scroll`` + # is created (compatibility attribute) but is NOT a splitter pane — the input + + # config sections are re-anchored into ``workspace_scroll`` in ``panels.build_ui``. splitter.addWidget(workspace_scroll) splitter.addWidget(result_frame) for index in range(splitter.count()): splitter.setCollapsible(index, False) - splitter.setStretchFactor(0, 0) - splitter.setStretchFactor(1, 1) - splitter.setStretchFactor(2, 0) - + # Left (config/data) : right (result) defaults to ~1:3 — the result pane is where the output + # lives and deserves the bulk of the width. Both panes stretch (the result faster) so the ratio + # is preserved as the window resizes, while WORKSPACE_CANVAS_MIN_WIDTH keeps the left usable. + splitter.setStretchFactor(0, 1) + splitter.setStretchFactor(1, 3) + + # Initial sizes target ~1:3 (result gets 3×). The left pane can't go below + # WORKSPACE_CANVAS_MIN_WIDTH, so on narrow windows the ratio widens toward the left minimum; + # on wide windows it approaches a true 1:3. Assume a reasonable default width if the window + # hasn't been sized yet (owner_width==0 at build), so the first paint isn't left-heavy. owner_width = int(getattr(owner, "width", lambda: 0)() or 0) - available = max( - owner_width, - CONFIG_RAIL_WIDTH + WORKSPACE_CANVAS_MIN_WIDTH + RESULT_RAIL_WIDTH, - ) - workspace_width = max( - WORKSPACE_CANVAS_MIN_WIDTH, - available - CONFIG_RAIL_WIDTH - RESULT_RAIL_WIDTH, - ) - splitter.setSizes([CONFIG_RAIL_WIDTH, workspace_width, RESULT_RAIL_WIDTH]) + default_width = 1600 + available = max(owner_width or default_width, WORKSPACE_CANVAS_MIN_WIDTH + RESULT_RAIL_WIDTH) + workspace_width = max(WORKSPACE_CANVAS_MIN_WIDTH, available // 4) + splitter.setSizes([workspace_width, available - workspace_width]) return splitter diff --git a/app_desktop/workbench_results.py b/app_desktop/workbench_results.py index 19f92446..47b2d3ea 100644 --- a/app_desktop/workbench_results.py +++ b/app_desktop/workbench_results.py @@ -8,7 +8,7 @@ from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget -from app_desktop.theme import result_overview_card_style +from app_desktop.theme import CARD_PADDING, result_overview_card_style MAX_RESULT_OVERVIEW_ROWS = 50 MAX_RESULT_OVERVIEW_STATE_ROWS = 100 @@ -47,7 +47,7 @@ def build_result_overview(owner: Any) -> QWidget: widget.setObjectName("workbench_result_overview_panel") widget.setStyleSheet(result_overview_card_style()) layout = QVBoxLayout(widget) - layout.setContentsMargins(10, 8, 10, 8) + layout.setContentsMargins(*CARD_PADDING) layout.setSpacing(4) title_row = QWidget() diff --git a/app_desktop/workbench_toolbar.py b/app_desktop/workbench_toolbar.py index 4b8a0301..537b419f 100644 --- a/app_desktop/workbench_toolbar.py +++ b/app_desktop/workbench_toolbar.py @@ -115,6 +115,23 @@ def build_workbench_toolbar(owner: object) -> QWidget: layout.addWidget(identity_label) layout.addSpacing(6) + # Compute-mode selector slot (left of the workspace buttons). The real + # ``mode_combo`` is created later in ``panels.build_ui`` (after this toolbar), + # so we reserve a labelled slot here and let ``panels.py`` insert the combo into + # ``_toolbar_mode_slot`` once it exists (lazy/after-build, like the option panels). + mode_label = QLabel("模式:") + mode_label.setObjectName("workbench_mode_label") + register = getattr(owner, "_register_text", None) + if callable(register): + register(mode_label, "模式:", "Mode:") + layout.addWidget(mode_label) + mode_slot = QHBoxLayout() + mode_slot.setContentsMargins(0, 0, 0, 0) + mode_slot.setSpacing(0) + dynamic_owner._toolbar_mode_slot = mode_slot + layout.addLayout(mode_slot) + layout.addSpacing(8) + dynamic_owner.new_workspace_button = make_toolbar_button( owner, "新建", @@ -188,9 +205,34 @@ def build_workbench_toolbar(owner: object) -> QWidget: tooltip_zh="停止正在运行的计算。", tooltip_en="Stop the running calculation.", ) + # Ctrl/⌘+Return runs from the toolbar 运行 button (the bottom 开始执行 button that used + # to own this shortcut was removed in 4·4c). 停止 starts disabled — the run/stop state + # machine (window_extrapolation_mixin._set_button_to_stop_mode/_run_mode) enables 停止 + # and disables 运行 while a job runs, then reverses when it finishes. + from PySide6.QtGui import QKeySequence + + dynamic_owner.workbench_run_button.setShortcut(QKeySequence("Ctrl+Return")) + dynamic_owner.workbench_stop_button.setEnabled(False) layout.addWidget(dynamic_owner.workbench_run_button) layout.addWidget(dynamic_owner.workbench_stop_button) + # 计算 options button. Opens a resizable, non-modal QDialog window — see + # app_desktop.options_dialogs. Only the button lives here; panels.py builds the dialog + # (reparenting the real option controls) once those controls exist (lazy/after-build), + # then binds the button to open its dialog. LaTeX options moved to a result-panel entry + # (result_latex_options_button) — user: 工具栏不需要 latex, 单独的 LaTeX 选项入口. + dynamic_owner.workbench_compute_options_button = make_toolbar_button( + owner, + "计算", + "Compute", + "workbench_compute_options_button", + QStyle.StandardPixmap.SP_ComputerIcon, + tooltip_zh="精度与并行/资源选项。", + tooltip_en="Precision and parallel/resource options.", + ) + dynamic_owner.workbench_compute_options_button.setCheckable(True) + layout.addWidget(dynamic_owner.workbench_compute_options_button) + layout.addStretch(1) dynamic_owner.job_status_label = QLabel() @@ -206,6 +248,18 @@ def build_workbench_toolbar(owner: object) -> QWidget: layout.addSpacing(8) + dynamic_owner.history_button = make_toolbar_button( + owner, + "历史", + "History", + "history_button", + QStyle.StandardPixmap.SP_FileDialogDetailedView, + "_toggle_history_popup", + tooltip_zh="打开结果历史(恢复、对比、删除等)。", + tooltip_en="Open the result history (restore, compare, delete, …).", + ) + layout.addWidget(dynamic_owner.history_button) + dynamic_owner.docs_button = make_toolbar_button( owner, "文档", diff --git a/app_desktop/workbench_variable_panel.py b/app_desktop/workbench_variable_panel.py index ad708a06..3b87f4e2 100644 --- a/app_desktop/workbench_variable_panel.py +++ b/app_desktop/workbench_variable_panel.py @@ -14,7 +14,7 @@ QWidget, ) -from app_desktop.theme import variable_panel_style +from app_desktop.theme import CARD_PADDING, variable_panel_style from app_desktop.workbench_layout import reparent_widget from app_desktop.workbench_specs import MODE_WORKBENCH_SPECS @@ -32,7 +32,7 @@ def build_variable_workspace_panel(owner: Any) -> QWidget: header_layout = QHBoxLayout(header) header_layout.setContentsMargins(0, 0, 0, 0) header_layout.setSpacing(6) - owner.workbench_variable_title = QLabel(owner._tr("参数与常数", "Parameters and constants")) + owner.workbench_variable_title = QLabel(owner._tr("参数", "Parameters")) owner.workbench_variable_title.setObjectName("workbench_variable_title") header_layout.addWidget(owner.workbench_variable_title, 0) @@ -41,18 +41,9 @@ def build_variable_workspace_panel(owner: Any) -> QWidget: owner.workbench_variable_summary.setWordWrap(True) header_layout.addWidget(owner.workbench_variable_summary, 1) - owner.workbench_variable_toggle_button = QPushButton(owner._tr("折叠", "Collapse")) - owner.workbench_variable_toggle_button.setObjectName("workbench_variable_toggle_button") - owner.workbench_variable_toggle_button.setProperty("datalab_variable_toolbar_button", True) - owner_ref = weakref.ref(owner) - - def _toggle_from_button() -> None: - current_owner = owner_ref() - if current_owner is not None: - _toggle_variable_workspace_panel(current_owner) - - owner.workbench_variable_toggle_button.clicked.connect(_toggle_from_button) - header_layout.addWidget(owner.workbench_variable_toggle_button, 0) + # No collapse button: the variable panel (参数/未知量) is compact and always relevant when + # visible; a 折叠 toggle added clutter without value (user request). The panel self-hides via + # refresh_variable_workspace_panel when the mode has no variables. layout.addWidget(header) owner.workbench_variable_stack = QStackedWidget() @@ -60,7 +51,6 @@ def _toggle_from_button() -> None: layout.addWidget(owner.workbench_variable_stack) owner._workbench_variable_pages = {} owner._workbench_variable_sections = {} - owner._workbench_variable_collapsed = False return panel @@ -133,11 +123,11 @@ def _refresh_variable_summary(*_args: object) -> None: def _mounts_in_panel_order(spec: Any) -> tuple[Any, ...]: - if spec.mode_key == "fitting": - return spec.parameters + spec.constants + spec.tables + # Constants moved to the 常数 sheet tab, so the variable panel no longer mounts spec.constants + # (it is empty for every mode anyway now). Only parameters + tables (未知量) live here. if spec.mode_key == "root_solving": - return spec.tables + spec.constants + spec.parameters - return spec.parameters + spec.tables + spec.constants + return spec.tables + spec.parameters + return spec.parameters + spec.tables def _make_variable_section(owner: Any, mode: str, mount: Any) -> tuple[QFrame, QHBoxLayout, QVBoxLayout]: @@ -148,7 +138,7 @@ def _make_variable_section(owner: Any, mode: str, mount: Any) -> tuple[QFrame, Q section.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) outer = QVBoxLayout(section) - outer.setContentsMargins(10, 8, 10, 10) + outer.setContentsMargins(*CARD_PADDING) outer.setSpacing(8) title_row = QWidget() @@ -229,7 +219,7 @@ def refresh_variable_workspace_panel(owner: Any) -> None: if not has_variables: title = getattr(owner, "workbench_variable_title", None) if title is not None: - title.setText(owner._tr("参数与常数", "Parameters and constants")) + title.setText(owner._tr("参数", "Parameters")) if summary is not None: summary.setText(owner._tr("未填写", "No entries")) if panel is not None: @@ -262,28 +252,8 @@ def refresh_variable_workspace_panel(owner: Any) -> None: title.setText(_panel_title(owner, mode)) if summary is not None: summary.setText(_variable_summary_text(owner, mode)) - _refresh_variable_toggle(owner, page_has_visible_variables) if stack is not None: - stack.setVisible(page_has_visible_variables and not bool(getattr(owner, "_workbench_variable_collapsed", False))) - - -def _toggle_variable_workspace_panel(owner: Any) -> None: - owner._workbench_variable_collapsed = not bool(getattr(owner, "_workbench_variable_collapsed", False)) - refresh_variable_workspace_panel(owner) - - -def _refresh_variable_toggle(owner: Any, panel_has_variables: bool) -> None: - button = getattr(owner, "workbench_variable_toggle_button", None) - if button is None: - return - collapsed = bool(getattr(owner, "_workbench_variable_collapsed", False)) - button.setVisible(panel_has_variables) - button.setText(owner._tr("展开", "Expand") if collapsed else owner._tr("折叠", "Collapse")) - button.setToolTip( - owner._tr("显示参数、常数和未知量设置", "Show parameter, constant, and unknown settings") - if collapsed - else owner._tr("隐藏参数、常数和未知量设置", "Hide parameter, constant, and unknown settings") - ) + stack.setVisible(page_has_visible_variables) def _variable_summary_text(owner: Any, mode: str) -> str: @@ -344,21 +314,12 @@ def _refresh_variable_section_title(owner: Any, section: QFrame) -> None: def _panel_title(owner: Any, mode: str) -> str: + # Constants live in the 常数 tab now, so this panel only ever holds parameters and/or unknowns. roles = tuple( str(section.property("datalab_variable_section_role") or "") for section, _attrs in getattr(owner, "_workbench_variable_sections", {}).get(mode, []) if section.isVisible() ) - if roles == ("constants",): - return owner._tr("常数", "Constants") - if "unknowns" in roles and "constants" in roles: - return owner._tr("未知量与常数", "Unknowns and constants") if "unknowns" in roles: return owner._tr("未知量", "Unknowns") - if "constants" in roles and "parameters" in roles: - if roles.index("parameters") < roles.index("constants"): - return owner._tr("参数与常数", "Parameters and constants") - return owner._tr("常数与参数", "Constants and parameters") - if "parameters" in roles: - return owner._tr("参数", "Parameters") - return owner._tr("参数与常数", "Parameters and constants") + return owner._tr("参数", "Parameters") diff --git a/app_desktop/workbench_visual_contract.py b/app_desktop/workbench_visual_contract.py index ee56cc0f..7ef6a852 100644 --- a/app_desktop/workbench_visual_contract.py +++ b/app_desktop/workbench_visual_contract.py @@ -47,11 +47,14 @@ def widget_metric(root: QWidget, object_name: str) -> WorkbenchRegionMetric: def workbench_region_metrics(root: QWidget) -> dict[str, WorkbenchRegionMetric]: + # Two-pane layout: the config rail merged into the workspace pane, so the visible + # regions are toolbar + merged workspace pane + result rail + status strip. The + # config rail is no longer a visible pane (kept as a detached compatibility widget), + # so it is not enumerated here. return { name: widget_metric(root, name) for name in ( TOOLBAR_OBJECT, - CONFIG_RAIL_OBJECT, WORKSPACE_CANVAS_OBJECT, RESULT_RAIL_OBJECT, STATUS_STRIP_OBJECT, @@ -66,13 +69,8 @@ def visual_contract_issues(root: QWidget) -> list[dict[str, object]]: if not metric.visible or metric.width <= 0 or metric.height <= 0: issues.append({"kind": "missing_workbench_region", "widget": name}) - config = metrics[CONFIG_RAIL_OBJECT] workspace = metrics[WORKSPACE_CANVAS_OBJECT] result = metrics[RESULT_RAIL_OBJECT] - if config.visible and config.width < CONFIG_RAIL_MIN_WIDTH: - issues.append( - {"kind": "config_rail_width", "widget": CONFIG_RAIL_OBJECT, "width": config.width} - ) if workspace.visible and workspace.width < WORKSPACE_CANVAS_MIN_WIDTH: issues.append( { @@ -85,13 +83,14 @@ def visual_contract_issues(root: QWidget) -> list[dict[str, object]]: issues.append( {"kind": "result_rail_width", "widget": RESULT_RAIL_OBJECT, "width": result.width} ) - if config.visible and workspace.visible and result.visible: - if not (config.x < workspace.x < result.x): + # Two-pane order: the merged workspace pane sits left of the result rail. + if workspace.visible and result.visible: + if not (workspace.x < result.x): issues.append( { "kind": "region_order", "widget": "workbench", - "positions": {"config": config.x, "workspace": workspace.x, "result": result.x}, + "positions": {"workspace": workspace.x, "result": result.x}, } ) return issues diff --git a/app_desktop/workers_core.py b/app_desktop/workers_core.py index cab026df..00355270 100644 --- a/app_desktop/workers_core.py +++ b/app_desktop/workers_core.py @@ -1202,6 +1202,9 @@ def _execute_error_mode(applied_precision): "results": results, "table_segments": table_segments, "constants": constants_used, + # ``used_columns`` (used_headers) was local-only; retain it so the on-demand + # LaTeX rebuild can reproduce the run-time tex without recomputing. + "used_columns": used_headers, "formula": job.formula or "", "precision_used": applied_precision, "propagation": normalize_uncertainty_propagation_config( @@ -1508,6 +1511,10 @@ class FitJob: verbose: bool = False render_plots: bool = True latex_digits: int = 16 + # Retained so on-demand LaTeX rebuild reproduces the run-time tex (comparison job + # already carries these; single-fit lacked them and re-read live widgets). + latex_group_size: int = 3 + uncertainty_digits: int = 1 weighted: bool = False label: str = "" is_multidim: bool = False diff --git a/app_desktop/workspace_controller.py b/app_desktop/workspace_controller.py index 9a15772b..d83c28da 100644 --- a/app_desktop/workspace_controller.py +++ b/app_desktop/workspace_controller.py @@ -10,6 +10,10 @@ from PySide6.QtWidgets import QComboBox, QTableWidget, QTableWidgetItem +from app_desktop.latex_inputs_serialization import ( + decode_latex_inputs, + encode_latex_inputs, +) from app_desktop.fitting_input_normalization import ( normalize_constants_state, normalize_parameter_rows, @@ -252,26 +256,52 @@ def _capture_data_section(window: Any, *, constants: bool = False) -> tuple[dict raw_path = None source_path = path_text or None - source_kind = "file" if use_file else ("manual_text" if stack is not None and stack.currentIndex() == 1 else "manual_table") + # Only claim source_kind="file" when there is an actual path (review P-C): use_file with an + # empty path used to tag the section "file" while capturing the manual table → inconsistent + # state with no attachment. Fall back to the manual kind so save/restore stay coherent. + _text_view = stack is not None and stack.currentIndex() == 1 + source_kind = ( + "file" if (use_file and path_text) else ("manual_text" if _text_view else "manual_table") + ) if constants and editor is not None and not use_file: source_kind = "manual_text" if editor.using_text_view() else "manual_table" canonical: dict[str, Any] if use_file and path_text: - raw = Path(path_text).read_bytes() + # Guard the file read (review P-A): if the file was moved/deleted/unreadable since the + # user picked it, saving the workspace must NOT crash. Keep the source as "file" and the + # path (so the user can re-point it), but attach empty content rather than raising. + try: + raw = Path(path_text).read_bytes() + except OSError: + raw = b"" decoded_text, encoding = _decode_bytes(raw) raw_path = f"attachments/sources/{section_name}.bin" attachments[raw_path] = raw canonical = {"rows": []} elif constants and editor is not None: rows = editor.rows() - canonical = {"headers": ["Name", "Value"], "rows": [[row["name"], row["value"]] for row in rows]} + # Safe key access (review P-D): a malformed row lacking name/value must not KeyError-crash + # the save (mirrors the .get() used in the restore path). + canonical = { + "headers": ["Name", "Value"], + "rows": [[row.get("name", ""), row.get("value", "")] for row in rows], + } decoded_text = editor.raw_text() encoding = "utf-8" raw = decoded_text.encode("utf-8") elif source_kind == "manual_text": decoded_text = _text(text_edit) encoding = "utf-8" - canonical = {"rows": [line.split() for line in decoded_text.splitlines() if line.strip()]} + # The text itself round-trips via decoded_text; this canonical is the derived tabular + # view. Split on TAB when present so empty cells are preserved (review P-B: bare .split() + # dropped empty cells and shifted columns left); fall back to whitespace otherwise. + canonical = { + "rows": [ + (line.split("\t") if "\t" in line else line.split()) + for line in decoded_text.splitlines() + if line.strip() + ] + } raw = decoded_text.encode("utf-8") else: if table is None: @@ -537,11 +567,16 @@ def _restore_constants_editor_state(editor: Any, state: Any) -> None: if "numeric_mode" in state and hasattr(editor, "set_numeric_mode"): editor.set_numeric_mode(str(state.get("numeric_mode") or "uncertainty")) editor.set_rows(rows) - if text is not None: - if hasattr(editor, "set_raw_text"): - editor.set_raw_text(str(text)) - else: - editor.set_text(str(text)) + if text is not None and use_text_view and hasattr(editor, "set_raw_text"): + # Only restore the stored text as authoritative when the workspace was saved in TEXT view. + # In TABLE view the rows we just restored are authoritative and the saved text is a stale + # draft — restoring it (stamped in-sync) defeated the editor's anti-stale guard, so a later + # table→text toggle surfaced the stale draft instead of regenerating from the rows. Leaving + # the text draft unset keeps _text_source_table_revision out of sync, so the toggle + # regenerates from the restored rows (audit A12). + editor.set_raw_text(str(text)) + elif text is not None and not hasattr(editor, "set_raw_text"): + editor.set_text(str(text)) editor.setChecked(bool(state.get("enabled"))) editor.use_text_view(use_text_view) @@ -752,7 +787,8 @@ def _restore_common_config(window: Any, common: Any, latex: Any) -> None: _set_value(getattr(window, "mpmath_precision_spin", None), common.get("mpmath_precision")) _set_value(getattr(window, "uncertainty_digits_spin", None), common.get("uncertainty_digits")) _set_value(getattr(window, "display_digits_spin", None), common.get("display_digits")) - _set_checked_if(window, "generate_latex_checkbox", common.get("generate_latex")) + # generate_latex_checkbox was removed (4·4d); an old workspace's "generate_latex" + # key in common config is simply ignored on restore. _set_checked_if(window, "generate_plots_checkbox", common.get("generate_plots")) _set_checked_if(window, "verbose_checkbox", common.get("verbose")) _set_checked_if(window, "scientific_checkbox", common.get("display_scientific")) @@ -763,7 +799,10 @@ def _restore_common_config(window: Any, common: Any, latex: Any) -> None: _set_checked_if(window, "caption_checkbox", latex.get("use_caption")) _set_text(getattr(window, "output_file_edit", None), str(latex.get("output_path") or "")) _set_text(getattr(window, "caption_edit", None), str(latex.get("caption") or "")) - _set_combo_data(getattr(window, "latex_engine_combo", None), str(latex.get("engine") or "tectonic")) + # engine is now an engine MODE (auto/bundled/local). An old workspace that stored a + # binary name (pdflatex/xelatex/tectonic) simply won't match a mode item and the + # combo stays at its default (auto) — safe graceful degradation. + _set_combo_data(getattr(window, "latex_engine_combo", None), str(latex.get("engine") or "auto")) def _restore_extrapolation_config(window: Any, config: Any) -> None: @@ -1112,7 +1151,7 @@ def _capture_config(window: Any) -> dict[str, Any]: "common": { "mpmath_precision": _value(getattr(window, "mpmath_precision_spin", None), 16), "uncertainty_digits": _value(getattr(window, "uncertainty_digits_spin", None), 1), - "generate_latex": _checked(getattr(window, "generate_latex_checkbox", None)), + # generate_latex removed (4·4d — the checkbox is gone; run never writes tex). "generate_plots": _checked(getattr(window, "generate_plots_checkbox", None)), "verbose": _checked(getattr(window, "verbose_checkbox", None)), "display_scientific": _checked(getattr(window, "scientific_checkbox", None)), @@ -1125,7 +1164,7 @@ def _capture_config(window: Any) -> dict[str, Any]: "group_size": _value(getattr(window, "latex_group_size_spin", None), 3), "use_caption": _checked(getattr(window, "caption_checkbox", None)), "caption": _text(getattr(window, "caption_edit", None)), - "engine": _combo_data(getattr(window, "latex_engine_combo", None), "tectonic"), + "engine": _combo_data(getattr(window, "latex_engine_combo", None), "auto"), }, "extrapolation": { "method": _combo_data(getattr(window, "method_combo", None), "richardson"), @@ -1787,6 +1826,9 @@ def restore_history_entry_result(window: Any, entry: HistoryEntry) -> None: window._last_result_semantic_snapshot_kind = kind window._last_result_kind = None window._last_result_payloads = {} + # Cleared alongside the display payload; 4·2 cross-restore will repopulate this from + # the semantic snapshot so on-demand 生成 TeX works after a workspace restore. + window._last_latex_inputs = {} if hasattr(window, "_set_csv_data"): window._set_csv_data(semantic_csv_rows, semantic_csv_headers, final_result=False) if hasattr(window, "log_edit"): @@ -1854,6 +1896,20 @@ def capture_workspace( "config": workspace["config"], "workspace": workspace, } + # Persist the on-demand-tex stash so 生成 TeX works after reopening WITHOUT recomputing. + # It lives at the manifest top level (not inside `workspace`, which is model-validated and + # would drop unknown keys). Encoded to a JSON-safe, full-precision form. + latex_inputs = getattr(window, "_last_latex_inputs", None) + encoded_latex_inputs = encode_latex_inputs(latex_inputs) + if encoded_latex_inputs: + manifest["latex_inputs"] = encoded_latex_inputs + # The tex-rebuild stash is a best-effort convenience (decode_latex_inputs treats it as + # optional; on reopen the user re-runs to regenerate tex). A high-dps fit with many points + # can encode to several MiB and blow the 2 MiB manifest budget, which used to make the WHOLE + # workspace unsaveable ("manifest exceeds size limit"). Drop the stash rather than fail the + # save, so no valid workspace is ever lost to this optional extra (audit A4). + if _manifest_json_size_bytes(manifest) > MAX_MANIFEST_BYTES: + del manifest["latex_inputs"] _fit_history_to_manifest_budget(window, manifest) return WorkspaceBundle(manifest=manifest, attachments=attachments) @@ -1877,9 +1933,16 @@ def _restore_data_section(window: Any, section: dict[str, Any], *, constants: bo stack = getattr(window, "_data_stack", None) source_kind = section.get("source_kind") if use_file_checkbox is not None: + # Legacy no-op: the file-source flag is now derived from the file-path edit (file-precedence), + # not a checkbox. Kept for older callers/tests that still poke it. use_file_checkbox.setChecked(False) if file_edit is not None: - file_edit.setText(str(section.get("source_path_label") or "")) + # File-precedence: keep the file path only if the file STILL EXISTS (then the run reads the + # live file). If it is gone, clear the path so the run falls back to the inlined data (its + # CONTENTS were captured as an attachment on save → the workspace stays self-contained). + source_path_label = str(section.get("source_path_label") or "") + keep_path = bool(source_path_label) and Path(source_path_label).exists() + file_edit.setText(source_path_label if keep_path else "") if stack is not None: stack.setCurrentIndex(1 if source_kind in {"manual_text", "file"} else 0) canonical = section.get("canonical_table") or {} @@ -2052,6 +2115,12 @@ def _restore_workspace_contents(window: Any, manifest: dict[str, Any], attachmen window._last_result_semantic_snapshot_kind = None window._last_result_kind = None window._last_result_payloads = {} + # Rehydrate the on-demand-tex stash so 生成 TeX works after reopening without recomputing. + # Best-effort: a malformed/older manifest simply yields an empty stash (old behaviour). + try: + window._last_latex_inputs = decode_latex_inputs(manifest.get("latex_inputs")) + except Exception: + window._last_latex_inputs = {} _restore_ui_state(window, workspace.get("ui") or {}) window._workspace_snapshot_only = bool(snapshot.get("present")) window._workspace_history_store = history_store diff --git a/app_web/blueprints/docs.py b/app_web/blueprints/docs.py index c9fc915d..65c0632c 100644 --- a/app_web/blueprints/docs.py +++ b/app_web/blueprints/docs.py @@ -153,7 +153,10 @@ def add_heading_ids(match): heading_id = re.sub(r"[-\s]+", "-", heading_id).strip("-") return f'<{tag} id="{heading_id}">{content}' - html_content = re.sub(r"<(h[123])>(.+?)", add_heading_ids, html_content) + # \1 (not \\1) — a real backreference to the opening tag; the old \\1 matched the literal + # text "" which never occurs, so no heading ids were emitted and every TOC anchor was + # dead (audit A7). + html_content = re.sub(r"<(h[123])>(.+?)", add_heading_ids, html_content) page_order = [p["slug"] for p in DOCS_PAGES] page_title_map: dict[str, dict[str, str]] = {p["slug"]: dict(p.get("title") or {}) for p in DOCS_PAGES} diff --git a/app_web/blueprints/pages.py b/app_web/blueprints/pages.py index a4090016..bda57a46 100644 --- a/app_web/blueprints/pages.py +++ b/app_web/blueprints/pages.py @@ -1,6 +1,6 @@ from __future__ import annotations -from flask import Blueprint, flash, render_template, request +from flask import Blueprint, abort, flash, render_template, request from .._security_shim import csrf_protect from ..logic.common import ( @@ -14,6 +14,23 @@ bp = Blueprint("pages", __name__) +@bp.before_request +def _rate_limit_compute_posts() -> None: + """Per-IP rate limit for the heavy compute POST routes (audit A2). + + Each compute route runs an mpmath computation while holding a process-global serial lock, so an + attacker hammering them can starve legitimate users. GET (form render) is cheap and untouched; + only POST is throttled, reusing the SSE blueprint's battle-tested sliding-window limiter (which + also honours the TESTING / DATALAB_SSE_DISABLE_RATE_LIMIT bypasses). Over budget → 429. + """ + if request.method != "POST": + return + from .sse import _check_rate_limit, _client_ip + + if not _check_rate_limit(_client_ip()): + abort(429) + + SAMPLE_DATA = """A B C -0.750000 -0.702321 -0.680145 -0.500000 -0.476901 -0.461822 diff --git a/app_web/logic/common.py b/app_web/logic/common.py index 2edb1ce0..ce521d39 100644 --- a/app_web/logic/common.py +++ b/app_web/logic/common.py @@ -80,6 +80,24 @@ def _parse_int(text: str | None) -> int | None: raise ValueError(f"无法解析整数: {text} / Failed to parse integer: {text}") from exc +def _parse_precision(text: str | None, default: int | None = None) -> int | None: + """Parse an mpmath-precision (dps) field from a request and CLAMP it to the app's bounded + envelope [MIN_MPMATH_DPS, MAX_MPMATH_DPS]. + + ``mp.dps`` is process-global and every compute route holds a serial lock while it runs, so an + unbounded user value (e.g. 100_000_000) would set an absurd precision and stall the whole + worker — a trivial DoS. Clamping at parse time (mirroring the SSE path's precision bounds) + ensures the guard downstream can never receive a pathological value. Returns ``default`` when + the field is absent/empty. + """ + from shared.precision import MAX_MPMATH_DPS, MIN_MPMATH_DPS + + value = _parse_int(text) + if value is None: + return default + return max(MIN_MPMATH_DPS, min(MAX_MPMATH_DPS, value)) + + def _parse_float(text: str | None) -> float | None: if text is None: return None diff --git a/app_web/logic/error_propagation.py b/app_web/logic/error_propagation.py index b348a668..34f0eeaa 100644 --- a/app_web/logic/error_propagation.py +++ b/app_web/logic/error_propagation.py @@ -37,6 +37,7 @@ _is_checked, _latex_to_plain, _parse_int, + _parse_precision, ) from .plots import _render_contribution_plot, _render_monte_carlo_distribution_plot @@ -174,7 +175,7 @@ def _should_collect_monte_carlo_distribution( @mpmath_synchronized def _run_error_propagation(data_text: str, constants_text: str, form, lang: str = "zh") -> ErrorPropagationBundle: _reject_active_units_on_web(form) - mp_precision = _parse_int(form.get("error_mp_precision")) + mp_precision = _parse_precision(form.get("error_mp_precision")) latex_precision = _parse_int(form.get("error_latex_precision")) latex_group_size = _parse_int(form.get("error_latex_group_size")) if latex_group_size is None: diff --git a/app_web/logic/extrapolation.py b/app_web/logic/extrapolation.py index c8e4116d..c273523a 100644 --- a/app_web/logic/extrapolation.py +++ b/app_web/logic/extrapolation.py @@ -32,6 +32,7 @@ _generate_csv_from_rows, _is_checked, _parse_int, + _parse_precision, ) from .plots import _render_extrapolation_plot @@ -160,7 +161,7 @@ def _method_options_payload( @mpmath_synchronized def _run_extrapolation(data_text: str, form, lang: str = "zh") -> ExtrapolationResultBundle: method = (form.get("method") or "power_law").strip() - mp_precision = _parse_int(form.get("mp_precision")) + mp_precision = _parse_precision(form.get("mp_precision")) latex_precision = _parse_int(form.get("latex_precision")) latex_group_size = _parse_int(form.get("latex_group_size")) if latex_group_size is None: diff --git a/app_web/logic/fitting.py b/app_web/logic/fitting.py index a375ee31..252904b4 100644 --- a/app_web/logic/fitting.py +++ b/app_web/logic/fitting.py @@ -2,6 +2,7 @@ import json import logging +import re from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import Any @@ -29,8 +30,13 @@ ) from datalab_latex.sisetup_block import build_sisetup_block from fitting import ( + FitRunner, + ImplicitModelDefinition, + ImplicitSolveOptions, + ModelProblem, build_inverse_series_definition, build_polynomial_definition, + infer_parameter_names, render_fitting_overview, summarize_fit_result, ) @@ -53,6 +59,7 @@ _merged_core_warnings, _norm_token, _parse_int, + _parse_precision, ) from shared.fitting_uncertainty import fit_uncertainty_policy from shared.uncertainty import parse_uncertainty_format @@ -216,6 +223,78 @@ def _pade_template(m: int, n: int) -> tuple[str, dict[str, dict[str, float]]] | return expression, params +def _build_self_consistent_problem( + form, + headers: list[str], + rows: list[tuple[mp.mpf, ...]], + var_mapping: dict[str, str], + x_column: str, +) -> tuple[ModelProblem, dict[str, list[mp.mpf]]]: + """Parse implicit-model form fields into (problem, variable_data). + + Mirrors the desktop's ``_collect_implicit_config`` (window.py). Caller must + hold ``_precision_guard`` — ``_column_series`` parses to ``mp.mpf`` at the + active ``mp.dps``. + """ + equation = (form.get("fit_implicit_equation") or "").strip() + implicit_variable = (form.get("fit_implicit_variable") or "").strip() + output_expression = (form.get("fit_implicit_output") or "").strip() + if not equation: + raise ValueError(_dual_msg("隐式方程不能为空。", "Implicit equation cannot be empty.")) + if not output_expression: + raise ValueError(_dual_msg("输出表达式不能为空。", "Output expression cannot be empty.")) + if not re.match(r"^[A-Za-z_]\w*$", implicit_variable): + raise ValueError(_dual_msg("隐式变量必须是有效标识符。", "Implicit variable must be a valid identifier.")) + + method = (form.get("fit_implicit_method") or "fixed_point").strip() or "fixed_point" + initial = (form.get("fit_implicit_initial") or "0").strip() or "0" + tolerance = (form.get("fit_implicit_tolerance") or "1e-30").strip() or "1e-30" + max_iterations = _parse_int(form.get("fit_implicit_max_iter")) or 80 + + implicit_params_text = form.get("fit_implicit_params") or "" + try: + params_cfg = json.loads(implicit_params_text) if str(implicit_params_text).strip() else {} + except Exception as exc: + # Only JSON syntax errors get wrapped here; the non-dict check below raises + # its own already-bilingual message OUTSIDE this try so it is not re-wrapped + # into a doubled "汉语 / English / 汉语 / English" string (breaks the locale split). + raise ValueError( + _dual_msg(f"自洽隐式模型参数解析失败: {exc}", f"Failed to parse self-consistent model parameters: {exc}") + ) from exc + if not isinstance(params_cfg, dict): + raise ValueError(_dual_msg("参数配置必须为 JSON 对象(key 为参数名)。", "Parameter config must be a JSON object.")) + normalized_cfg: dict[str, dict[str, object]] = { + str(name): (conf if isinstance(conf, dict) else {"initial": conf}) for name, conf in params_cfg.items() + } + + variable_map = dict(var_mapping) if var_mapping else {"x": x_column} + x_variables = tuple(variable_map.keys()) + variable_data = {name: _column_series(headers, rows, col) for name, col in variable_map.items()} + + parameter_names = infer_parameter_names( + f"{equation}\n{output_expression}", list(x_variables) + [implicit_variable], list(normalized_cfg.keys()) + ) + + definition = ImplicitModelDefinition( + x_variables=x_variables, + implicit_variable=implicit_variable, + equation=equation, + output_expression=output_expression, + parameters=tuple(parameter_names), + solve_options=ImplicitSolveOptions( + method=method, initial=initial, tolerance=tolerance, max_iterations=max_iterations + ), + ) + problem = ModelProblem( + model_type="self_consistent", + expression=output_expression, + variables=x_variables, + parameter_config=normalized_cfg, + implicit_definition=definition, + ) + return problem, variable_data + + def _normalize_fit_mode(raw_mode: str | None) -> str: mode = (raw_mode or "polynomial").strip() legacy_aliases = { @@ -253,6 +332,17 @@ def _format_fit_rows( def _fmt(value) -> str: return _format_with_precision(value, mp_precision) + def _fmt_sigma(value) -> str: + # An undefined (non-finite) uncertainty renders as "N/A" so the CSV matches + # the params table's rendering (one response must not show 'nan' and 'N/A' + # for the same sigma). + try: + if not mp.isfinite(mp.mpf(value)): + return "N/A" + except Exception: + pass + return _fmt(value) + if expression: rows.append( { @@ -278,9 +368,9 @@ def _fmt(value) -> str: "section": "parameter", "name": name, "value": _fmt(value), - "uncertainty": _fmt(total_errors.get(name, 0)), - "stat_error": _fmt(stat_errors.get(name, "")) if name in stat_errors else "", - "sys_error": _fmt(sys_errors.get(name, "")) if name in sys_errors else "", + "uncertainty": _fmt_sigma(total_errors.get(name, 0)), + "stat_error": _fmt_sigma(stat_errors.get(name, "")) if name in stat_errors else "", + "sys_error": _fmt_sigma(sys_errors.get(name, "")) if name in sys_errors else "", "note": "", } ) @@ -736,7 +826,7 @@ def _generate_fitting_comparison_latex( @mpmath_synchronized def _run_fit(data_text: str, form) -> FitResultBundle: - mp_precision = _parse_int(form.get("fit_mp_precision")) or 80 + mp_precision = _parse_precision(form.get("fit_mp_precision")) or 80 log_scale = (form.get("fit_log_scale") or "").strip().lower() fit_mode = _normalize_fit_mode(form.get("fit_mode")) custom_expr = (form.get("fit_custom_expr") or "").strip() @@ -904,14 +994,24 @@ def _collect_params(fit_res): err = fit_res.param_errors.get(name) if fit_res.param_errors else None val = mp.mpf(value) sigma = mp.mpf(err) if err is not None else mp.mpf("0") - latex = format_result_with_uncertainty_latex(val, sigma, result_digits) + # A non-finite uncertainty (e.g. an unused/degenerate parameter whose + # covariance is undefined) cannot be formatted with siunitx — + # format_result_with_uncertainty_latex would raise a raw, non-bilingual + # "cannot convert inf or nan to int". Render the value alone and mark the + # uncertainty unavailable instead of 500-crashing the whole fit response. + if mp.isfinite(sigma): + latex = format_result_with_uncertainty_latex(val, sigma, result_digits) + uncertainty_display = _format_number(sigma, 10) + else: + latex = "" + uncertainty_display = "N/A" collected.append( { "name": name, "value_raw": val, "uncertainty_raw": sigma, "value": _format_number(val, 10), - "uncertainty": _format_number(sigma, 10), + "uncertainty": uncertainty_display, "latex": _latex_to_plain(latex) if latex else "", } ) @@ -1011,67 +1111,83 @@ def _render_plot(fit_res): ) try: params_cfg = json.loads(custom_params_text) if str(custom_params_text).strip() else {} - if not isinstance(params_cfg, dict): - raise ValueError( - _dual_msg( - "参数配置必须为 JSON 对象(key 为参数名)。", - "Parameter config must be a JSON object.", - ) - ) - normalized_cfg: dict[str, dict[str, object]] = {} - for name, conf in params_cfg.items(): - if isinstance(conf, dict): - normalized_cfg[str(name)] = conf - else: - normalized_cfg[str(name)] = {"initial": conf} - parameter_config = normalized_cfg - parameter_names = list(normalized_cfg.keys()) except Exception as exc: + # Only JSON syntax errors get wrapped; the non-dict check below raises + # its own already-bilingual message OUTSIDE this try (same CR-1 pattern + # as _build_self_consistent_problem) so it is not double-wrapped. raise ValueError( _dual_msg( f"自定义模型解析失败: {exc}", f"Failed to parse custom model: {exc}", ) ) from exc + if not isinstance(params_cfg, dict): + raise ValueError( + _dual_msg( + "参数配置必须为 JSON 对象(key 为参数名)。", + "Parameter config must be a JSON object.", + ) + ) + normalized_cfg: dict[str, dict[str, object]] = {} + for name, conf in params_cfg.items(): + if isinstance(conf, dict): + normalized_cfg[str(name)] = conf + else: + normalized_cfg[str(name)] = {"initial": conf} + parameter_config = normalized_cfg + parameter_names = list(normalized_cfg.keys()) model_expr = custom_expr variable_map = dict(var_mapping) if var_mapping else {"x": x_column} best_label = "自定义模型 / Custom model" + elif fit_mode == "self_consistent": + problem, variable_data = _build_self_consistent_problem(form, headers, rows, var_mapping, x_column) + definition = problem.implicit_definition + assert isinstance(definition, ImplicitModelDefinition) + fit_res = FitRunner().fit( + problem, variable_data, y_vals, precision=mp_precision, weights=fit_weights, data_sigmas=sigma_list + ) + best_label = "自洽隐式模型 / Self-consistent" + expression_for_latex = expression_for_csv = definition.output_expression else: raise _unsupported_fit_mode_error(fit_mode) - request = build_fitting_request( - model_type=fit_mode, - headers=headers, - data_rows=rows, - variable_map=variable_map, - target_column=target_column, - model_expr=model_expr, - sigma_rows=sigma_rows, - sigma_series=sigma_list, - parameter_config=parameter_config, - parameter_names=parameter_names, - template_expr=template_expr, - template_params=template_params, - poly_degree=max(1, poly_degree), - inverse_min=inv_min, - inverse_max=inv_max, - pade_m=pade_m, - pade_n=pade_n, - weighted=use_weights, - refine_with_mcmc=refine_with_mcmc, - label=best_label, - weights=fit_weights, - precision_digits=mp_precision, - uncertainty_digits=result_digits, - request_id="web-fitting", - ) - core_result = create_core_session_service().submit(request) - if core_result.status is not ResultStatus.SUCCEEDED: - raise ValueError(_core_failure_message(core_result.payload, "Fitting failed.")) - fit_res = fitting_payload_to_fit_result(core_result.payload["fit_result"]) - warnings.extend(_merged_core_warnings(core_result.payload, core_result.warnings)) - expression_for_latex = core_result.payload.get("expression") if "expression" in core_result.payload else None - expression_for_csv = str(expression_for_latex or model_expr) + if fit_res is None: + request = build_fitting_request( + model_type=fit_mode, + headers=headers, + data_rows=rows, + variable_map=variable_map, + target_column=target_column, + model_expr=model_expr, + sigma_rows=sigma_rows, + sigma_series=sigma_list, + parameter_config=parameter_config, + parameter_names=parameter_names, + template_expr=template_expr, + template_params=template_params, + poly_degree=max(1, poly_degree), + inverse_min=inv_min, + inverse_max=inv_max, + pade_m=pade_m, + pade_n=pade_n, + weighted=use_weights, + refine_with_mcmc=refine_with_mcmc, + label=best_label, + weights=fit_weights, + precision_digits=mp_precision, + uncertainty_digits=result_digits, + request_id="web-fitting", + ) + core_result = create_core_session_service().submit(request) + if core_result.status is not ResultStatus.SUCCEEDED: + raise ValueError(_core_failure_message(core_result.payload, "Fitting failed.")) + fit_res = fitting_payload_to_fit_result(core_result.payload["fit_result"]) + warnings.extend(_merged_core_warnings(core_result.payload, core_result.warnings)) + expression_for_latex = ( + core_result.payload.get("expression") if "expression" in core_result.payload else None + ) + expression_for_csv = str(expression_for_latex or model_expr) + params = _collect_params(fit_res) metrics = _collect_metrics(fit_res) diagnostic_correlations, diagnostic_residuals = _collect_diagnostic_display(fit_res) diff --git a/app_web/logic/root_solving.py b/app_web/logic/root_solving.py index 760e236d..e14c577f 100644 --- a/app_web/logic/root_solving.py +++ b/app_web/logic/root_solving.py @@ -18,6 +18,7 @@ _format_number, _latex_to_plain, _parse_int, + _parse_precision, ) @@ -120,7 +121,7 @@ def _root_latex(name: str, value_text: str, uncertainty, uncertainty_digits: int @mpmath_synchronized def _run_root_solving(form, lang: str = "zh") -> RootSolvingResultBundle: - mp_precision = _parse_int(form.get("root_mp_precision")) + mp_precision = _parse_precision(form.get("root_mp_precision")) display_digits = _parse_int(form.get("root_display_digits")) or 12 uncertainty_digits = _parse_int(form.get("root_uncertainty_digits")) if uncertainty_digits is None: diff --git a/app_web/logic/statistics.py b/app_web/logic/statistics.py index 4b9fa3a9..d4b18481 100644 --- a/app_web/logic/statistics.py +++ b/app_web/logic/statistics.py @@ -33,6 +33,7 @@ _merged_core_warnings, _norm_token, _parse_int, + _parse_precision, ) from .plots import _render_statistics_plot, _render_statistics_plots from shared.uncertainty import has_explicit_uncertainty, parse_uncertainty_format @@ -129,13 +130,6 @@ def _parse_stats_data(text: str): sigma_val = mp.mpf(uv2.value) except Exception: sigma_val = mp.mpf(token2) - if not mp.isfinite(sigma_val): - raise ValueError( - _dual_msg( - f"第 {line_num} 行的不确定度不是有限数: {parts[1]}", - f"Uncertainty on line {line_num} is not finite: {parts[1]}", - ) - ) except Exception as exc: raise ValueError( @@ -144,6 +138,16 @@ def _parse_stats_data(text: str): f"Could not parse line {line_num} as numbers: {line} ({exc})", ) ) from exc + # Raised OUTSIDE the parse try so this already-bilingual message is not + # re-wrapped into a doubled "汉语 / English / 汉语 / English" string + # (same CR-1 pattern as the fitting params parsers). + if not mp.isfinite(sigma_val): + raise ValueError( + _dual_msg( + f"第 {line_num} 行的不确定度不是有限数: {parts[1]}", + f"Uncertainty on line {line_num} is not finite: {parts[1]}", + ) + ) values.append(val) sigmas.append(sigma_val) @@ -166,7 +170,7 @@ def _format_statistics_rows(stats_result: dict, row_count: int, mp_precision: in @mpmath_synchronized def _run_statistics(data_text: str, form, lang: str = "zh") -> StatsResultBundle: - mp_precision = _parse_int(form.get("stats_mp_precision")) + mp_precision = _parse_precision(form.get("stats_mp_precision")) latex_precision = _parse_int(form.get("stats_digits")) or 12 latex_group_size = _parse_int(form.get("stats_latex_group_size")) if latex_group_size is None: diff --git a/app_web/static/js/i18n.js b/app_web/static/js/i18n.js index 2fb5c3d1..c38f543b 100644 --- a/app_web/static/js/i18n.js +++ b/app_web/static/js/i18n.js @@ -244,10 +244,21 @@ 'fit.modePade': 'Padé 拟合', 'fit.modePowerLimit': '幂律极限拟合', 'fit.modeComparison': '选定拟合比较', + 'fit.modeSelfConsistent': '自洽隐式模型 / Self-consistent', 'fit.customExprLabel': '自定义模型表达式', 'fit.customExprPlaceholder': '如 A*x**(-p) + C', 'fit.customParamsLabel': '参数配置 (JSON)', 'fit.varMappingLabel': '变量映射 (var: 列名,每行一对,留空默认 x)', + 'fit.implicitEquationLabel': '隐式方程', + 'fit.implicitEquationPlaceholder': '如 y - A*exp(-B/x*y)', + 'fit.implicitVariableLabel': '隐式变量', + 'fit.implicitVariablePlaceholder': '如 y', + 'fit.implicitOutputLabel': '输出表达式', + 'fit.implicitOutputPlaceholder': '如 y', + 'fit.implicitParamsLabel': '参数配置 (JSON,可选)', + 'fit.implicitInitialLabel': '初值', + 'fit.implicitToleranceLabel': '收敛容差', + 'fit.implicitMaxIterLabel': '最大迭代次数', 'fit.polyDegreeLabel': '多项式最高阶', 'fit.logScaleLabel': '坐标轴对数刻度', 'fit.logScalePlaceholder': 'x / y / xy,留空为线性', @@ -663,10 +674,21 @@ 'fit.modePade': 'Padé fit', 'fit.modePowerLimit': 'Power-law limit fit', 'fit.modeComparison': 'Selected-fit comparison', + 'fit.modeSelfConsistent': 'Self-consistent / implicit model', 'fit.customExprLabel': 'Custom model expression', 'fit.customExprPlaceholder': 'e.g., A*x**(-p) + C', 'fit.customParamsLabel': 'Parameter config (JSON)', 'fit.varMappingLabel': 'Variable mapping (var: column name, one pair per line, default x if blank)', + 'fit.implicitEquationLabel': 'Implicit equation', + 'fit.implicitEquationPlaceholder': 'e.g., y - A*exp(-B/x*y)', + 'fit.implicitVariableLabel': 'Implicit variable', + 'fit.implicitVariablePlaceholder': 'e.g., y', + 'fit.implicitOutputLabel': 'Output expression', + 'fit.implicitOutputPlaceholder': 'e.g., y', + 'fit.implicitParamsLabel': 'Parameter config (JSON, optional)', + 'fit.implicitInitialLabel': 'Initial value', + 'fit.implicitToleranceLabel': 'Convergence tolerance', + 'fit.implicitMaxIterLabel': 'Max iterations', 'fit.polyDegreeLabel': 'Polynomial max degree', 'fit.logScaleLabel': 'Axis log scale', 'fit.logScalePlaceholder': 'x / y / xy, leave blank for linear', diff --git a/app_web/templates/fit.html b/app_web/templates/fit.html index b80cd0b3..9fa9b3b5 100644 --- a/app_web/templates/fit.html +++ b/app_web/templates/fit.html @@ -79,6 +79,7 @@

用现有高精度拟合核心在浏览器里运行显 + @@ -92,6 +93,32 @@

用现有高精度拟合核心在浏览器里运行显 +
+ + + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
diff --git a/datalab_latex/latex_formatting.py b/datalab_latex/latex_formatting.py index 302f8664..942a4f0d 100644 --- a/datalab_latex/latex_formatting.py +++ b/datalab_latex/latex_formatting.py @@ -18,11 +18,30 @@ def _split_mantissa_exponent(value: mp.mpf) -> tuple[mp.mpf, int]: return mantissa, exponent +# Extra working digits over `places` so the mp.power(10, places) product and the value*factor +# multiply never lose the requested fractional digits to the ambient mp.dps. +_FORMAT_GUARD_DIGITS = 12 + + +def _format_workdps(places: int) -> int: + """Working precision for formatting a value to `places` decimals. + + These formatters run at the AMBIENT ``mp.dps`` unless guarded; when the caller stashes a + high-precision result and formats it later (e.g. on-demand TeX rebuild after the run's own + precision_guard has closed), the ambient dps can be the process default (~15) while `places` + is 20-200. Without a floor, the intermediate products carry only ~15 sig digits and silently + corrupt every digit past ~16. Floor the working precision to comfortably exceed `places` and + the value's own magnitude so the rounding is exact regardless of the caller's ambient dps. + """ + return max(int(mp.dps), int(places) + _FORMAT_GUARD_DIGITS) + + def _round_to_places(value: mp.mpf, places: int) -> mp.mpf: if places <= 0: return mp.nint(value) - factor = mp.power(10, places) - return mp.nint(value * factor) / factor + with _precision_guard(_format_workdps(places)): + factor = mp.power(10, places) + return mp.nint(value * factor) / factor def _format_fixed_places(value: mp.mpf, places: int) -> str: @@ -33,11 +52,12 @@ def _format_fixed_places(value: mp.mpf, places: int) -> str: except Exception: text = str(mp.nstr(rounded, n=20, strip_zeros=True)) return text[:-2] if text.endswith(".0") else text - sign = "-" if rounded < 0 else "" - abs_val = mp.fabs(rounded) - integer_part = int(mp.floor(abs_val)) - fractional = abs_val - integer_part - scaled = int(mp.nint(fractional * mp.power(10, places))) + with _precision_guard(_format_workdps(places)): + sign = "-" if rounded < 0 else "" + abs_val = mp.fabs(rounded) + integer_part = int(mp.floor(abs_val)) + fractional = abs_val - integer_part + scaled = int(mp.nint(fractional * mp.power(10, places))) frac_str = f"{scaled:0{places}d}" return f"{sign}{integer_part}.{frac_str}" @@ -633,6 +653,50 @@ def add_spacing_to_number(number_str: str, for_siunitx: bool = False, group_size return number_str +def group_digits_both_sides(number_str: str, group_size: int, sep: str = "\\,") -> str: + """Group BOTH the integer and fractional parts of a number by ``group_size`` digits. + + Unlike :func:`add_spacing_to_number` (which only spaces the fractional part), this + inserts ``sep`` every ``group_size`` digits in the integer part (from the decimal point + leftward, thousands-style) AND the fractional part (rightward). Any leading sign and any + trailing suffix (uncertainty ``(NN)``, exponent) are preserved untouched. + + This is the app-side grouping path used when the LaTeX engine's siunitx cannot honour a + variable digit-group width (the bundled Tectonic siunitx is pinned at 3): the number is + pre-grouped here so any width renders correctly with a plain (non-S) column. + + Returns ``number_str`` unchanged when ``group_size <= 0``. + """ + try: + group_size = int(group_size) + except Exception: + group_size = 3 + if group_size <= 0: + return number_str + + match = re.match(r"^([+\-−]?)(\d+)(?:\.(\d+))?(.*)$", number_str.strip()) + if not match: + return number_str + sign, int_part, frac_part, tail = match.groups() + + grouped_int_chars: list[str] = [] + for i, ch in enumerate(reversed(int_part)): + if i > 0 and i % group_size == 0: + grouped_int_chars.append(sep) + grouped_int_chars.append(ch) + grouped_int = "".join(reversed(grouped_int_chars)) + + result = (sign or "") + grouped_int + if frac_part is not None: + grouped_frac_chars: list[str] = [] + for i, ch in enumerate(frac_part): + if i > 0 and i % group_size == 0: + grouped_frac_chars.append(sep) + grouped_frac_chars.append(ch) + result += "." + "".join(grouped_frac_chars) + return result + (tail or "") + + def add_latex_spacing_to_number(number_str: str, group_size: int = 3) -> str: """ Add LaTeX thin spaces (\\\\,) every N digits in the decimal part of a number. @@ -793,6 +857,17 @@ def _format_value_for_latex_file( except Exception: sig = None + # Non-finite inputs cannot be typeset numerically — int(mp.floor(nan)) deeper + # down raises 'cannot convert inf or nan to int' and would discard the whole + # table. Guarded HERE (not only in the public wrapper) because the + # extrapolation/error-propagation table builders call this private function + # directly. An undefined sigma degrades to a bare value; an undefined value + # becomes a parse-safe literal cell (valid in both S and dcolumn columns). + if sig is not None and not mp.isfinite(sig): + sig = None + if not mp.isfinite(val): + return siunitx_safe_cell(str(mp.nstr(val)), align="c") + if use_dcolumn: if sig is not None and not mp.almosteq(sig, mp.mpf("0")): return format_uncertainty_notation_for_dcolumn( diff --git a/datalab_latex/latex_tables_common.py b/datalab_latex/latex_tables_common.py index eb4b0aad..37965ee3 100644 --- a/datalab_latex/latex_tables_common.py +++ b/datalab_latex/latex_tables_common.py @@ -97,11 +97,15 @@ def _build_standalone_preamble( include_dcolumn: bool = False, needs_cjk: bool = False, latex_group_size: int = 3, + native_group_width: bool = True, ) -> list[str]: """Return a minimal standalone LaTeX preamble tuned for fast compilation. Args: latex_group_size: Group size for grouping digits (0 = no grouping) + native_group_width: When True the engine honours siunitx digit-group-size (emit it + for native S-column variable-width grouping); when False (bundled Tectonic) the + cells are pre-grouped app-side, so don't emit the key. """ group_size = max(0, int(latex_group_size)) doc_class = "\\documentclass[varwidth={0:.2f}in,border=12pt]{{standalone}}".format(width_in) @@ -145,10 +149,12 @@ def _build_standalone_preamble( # documents still compile against older TeX Live distributions where # siunitx v2 is the default. (Was 5 near-duplicate inline copies # before — removing them all keeps the format drift-free.) + emit_dgs = bool(native_group_width and not include_dcolumn and group_size > 0) preamble.append( build_sisetup_block( group_size=group_size, include_dcolumn=include_dcolumn, + emit_digit_group_size=emit_dgs, ).rstrip("\n") ) preamble.append("") diff --git a/datalab_latex/latex_tables_error_propagation.py b/datalab_latex/latex_tables_error_propagation.py index 85c330e7..5c4a3591 100644 --- a/datalab_latex/latex_tables_error_propagation.py +++ b/datalab_latex/latex_tables_error_propagation.py @@ -11,7 +11,12 @@ from shared.uncertainty import UncertainValue, parse_uncertainty_format from .expression_engine import format_latex_formula -from .latex_formatting import _format_value_for_latex_file, _siunitx_column_spec, calculate_dcolumn_format_for_column +from .latex_formatting import ( + _format_value_for_latex_file, + _siunitx_column_spec, + calculate_dcolumn_format_for_column, + group_digits_both_sides, +) from .latex_tables_common import ( _build_standalone_preamble, _estimate_page_geometry, @@ -191,8 +196,14 @@ def generate_error_propagation_table( latex_group_size: int = 3, input_units: Mapping[str, str] | None = None, result_unit: str | None = None, + native_group_width: bool = True, ) -> None: """Generate a LaTeX table for error propagation results.""" + # App-side grouping when the engine can't vary the siunitx group WIDTH (non-native) and + # grouping is on in siunitx (non-dcolumn) mode: pre-group each numeric cell + use plain r + # columns instead of S columns siunitx would re-group at a fixed 3. + _group = max(0, int(latex_group_size)) + app_group = (not native_group_width) and (not use_dcolumn) and _group > 0 if used_columns is None: header_indices = list(range(len(headers))) else: @@ -234,6 +245,15 @@ def generate_error_propagation_table( ) formatted_result_column.append(result_formatted) column_lengths[-1] = max(column_lengths[-1], _string_length_hint(result_formatted)) + if app_group: + def _wrap(cell: str) -> str: + # A non-finite value renders as a \multicolumn literal cell; wrapping it + # in \text{...} is invalid TeX ("Misplaced \omit") — pass it through. + if "\\multicolumn" in cell: + return cell + return "\\text{" + group_digits_both_sides(cell, _group) + "}" + formatted_columns = [[_wrap(c) for c in col] for col in formatted_columns] + formatted_result_column = [_wrap(c) for c in formatted_result_column] page_w, _ = _estimate_page_geometry(column_lengths, len(parsed_data) + 6) cjk_segments = [ caption if caption else "", @@ -250,6 +270,7 @@ def generate_error_propagation_table( include_dcolumn=use_dcolumn, needs_cjk=needs_cjk, latex_group_size=latex_group_size, + native_group_width=native_group_width, ) header_cols = ["$n$"] @@ -275,6 +296,9 @@ def generate_error_propagation_table( "result_col", ) table_format = "c " + " ".join(data_formats) + " " + result_format + elif app_group: + # Cells are pre-grouped + wrapped in \text{}; plain right-aligned columns. + table_format = "c " + " ".join(["r"] * len(formatted_columns)) + " r" else: data_formats = [_siunitx_column_spec(col_data[start_row:end_row]) for col_data in formatted_columns] result_format = _siunitx_column_spec(formatted_result_column[start_row:end_row]) diff --git a/datalab_latex/latex_tables_extrapolation.py b/datalab_latex/latex_tables_extrapolation.py index e4814476..9bf14114 100644 --- a/datalab_latex/latex_tables_extrapolation.py +++ b/datalab_latex/latex_tables_extrapolation.py @@ -23,6 +23,7 @@ _siunitx_column_spec, calculate_dcolumn_format_for_column, format_result_with_uncertainty_latex, + group_digits_both_sides, ) from .latex_tables_common import ( _apply_aliases, @@ -354,12 +355,18 @@ def generate_latex_table( table_segments: list[tuple[int, int]] | None = None, result_uncertainty_digits: int | None = None, latex_group_size: int = 3, + native_group_width: bool = True, ) -> None: """Generate a LaTeX table with the data and extrapolation results.""" headers = list(headers) data_rows = list(data_rows) extrapolated_results = list(extrapolated_results) latex_content: list[str] = [] + # App-side grouping when the engine can't vary the siunitx group WIDTH (non-native) and + # grouping is on in siunitx (non-dcolumn) mode: pre-group each numeric cell + use plain r + # columns instead of S columns siunitx would re-group at a fixed 3. + _group = max(0, int(latex_group_size)) + app_group = (not native_group_width) and (not use_dcolumn) and _group > 0 column_count = len(headers) formatted_data_columns: list[list[str]] = [[] for _ in range(column_count)] @@ -394,6 +401,16 @@ def generate_latex_table( ) formatted_result_strings.append(result_formatted) + if app_group: + def _wrap(cell: str) -> str: + # A non-finite value renders as a \multicolumn literal cell; wrapping it + # in \text{...} is invalid TeX ("Misplaced \omit") — pass it through. + if "\\multicolumn" in cell: + return cell + return "\\text{" + group_digits_both_sides(cell, _group) + "}" + formatted_data_columns = [[_wrap(c) for c in col] for col in formatted_data_columns] + formatted_result_strings = [_wrap(c) for c in formatted_result_strings] + for col_strings in formatted_data_columns: column_lengths.append(max((_string_length_hint(s) for s in col_strings), default=6)) column_lengths.append(max((_string_length_hint(s) for s in formatted_result_strings), default=8)) @@ -408,6 +425,7 @@ def generate_latex_table( include_dcolumn=use_dcolumn, needs_cjk=needs_cjk, latex_group_size=latex_group_size, + native_group_width=native_group_width, ) ) @@ -434,6 +452,10 @@ def generate_latex_table( formatted_result_strings[start_row:end_row], f"extrapolation_result_{block_index}", ) + elif app_group: + # Cells are pre-grouped + wrapped in \text{}; plain right-aligned columns. + data_format_block = " ".join(["r"] * column_count) + result_format = "r" else: data_formats = [ _siunitx_column_spec(formatted_data_columns[col_idx][start_row:end_row]) diff --git a/datalab_latex/latex_tables_fitting.py b/datalab_latex/latex_tables_fitting.py index 9b03b323..42c1aaa7 100644 --- a/datalab_latex/latex_tables_fitting.py +++ b/datalab_latex/latex_tables_fitting.py @@ -1,13 +1,17 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from typing import Any from mpmath import mp from shared.latex_escaping import latex_escape as _canonical_latex_escape -from .latex_formatting import calculate_dcolumn_format_for_column, siunitx_column_spec +from .latex_formatting import ( + calculate_dcolumn_format_for_column, + group_digits_both_sides, + siunitx_column_spec, +) _NUMERIC_COLUMNS = ("chi2", "reduced_chi2", "aic", "bic", "rmse", "r2") @@ -17,10 +21,24 @@ def build_fitting_comparison_latex_block( *, use_dcolumn: bool, caption_text: str = "Selected model comparison", + latex_group_size: int = 3, + native_group_width: bool = True, ) -> list[str]: """Build a shared LaTeX table block for selected-fit comparison rows.""" row_list = [dict(row) for row in rows] + # App-side grouping when the engine can't vary the siunitx group WIDTH (non-native) and + # grouping is on in siunitx (non-dcolumn) mode: pre-group each numeric metric cell + use + # plain r metric columns instead of S columns siunitx would re-group at a fixed 3 + # (dual-model review F2). + _group = max(0, int(latex_group_size)) + app_group = (not native_group_width) and (not use_dcolumn) and _group > 0 + + def group_cell(cell: str) -> str: + if app_group and _is_numeric_latex_cell(cell): + return "\\text{" + group_digits_both_sides(cell, _group) + "}" + return cell + value_cells = [ _metric_text(row.get(column)) for row in row_list @@ -31,6 +49,8 @@ def build_fitting_comparison_latex_block( value_cells = ["0"] if use_dcolumn: numeric_spec = calculate_dcolumn_format_for_column(value_cells, "fit_comparison_values") + elif app_group: + numeric_spec = "r" else: numeric_spec = siunitx_column_spec(value_cells) metric_specs = " ".join(numeric_spec for _ in _NUMERIC_COLUMNS) @@ -55,7 +75,7 @@ def build_fitting_comparison_latex_block( "\\midrule", ] for row in row_list: - lines.append(_comparison_latex_row(row)) + lines.append(_comparison_latex_row(row, group_cell=group_cell)) lines.extend( [ "\\bottomrule", @@ -73,26 +93,32 @@ def latex_escape(text: object) -> str: return _canonical_latex_escape(text) -def _comparison_latex_row(row: Mapping[str, Any]) -> str: +def _comparison_latex_row( + row: Mapping[str, Any], *, group_cell: Callable[[str], str] | None = None +) -> str: cells = [ latex_escape(row.get("order", "")), latex_escape(row.get("model_label", "")), latex_escape(row.get("status", "")), latex_escape(row.get("free_parameters", "")), - *[_latex_metric_cell(row.get(column)) for column in _NUMERIC_COLUMNS], + *[_latex_metric_cell(row.get(column), group_cell=group_cell) for column in _NUMERIC_COLUMNS], _latex_text_cell(row.get("warnings", "")), _latex_text_cell(row.get("error", "")), ] return " & ".join(cells) + " \\\\" -def _latex_metric_cell(value: Any) -> str: +def _latex_metric_cell( + value: Any, *, group_cell: Callable[[str], str] | None = None +) -> str: text = _metric_text(value) if not text: return "\\multicolumn{1}{c}{}" if not _is_numeric_latex_cell(text): return f"\\multicolumn{{1}}{{c}}{{{latex_escape(text)}}}" - return text + # group_cell (when the engine can't do native grouping) pre-groups the numeric cell + + # wraps it in \text{} so a plain r column renders the grouping. + return group_cell(text) if group_cell is not None else text def _metric_text(value: Any) -> str: diff --git a/datalab_latex/latex_tables_root.py b/datalab_latex/latex_tables_root.py index b678161e..a04c4e55 100644 --- a/datalab_latex/latex_tables_root.py +++ b/datalab_latex/latex_tables_root.py @@ -9,6 +9,7 @@ from datalab_latex.latex_formatting import ( calculate_dcolumn_format_for_column, format_value_for_latex_file, + group_digits_both_sides, siunitx_column_spec, ) from datalab_latex.sisetup_block import build_sisetup_block @@ -24,7 +25,12 @@ def build_root_latex_document( include_dcolumn: bool = False, language: str = "zh", root_units: Mapping[str, str] | None = None, + native_group_width: bool = True, ) -> str: + # native_group_width True → the engine honours siunitx digit-group-size → emit it (native + # S-column variable-width grouping). False → the engine can't (bundled Tectonic) → the + # cells are pre-grouped app-side, so don't emit the key. + emit_dgs = bool(native_group_width and not include_dcolumn and int(group_size) > 0) lines = [ "\\documentclass{article}", "\\usepackage[UTF8]{ctex}" if language == "zh" else "", @@ -32,7 +38,9 @@ def build_root_latex_document( "\\usepackage{dcolumn}" if include_dcolumn else "", "\\newcolumntype{d}[1]{D{.}{.}{#1}}" if include_dcolumn else "", "\\usepackage{siunitx}", - build_sisetup_block(group_size=group_size, include_dcolumn=include_dcolumn).rstrip(), + build_sisetup_block( + group_size=group_size, include_dcolumn=include_dcolumn, emit_digit_group_size=emit_dgs + ).rstrip(), "\\begin{document}", ] lines = [line for line in lines if line] @@ -47,6 +55,7 @@ def build_root_latex_document( language=language, include_dcolumn=include_dcolumn, root_units=root_units, + native_group_width=native_group_width, ) ) lines.append("\\end{document}") @@ -62,9 +71,14 @@ def _root_table( language: str, include_dcolumn: bool, root_units: Mapping[str, str] | None, + native_group_width: bool = True, ) -> list[str]: include_unit_column = bool(root_units) include_failure_column = any(_text(row.get("failure", "")).strip() for row in rows) + # App-side grouping when the engine can't vary the siunitx group WIDTH (non-native) and + # grouping is on in siunitx (non-dcolumn) mode: pre-group the value cell + use a plain r + # column instead of an S column siunitx would re-group at a fixed 3. + app_group = (not native_group_width) and (not include_dcolumn) and group_size > 0 headers = _headers( language, include_unit_column=include_unit_column, @@ -81,9 +95,22 @@ def _root_table( ) for row in rows ] - value_spec = calculate_dcolumn_format_for_column(value_cells, "root_value") if rows and include_dcolumn else "l" - if rows and not include_dcolumn: + if app_group: + # Skip \multicolumn literal cells (non-finite values): wrapping them in + # \text{...} is invalid TeX ("Misplaced \omit"). + value_cells = [ + cell if "\\multicolumn" in cell + else "\\text{" + group_digits_both_sides(cell, group_size) + "}" + for cell in value_cells + ] + if rows and include_dcolumn: + value_spec = calculate_dcolumn_format_for_column(value_cells, "root_value") + elif rows and app_group: + value_spec = "r" + elif rows: value_spec = siunitx_column_spec(value_cells) + else: + value_spec = "l" header_cells = [_escape_latex(header) for header in headers] value_header_index = 4 if include_unit_column else 3 header_cells[value_header_index] = "\\multicolumn{1}{c}{" + header_cells[value_header_index] + "}" @@ -97,7 +124,21 @@ def _root_table( " & ".join(header_cells) + r" \\", "\\midrule", ] - for row in rows: + for row_idx, row in enumerate(rows): + # Use the value cell computed above (already grouped app-side when needed) rather + # than recomputing — keeps the cell and the column-format estimate consistent. Note + # value_cells was built with include_dcolumn=False; in dcolumn mode re-format it. + if include_dcolumn: + value_cell = _number_with_uncertainty( + row.get("value", ""), + row.get("uncertainty", ""), + digits=digits, + uncertainty_digits=uncertainty_digits, + group_size=group_size, + include_dcolumn=True, + ) + else: + value_cell = value_cells[row_idx] lines.append( " & ".join( [ @@ -109,14 +150,7 @@ def _root_table( if include_unit_column else [] ), - _number_with_uncertainty( - row.get("value", ""), - row.get("uncertainty", ""), - digits=digits, - uncertainty_digits=uncertainty_digits, - group_size=group_size, - include_dcolumn=include_dcolumn, - ), + value_cell, _escape_latex(_text(row.get("backend", ""))), _escape_latex(_text(row.get("mode", ""))), *( diff --git a/datalab_latex/latex_tables_statistics_grouped.py b/datalab_latex/latex_tables_statistics_grouped.py index 2c041e36..300fda59 100644 --- a/datalab_latex/latex_tables_statistics_grouped.py +++ b/datalab_latex/latex_tables_statistics_grouped.py @@ -27,6 +27,7 @@ def generate_statistics_grouped_latex( uncertainty_digits: int | None = None, latex_group_size: int = 3, units: Mapping[str, Any] | None = None, + native_group_width: bool = True, ) -> str: """Generate a standalone LaTeX document for grouped statistics payloads.""" @@ -78,6 +79,7 @@ def generate_statistics_grouped_latex( include_dcolumn=use_dcolumn, needs_cjk=_needs_cjk_support(*(str(segment) for segment in text_segments)), latex_group_size=group_size, + native_group_width=native_group_width, ) value_columns = ", ".join(str(column) for column in payload["value_columns"]) lines.extend( diff --git a/datalab_latex/latex_tables_statistics_matrix.py b/datalab_latex/latex_tables_statistics_matrix.py index 7cc92958..060a7290 100644 --- a/datalab_latex/latex_tables_statistics_matrix.py +++ b/datalab_latex/latex_tables_statistics_matrix.py @@ -21,6 +21,7 @@ def generate_statistics_matrix_latex( use_dcolumn: bool = True, latex_group_size: int = 3, units: Mapping[str, Any] | None = None, + native_group_width: bool = True, ) -> str: """Generate a standalone LaTeX document for statistics matrix payloads.""" @@ -36,6 +37,7 @@ def generate_statistics_matrix_latex( include_dcolumn=use_dcolumn, needs_cjk=any(_contains_cjk_text(column) for column in columns + (caption_text,)), latex_group_size=latex_group_size, + native_group_width=native_group_width, ) lines.extend( [ diff --git a/datalab_latex/sisetup_block.py b/datalab_latex/sisetup_block.py index ba989074..172a5a42 100644 --- a/datalab_latex/sisetup_block.py +++ b/datalab_latex/sisetup_block.py @@ -27,6 +27,7 @@ def build_sisetup_block( *, group_size: int, include_dcolumn: bool, + emit_digit_group_size: bool | None = None, ) -> str: """Return the preamble block for siunitx number formatting. @@ -85,7 +86,10 @@ def build_sisetup_block( # guard below. lines.extend( [ - " group-digits = decimal,", + # ``all`` groups BOTH the integer and decimal parts (thousands separators); + # ``decimal`` grouped only the fractional digits, so integers like 12345678 + # rendered ungrouped and users saw "grouping does nothing". + " group-digits = all,", r" group-separator = {\,},", f" group-minimum-digits = {group_size},", " tight-spacing = true,", @@ -94,35 +98,27 @@ def build_sisetup_block( ] ) - if group_size != 3: - # ``digit-group-size`` is a siunitx-v3 key but its presence in - # the source tree predates its activation in the dispatcher: - # siunitx 3.0.49 (date 2022-02-15) — the version Tectonic - # currently bundles — has ``digit-group-size`` defined in - # siunitx.sty yet still rejects ``\sisetup{digit-group-size = N}`` - # at runtime with ``LaTeX3 Error: The key 'siunitx/digit-group- - # size' is unknown``. The key reaches a working dispatcher - # only in later 3.x releases (verified working on 3.4.14 - # from 2025-07-09). - # - # The cutoff was originally pinned to 2020/01/01 (siunitx v3 - # introduction date), then 2020/02/08 (3.0.0 release). Both - # were too early — the 2022-02-15 Tectonic siunitx slips past - # those guards and fires the override against an installation - # that doesn't honour the key. ``2024/01/01`` is the empirical - # safe cutoff: confirmed Tectonic-bundled v3.0.49 evaluates as - # earlier (override skipped, fall back to size 3 default which - # still compiles) and TeX Live 2025 v3.4.14 evaluates as later - # (override fires, requested size honoured). - # - # ``\@ifpackagelater`` is an internal LaTeX2e command, so it - # has to live inside ``\makeatletter ... \makeatother``. - # Previous revisions tried wrapping in ``\begingroup ... \endgroup`` - # for catcode-flip safety; that was a regression because TeX - # groups also scope ``\sisetup``'s package-state assignments, - # silently reverting the override. Plain - # ``\makeatletter ... \makeatother`` is the right pattern for - # a preamble-level package-state mutation. + # ``digit-group-size`` sets the WIDTH of each group (not just the threshold). It is a + # siunitx-v3 key that some v3 builds (Tectonic-bundled 3.0.49) still REJECT at runtime + # with ``LaTeX3 Error: The key 'siunitx/digit-group-size' is unknown`` while newer builds + # (TeX Live 3.4.14) honour it. Whether to emit it: + # emit_digit_group_size is True -> the app PROBED the engine and knows it is honoured; + # emit UNGUARDED (probe is authoritative). + # emit_digit_group_size is False -> probed as NOT honoured; never emit (doc must still + # compile; app-side text grouping handles width). + # emit_digit_group_size is None -> no probe result; fall back to the legacy + # \@ifpackagelater date heuristic (backward compatible + # for callers not yet probe-aware). Skipped when the + # requested size is 3 (both v2/v3 default to 3 anyway). + if emit_digit_group_size is True: + lines.append(f"\\sisetup{{digit-group-size = {group_size}}}") + elif emit_digit_group_size is None and group_size != 3: + # ``\@ifpackagelater`` is an internal LaTeX2e command, so it has to live inside + # ``\makeatletter ... \makeatother``. NOT wrapped in ``\begingroup ... \endgroup``: + # TeX groups scope ``\sisetup``'s package-state assignments and would silently + # revert the override at ``\endgroup`` time. 2024/01/01 is the empirical cutoff: + # Tectonic-bundled v3.0.49 evaluates as earlier (skipped → default size 3, still + # compiles), TeX Live v3.4.14 as later (fires → requested size honoured). lines.append(r"\makeatletter") lines.append( r"\@ifpackagelater{siunitx}{2024/01/01}{" diff --git a/docs/DATALAB_WEB_GUIDE.en.md b/docs/DATALAB_WEB_GUIDE.en.md index aead6f5a..4822b964 100644 --- a/docs/DATALAB_WEB_GUIDE.en.md +++ b/docs/DATALAB_WEB_GUIDE.en.md @@ -314,13 +314,18 @@ pip install gunicorn # 2. Start Gunicorn (recommended: use the bundled gunicorn.conf.py, which sizes # workers from the CPU count automatically) -gunicorn -c gunicorn.conf.py app_web.server:app +gunicorn -c gunicorn.conf.py 'app_web.server:create_app()' # Why multiple workers: mpmath's precision (mp.dps) is process-global, so each # worker handles one fit at a time. Concurrency comes from multiple worker # PROCESSES (not threads), so one user's long fit can't block everyone else. # gunicorn.conf.py defaults to 2*cores+1 with a FLOOR of 2 workers; override # with WEB_CONCURRENCY. Manual form: gunicorn -w 9 ... (a 4-core example). +# Note: the SSE rate-limiter and collab session registry are per-worker +# in-memory state — the DoS budget is roughly RATE_MAX_REQUESTS × workers +# (for a strict global limit, enforce it at the nginx limit_req layer), +# and multi-worker collaboration needs sticky sessions plus a shared store +# (Redis — see the collab extra in pyproject.toml). # 3. Configure Nginx reverse proxy # /etc/nginx/sites-available/datalab @@ -359,7 +364,7 @@ Environment="DATALAB_PORT=8000" # Behind the Nginx reverse proxy above: trust X-Forwarded-For so per-IP rate # limiting uses the real client IP, not the proxy's. Environment="DATALAB_TRUST_PROXY_HEADERS=1" -ExecStart=/usr/bin/gunicorn -c gunicorn.conf.py app_web.server:app +ExecStart=/usr/bin/gunicorn -c gunicorn.conf.py 'app_web.server:create_app()' Restart=always [Install] @@ -505,7 +510,7 @@ Recommended Gunicorn worker count: - Example: 4-core CPU → 9 workers ```bash -gunicorn -w 9 -b 127.0.0.1:8000 app_web.server:app +gunicorn -w 9 -b 127.0.0.1:8000 'app_web.server:create_app()' ``` #### 8.2 Caching diff --git a/docs/DATALAB_WEB_GUIDE.md b/docs/DATALAB_WEB_GUIDE.md index b7d44bb7..4f4d2bf0 100644 --- a/docs/DATALAB_WEB_GUIDE.md +++ b/docs/DATALAB_WEB_GUIDE.md @@ -313,12 +313,15 @@ export DATALAB_DEBUG=1 pip install gunicorn # 2. 启动 Gunicorn(推荐:用仓库自带的 gunicorn.conf.py,worker 数按核心自动计算) -gunicorn -c gunicorn.conf.py app_web.server:app +gunicorn -c gunicorn.conf.py 'app_web.server:create_app()' # 说明:mpmath 的精度(mp.dps)是进程全局的,每个 worker 同一时刻只处理一个拟合。 # 因此靠“多 worker 进程”而非线程来支撑并发——这样一个用户的长拟合不会阻塞其他人。 # gunicorn.conf.py 默认按 2×核心数+1 计算并**至少 2 个 worker**;可用 WEB_CONCURRENCY 覆盖。 -# 若需手动指定:gunicorn -w 9 -b 127.0.0.1:8000 app_web.server:app(4 核示例) +# 若需手动指定:gunicorn -w 9 -b 127.0.0.1:8000 'app_web.server:create_app()'(4 核示例) +# 注意:SSE 限流器与协作会话注册表按 worker 各自保存在内存中——DoS 限流额度约为 +# RATE_MAX_REQUESTS×worker 数(严格全局限流请在 nginx limit_req 层做);多 worker +# 协作需要粘性会话加共享存储(Redis,见 pyproject.toml 的 collab extra)。 # 3. 配置 Nginx 反向代理 # /etc/nginx/sites-available/datalab @@ -356,7 +359,7 @@ Environment="DATALAB_HOST=127.0.0.1" Environment="DATALAB_PORT=8000" # 位于上面的 Nginx 反向代理之后:信任 X-Forwarded-For,使限流按真实客户端 IP 生效。 Environment="DATALAB_TRUST_PROXY_HEADERS=1" -ExecStart=/usr/bin/gunicorn -c gunicorn.conf.py app_web.server:app +ExecStart=/usr/bin/gunicorn -c gunicorn.conf.py 'app_web.server:create_app()' Restart=always [Install] @@ -502,7 +505,7 @@ Gunicorn worker 数量建议: - 示例:4 核 CPU → 9 workers ```bash -gunicorn -w 9 -b 127.0.0.1:8000 app_web.server:app +gunicorn -w 9 -b 127.0.0.1:8000 'app_web.server:create_app()' ``` #### 8.2 缓存策略 diff --git a/docs/SWARM_REVIEW_2026.md b/docs/SWARM_REVIEW_2026.md new file mode 100644 index 00000000..c9dd9f9d --- /dev/null +++ b/docs/SWARM_REVIEW_2026.md @@ -0,0 +1,347 @@ +> Generated 2026-07-03 by a multi-agent swarm review (11 Claude dimension reviewers + Codex external pass). Every finding adversarially verified by 2 independent skeptics (86 candidates → 56 survived, 30 refuted), then EVERY finding re-verified line-by-line against the code (0 overturned, 40+ precision fixes applied). +> **External dual-model adversarial review: PASSED** — Codex (`VERDICT: PASS`, 0 disputes) and Gemini 3.1 Pro via Antigravity (all 9 refutation attempts failed; "100% factual"), both on 2026-07-03. Plan/analysis document — no code changed. See §六 for methodology. + +# DataLab 全面蜂群审阅报告 + +## 一、执行摘要 + +DataLab 的核心数学层(`extrapolation_methods/`、`fitting/`、`datalab_core/`)架构清晰、精度纪律(`precision_guard`)执行到位,未发现数值正确性层面的严重缺陷——整体健康度良好。真正值得优先处理的问题集中在**部署可用性**与**Web 并发架构**:文档中给运维的生产启动命令(`gunicorn ... app_web.server:app`)指向一个根本不存在的符号,照做即无法启动;而同一份文档推荐的 `-w 4` 多进程部署会静默破坏进程内状态的 SSE 限流器与协作会话注册表(这既是功能 bug 也是 DoS 控制被绕过的安全问题)。第二个主题是**GUI/计算分层的裂缝**:扩展统计工作流在 Qt UI 线程上同步跑高精度计算冻结界面、顶部工具栏 Run/Stop 按钮与真实运行态脱节甚至“Run 键静默停止任务”、长任务缺乏进度反馈。第三个主题是**声称的“单一数据源”名不副实**——`ui_specs.py`、双语 `/` 分隔、`{{占位符}}` 替换、per-mode 前端胶水在桌面与 Web 各写一遍,正是项目自己想防的漂移。第四是**加速的诚实结论**:鉴于 mpmath 的任意精度本质,GPU 基本无用;真正的免费提速是安装 `gmpy2`(2–10x,零代码改动),其次是接入已经写好却处于死代码状态的 `sampling_parallel.py`。总体建议:先修 P0 部署与并发文档(低工作量、高影响),再补 GUI 分层与进度反馈,加速工作从 gmpy2 起步而非 GPU。 + +## 二、按严重度排序的问题清单 + +### [HIGH] 文档给运维的生产 WSGI 启动命令指向不存在的 `app_web.server:app`,gunicorn/waitress 无法启动 + +> **✅ 已修复(2026-07-03,分支 `fix/p0-deploy-wsgi`)** —— 全部 5 个部署面(deploy.en/zh、DATALAB_WEB_GUIDE.en/zh、gunicorn.conf.py)的 `app_web.server:app` 已改为工厂形式 `'app_web.server:create_app()'`(waitress 用 `--call app_web.server:create_app`);新增 `tests/test_deploy_docs_wsgi_targets.py` 契约测试(解析所有部署面、断言每个目标可解析为 Flask app、禁止裸 `:app`)。**Codex + Gemini 3.1 Pro 双外部审阅通过。** + +- **证据**: `docs/web/deploy.en.md:59`(及 :96、:115、`deploy.zh.md`)指示 `gunicorn -w 4 -b 127.0.0.1:8000 app_web.server:app` / `waitress-serve ... app_web.server:app`;但 `app_web/server.py` 只暴露 `create_app()`(:60)和 `create_app_with_socketio()`(:122),没有模块级 `app`/`application` 符号。`import app_web.server; hasattr(s,'app')` → False。 +- **影响**: 运维照文档逐字执行,gunicorn 立即以 `Failed to find attribute 'app' in 'app_web.server'` 退出,生产永不启动。仅 dev 路径 `python app_web/server.py` 可用。 +- **建议**: 新增模块级 `app = create_app()`(或 `wsgi.py` 定义 `application = create_app()`)并更新文档;或改文档为工厂形式 `gunicorn -w 4 'app_web.server:create_app()'` / `waitress-serve --call app_web.server:create_app`。加一个导入文档中确切目标字符串的冒烟测试。 +- **工作量**: S | **来源**: claude | **验证**: CONFIRMED + +### [HIGH] 进程内 SSE 限流器与协作会话注册表是 per-process,被任何多 worker 部署静默破坏,而文档恰恰推荐 `gunicorn -w 4`(功能 + DoS 安全) + +> **✅ 已按"多 worker + 诚实文档化权衡"修复(2026-07-03)** —— 经外部审阅发现:代码其实**有意**多进程(`gunicorn.conf.py:60` `_resolve_workers()` 下限设为 2,`sse.py:68` 注明"scale by processes, not threads",因为 `_MP_SERIAL_LOCK` 把 mpmath 计算按进程串行化)。因此保留多 worker,但在全部部署面加了诚实的权衡说明:限流按 worker 计(有效额度≈`RATE_MAX_REQUESTS`×worker 数,严格全局限流应在 nginx `limit_req` 层做)、多 worker 协作需粘性会话 + 共享存储(Redis)。**并未**盲目改单 worker(那会串行化所有用户计算)。**Codex + Gemini 3.1 Pro 双外部审阅通过。** + +- **证据**: SSE 限流状态 `_RATE_HISTORY: dict[str, collections.deque]`(`app_web/blueprints/sse.py:104`)加 `threading.Lock`(:105)均为进程本地;协作房间 `self._sessions`(`app_web/blueprints/collaborate.py:253`),其自身注释承认“in-memory and tied to one worker process — multi-worker collab would need Redis”(:42-43)。但 `deploy.en.md:59`(及 :96、`deploy.zh.md:58/:95`)推荐 `-w 4`;且该命令目标 `app_web.server:app` 并不存在——`app` 仅在 `server.py` 的 `__main__` 块内定义,命令按原样无法启动(另一处文档缺陷),入口一旦修正为工厂调用,多 worker 状态分裂即生效。 +- **影响**: 4 workers 下 SSE 实际速率预算 ≈4×(同一客户端散列到不同 worker 绕过限制,而限流器是 DoS 控制,:90-109,安全相关);worker A 铸造的 collab join_token 在 worker B 不可见,协作非确定性失败。 +- **失败场景**: (仅适用于以多 worker 方式部署 SocketIO app 的场景——文档 gunicorn 目标对应的普通 `create_app()` 根本不注册 `/collab` 蓝图,只有 `create_app_with_socketio` 注册,`app_web/server.py:148-163`)用户 A 建会话(token 在 worker 2),用户 B 加入落到 worker 0 → “session not found”;攻击者跨 4 worker 发 40 次 SSE fit/min 永不触发 10/min 限制。 +- **建议**: 文档明确多 worker 需 sticky sessions + 共享存储(Redis)支撑限流器与 collab;至少在这两个子系统假设单进程状态期间停止推荐 `-w 4`。长期以 Redis 支撑(collab extra 已注明需 Redis,`pyproject.toml:80-83`)。 +- **工作量**: L | **来源**: claude | **验证**: CONFIRMED + +> **多源印证 / 主题关联**: 本条与下方“全局 mpmath 锁使 Web 并发上限=进程数”和“`__main__` 默认启用 SocketIO/collab”共同构成同一个 **Web 并发/部署架构** 主题——三者叠加意味着当前推荐的部署姿态在功能、安全、容量规划三方面都站不住,应作为一个 P0 波次一起处理。 + +### [MEDIUM] 长时高精度任务除静态 “Running” 徽章外无任何进度反馈 + +- **证据**: 运行中反馈仅:配置栏按钮翻转为 “Stop”(`window_extrapolation_mixin.py:135`)、结果徽章文字 “计算中/Running”(`workbench_results.py:284,332`)、状态条 “运行中/Running”(`shell_layout.py:37-39`)。运行路径(`window.py:2741` `_start_worker_with_workbench_result_state`)无 QProgressBar、无 busy spinner、无耗时计数。而 LaTeX/Tectonic 反而用了 QProgressDialog(`window_latex_compile_mixin.py:171,466`),主 mpmath 任务却没有——后者在高 dps(上限 1_000_000)恰是可跑数十秒至数分钟的操作。 +- **影响**: 用户无法判断重型 LM 或 Wynn-ε 任务是在工作还是卡死,也不知已运行多久。 +- **建议**: 在结果概览/状态条加不确定态 QProgressBar 或 busy 指示(复用现有 running-state 钩子),配 QElapsedTimer + 1s QTimer 的耗时标签;对已发 `log_ready` 的 worker 把最新行作为实时副标题。对齐应用已有的 LaTeX 编译反馈。 +- **工作量**: M | **来源**: claude | **验证**: CONFIRMED + +### [MEDIUM] 顶部工具栏 Run/Stop 从不反映运行态;工具栏 Run 会在无确认提示下停止正在运行的任务 + +- **证据**: 工具栏建两个始终可见按钮 `workbench_run_button`(方法 `run_extrapolation`/`run_calculation`)与 `workbench_stop_button`(`stop_calculation`/`_stop_current_worker`)(`workbench_toolbar.py:169-192`),全仓 grep 无对二者的 setVisible/setEnabled。而 `run_calculation()` 是切换:worker 运行时调用 `_stop_current_worker()` 并返回(`window_extrapolation_mixin.py:180-184`)。此外 `run_extrapolation`/`stop_calculation` 并不存在(仅 `run_calculation`/`_stop_current_worker` 可解析)。 +- **失败场景**: 启动长计算后点顶部蓝色 “Run”(仍标 Run、仍启用),`run_calculation()` 见 worker 运行即调 `_stop_current_worker()` 无确认地中止在途任务(仅日志提示“正在停止任务...”)——与标签承诺相反。 +- **影响**: 两个运行控件对状态判断不一致(配置栏主按钮通过 `datalab_run_state` 正确切换,工具栏不切换);idle 时工具栏 Stop 是死 no-op。 +- **建议**: 用单一 run-state 信号驱动工具栏按钮:idle 只显示/启用 Run,运行中只显示/启用 Stop(在已存在的 `_set_button_to_stop_mode`/`_set_button_to_run_mode` 中切换)。删掉幽灵方法名 `run_extrapolation`/`stop_calculation`。 +- **工作量**: S | **来源**: claude | **验证**: CONFIRMED + +### [MEDIUM] 扩展统计工作流在 Qt UI 线程同步跑计算,冻结 GUI 且无法取消 + +- **证据**: 对 `_DIRECT_STATISTICS_WORKFLOWS`(bootstrap_confidence_intervals、covariance_correlation、grouped_statistics、hypothesis_tests、time_series_rolling;`window_extrapolation_mixin.py:28-34`)分发器内联调用 `self._run_statistics_mode(...)`(:393),而非像其他 JobMode 那样交给后台 QThread worker(extrapolation/error/标准 statistics 构建 CalcJob + `CalcWorker`,:380-388;fitting 用 `FitWorker`;root_solving 用 `RootSolvingWorker`,:654)。这些方法直接在 UI 线程调 `create_core_session_service().submit(...)`(`window_statistics_mixin.py:504/505,607/608,800/895,1158/1159,1254/1276`)。Bootstrap CI 在 mpmath 精度下可重采样数千次,阻塞事件循环。 +- **失败场景**: 选 bootstrap CI、大列多重采样、点计算 → Qt 窗口完全无响应(spinner 冻结、无重绘、无法取消)直到计算结束,macOS/Windows 可能显示 “未响应”。 +- **建议**: 让 direct-statistics 走与标准统计相同的 `CalcWorker(QThread)` 路径,使 `submit()` 离开 UI 线程;并传 `cancellation_checker` 支持取消。这也消除了在 window mixin 里做重计算的分层违规。 +- **工作量**: M | **来源**: claude | **验证**: CONFIRMED + +### [MEDIUM] Fit 梯度用有限差分偏导(每次 2 次额外全评估),未用仓库已有的缓存符号偏导 + +- **证据**: `_build_numeric_gradient_callable`(`fitting/model_parser.py:203`)调 `shared.derivatives.numerical_partial_derivative`,每次跑两次全 `safe_eval`(`shared/derivatives.py:330` f_plus、:335 f_minus)。LM 热循环 `_gradient`(`hp_fitter.py:157-166`)每迭代遍历 N 点,每点 evaluate(1)+ partial(2),k 参数 → 每迭代 ≈k·N·3 次表达式评估。而 `shared/derivatives.py` 已有 `_get_symbolic_partials`/`_build_symbolic_partials`(sympy.diff+lambdify,LRU 缓存 64)产出精确闭式偏导——fitting 从未 import(grep 仅见 `numerical_partial_derivative`)。 +- **影响**: 约 3× 冗余评估,且有限差分步长/截断误差污染 Jacobian/协方差。 +- **建议**: `build_model_specification` 中先试 `_get_symbolic_partials`,命中则用 lambdified callable 作梯度函数,sympy 返 None 时回退数值偏导。约 3× 降评估并提升 Jacobian 精度。 +- **工作量**: L | **来源**: claude | **验证**: CONFIRMED + +### [MEDIUM] 系统不确定度估计重跑整套多 seed 解两遍,丢弃每次重拟合昂贵的协方差/相关误差工作 + +- **证据**: `_estimate_systematic_uncertainty` 对 plus/minus 两方向各调 `solver(perturbed, base_seed)`(`hp_fitter.py:480-485`),即两次完整 `_run_once`。每次 `_run_once` 跑全部 seed 变体过 findroot,并经 `_process_solution`(:656-725)算 `_compute_covariance`(J^T J + `mat ** -1` 矩阵求逆,:347)、`_propagate_dependent_errors`、边界检测、全套统计。但调用方只读 `refit.params`(:496),两次重拟合的协方差/相关误差/统计全部丢弃。精度 80+ 时 k×k 求逆与逐点 Jacobian 填充占主导,白白约 3×。 +- **建议**: 给 `_run_once` 加 `params_only` 快路径(跳过协方差/相关误差/多余统计,仅保留最佳候选选择所需 chi2),供两次系统重拟合使用。 +- **工作量**: M | **来源**: claude | **验证**: CONFIRMED + +### [MEDIUM] median/std/variance 的 bootstrap 每个副本都算整套描述统计 + +- **证据**: `_evaluate_target` 把所有非 mean 目标(median、trimmed_mean、std、variance)都路由到 `compute_statistics(...descriptive_mode...)`(`statistics_bootstrap.py:506-524`)。描述分支(`statistics_compute.py:46+`)无条件算 mean、中心平方、方差、std、完整 `sorted()`、type-7 分位数 q1/median/q3、IQR、MAD,非零方差时还算偏度、峰度——对 std/variance 只用其中一个数。这对每个副本(上限 100000,`BOOTSTRAP_MAX_RESAMPLE_COUNT`)高精度执行。 +- **建议**: 加轻量 per-target 评估器(variance/std: mean + 平方 fsum;median: 单次 `_type7_quantile`;trimmed_mean: 排序+切片),`_evaluate_target` 中分发;完整 `compute_statistics` 仅保留给原样本统计。 +- **工作量**: M | **来源**: claude | **验证**: CONFIRMED + +### [MEDIUM] 批量残差/Jacobian 评估才是真正的内层热点,且为逐点 Python 循环无批处理——正确的加速目标是 CPU 向量化而非 GPU + +- **证据**: `_gradient`(`hp_fitter.py:157-166`)、`_compute_statistics`(:271-275)、`_compute_covariance`(:334-341)都 `for idx,(obs,target) in enumerate(zip(...))` 逐点调 `model.evaluate`/`model.partial`,各走 AST(`expression_engine._evaluate_ast`),梯度还每点每参 2 次 `safe_eval`(`derivatives.py:330,335`)。n 点 k 参每迭代 O(n·k) 全 AST 评估。解析已 lru_cache(`expression_engine.py:151`),成本在 AST 解释 + mp 算术;`model_parser.py:169` 每次重建 scope dict,无批处理。 +- **建议**: 高价值加速是把模型表达式一次编译为向量化闭包一趟评估所有点(低 dps 用 numpy,或融合 mpmath 循环复用单个 scope dict),CPU 侧批处理。GPU 仅在加了低 dps float64 快路径后才有意义。配合 gmpy2 命中真实热点。 +- **工作量**: L | **来源**: claude | **验证**: CONFIRMED + +### [MEDIUM] `ExtrapolationWindow.__init__` 是 ~100 行的上帝构造函数,混杂主题接线、~40 属性、模型启发式与无名时序魔数 + +- **证据**: 构造函数 `window.py:477-579`:窗口尺寸 `resize(1280, 760)`、OS 主题检测+信号/定时器接线(484-500,`setInterval(5000)`)、~40 个裸属性初始化(504-562)、脆弱的 poly-baseline 启发式(519-525)、`QTimer.singleShot(500/1500,...)`(572-573)、退出钩子(574-579)。`500/1500/5000/760` 字面量无文档。3198 行 window 上帝文件的入口,无类型标注削弱 mypy。 +- **建议**: 抽取 `_init_theme_wiring()`/`_init_workspace_state()`/`_init_pdf_state()`(`_init_*` 模式已存在,:566-567 调用),把 `500/1500/5000` 提升为命名模块常量。 +- **工作量**: L | **来源**: claude | **验证**: CONFIRMED + +### [MEDIUM] `ui_specs.py` 自称“single source of truth”,但 Web 前端在 i18n.js + 模板里独立重声明每个非方法标签 + +- **证据**: 模块头声明自己是桌面与 Web 共享的 SINGLE SOURCE OF TRUTH(`shared/ui_specs.py:6-10` 模块 docstring;未被 Web 消费的桌面专属注册表见 :756-937)。实际只有外推**方法参数**规格被共享(`app_web/blueprints/api.py:79-99` 消费 `EXTRAPOLATION_METHOD_SPECS`+`METHOD_DISPLAY_ORDER`)。grep `DESKTOP_FORM_SECTIONS`/`DESKTOP_RESULT_VIEWS`/`DESKTOP_PLOT_SPECS`/`INPUT_DATA_FIELD`/`ERROR_FORMULA_FIELD` 在 `app_web/` 零命中。Web 靠 ~1031 行手维护的 `app_web/static/js/i18n.js` 平行字符串表 + 模板硬编码(`error.html:6` '误差传递 / Error propagation' 与 `i18n.js:172` 重复)。改桌面标签会静默漂移 Web UI,docstring 误导贡献者。 +- **建议**: 要么让 Web 经 JSON 端点消费 `DESKTOP_FORM_SECTIONS/RESULT_VIEWS/PLOT_SPECS`(如 `api_ui_specs` 对方法参数所做),要么修正 header 精确声明哪些注册表共享、哪些桌面专属,并加当共享标签键在 i18n.js 缺失/分歧时失败的一致性测试。别留虚假的 single source of truth 声明。 +- **工作量**: L | **来源**: claude | **验证**: CONFIRMED + +> **多源印证 / 主题关联**: 与下方“双语 ` / ` 分割三处重实现”“`{{占位符}}` 桌面/Web 各写一遍”“per-mode 前端胶水重复”同属 **“单一数据源名不副实”** 主题——项目在参数控件上有正确的单源纪律,却在标签、分隔符、占位符、请求构建四处破例,均为已知会漂移的类别。四条合并看,优先级应提高。 + +### [MEDIUM] 12 个桌面 mixin 共享 ~80 个实例属性却无声明契约(无 Protocol/TYPE_CHECKING 存根),`ExtrapolationWindow` 组合未类型化且脆弱 + +- **证据**: `ExtrapolationWindow`(`window.py:467`)继承 QMainWindow + 7 顶层 mixin(含子 mixin 共 12 个 `window_*_mixin.py`),12 个 mixin 无一用 TYPE_CHECKING 声明借用属性(仅 window_fitting_residuals_mixin.py:90 引用 TYPE_CHECKING,且只用于导入 mpmath)。`WindowStatisticsMixin`(`window_statistics_mixin.py:243`)引用 81 个 `self.` 却只赋值 8 个,扣除该类自身定义的 25 个方法后,其余 53 个由其他 mixin/`window.__init__` 提供且无接口声明。`pyproject.toml:175-182` 仅 shared/fitting/extrapolation_methods/datalab_latex 严格,`app_desktop` 被排除,mypy 无从帮忙。`window.py` 3198 行、`window_statistics_mixin.py` 1922 行,远超用户全局编码准则的 800 行上限(该准则来自 ~/.claude/rules/common/coding-style.md,仓库自身未定文件行数准则)。 +- **建议**: 引入 `_WindowProtocol`(typing.Protocol)或 TYPE_CHECKING-only 基类声明共享属性/方法,各 mixin `if TYPE_CHECKING: class X(_WindowProtocol)`,使跨 mixin 契约显式且 mypy 可检——无需过度拆分文件。 +- **工作量**: M | **来源**: claude | **验证**: CONFIRMED + +### [MEDIUM] 误差传递 LaTeX 表遇任何 inf/NaN 结果(如数据单元格直接含 inf/nan,或求值无异常地产生非有限值)以 ValueError 中止,无有限性守卫 + +- **证据**: `_format_value_for_latex_file` → `_split_mantissa_exponent` → `int(mp.floor(...))` 对非有限输入抛 `ValueError: cannot convert inf or nan to int`(已复现)。`generate_error_propagation_table`(`datalab_latex/latex_tables_error_propagation.py:215-235`)将结果值/不确定度传入格式化时**无 try/except、无 isfinite 过滤**,不同于 `latex_tables_extrapolation.py`(:126/134/137 有 `mp.isfinite` 守卫)与统计模块。 +- **失败场景**: 用户数据单元格含 'inf'/'nan'(UncertainValue/parse 接受,已复现),或计算无异常地产生非有限值 → 结果/输入列含 inf/NaN → `generate_error_propagation_table` 抛 ValueError → 整表与 PDF 导出失败,报晦涩的 'cannot convert inf or nan to int' 而非产出 ∞/NaN 单元格。 +- **建议**: 格式化前守卫非有限值——跳过/替换为占位单元格(`\multicolumn{1}{c}{$\infty$}`/'NaN' 经 `siunitx_safe_cell`)或 try/except 回退转义文本,镜像 `datalab_latex/latex_tables_root.py` 的 `_number_with_uncertainty`(:162,回退在 :187-188)。同样守卫输入单元格循环。 +- **工作量**: S | **来源**: claude | **验证**: CONFIRMED + +### [MEDIUM] 内联公式预览在暗色模式近乎不可见(黑字,无暗色感知颜色) + +- **证据**: 内联预览标签暗色模式用暗背景 `#20242b`(`app_desktop/theme.py:253-258`),但每处桌面预览构建 `RenderRequest` **不带 color**(`formula_preview.py:218` `render_formula_pixmap`、:258 `update_formula_preview_with_empty_text` 只传 source/language/lhs)。`RenderRequest.color` 默认 `#111827`(近黑,`formula_render_service.py:29`),`render_mathtext_png` 就以该色画字。不同于 PDF 预览会在暗色反相(`pdf_preview.py:130-131`),mathtext PNG 从不反相/重着色。 +- **失败场景**: 切暗色主题输入 'a*Exp[-b*x]',内联预览显示近黑公式在暗盒上几乎不可读,仅纯文本源行(遵守暗色,`theme.py:248`)可读。 +- **建议**: 在 `render_formula_pixmap`/`update_formula_preview_with_empty_text` 把主题色接入 RenderRequest(暗色时 `color='#f8fafc'`)。color 是 `_render_desktop_preview_cached` lru_cache 键(`formula_renderer.py:52-58`),明暗分别缓存、无需失效缓存;但还需在主题切换路径触发一次预览刷新(`window.py:2135` `_apply_desktop_theme` 目前不刷新公式预览,仅刷新其他工作台卡片)。 +- **工作量**: S | **来源**: claude | **验证**: CONFIRMED + +### [MEDIUM] 特殊函数半数 safe-eval 白名单无 LaTeX 映射——按原文名逐字渲染(数学斜体) + +- **证据**: 计算白名单(`shared/expression_engine.py:40-74`)接受 Erf/Zeta/Gamma/BesselJ/BesselY/Airy/PolyLog/Hyp0f1/1f1/2f1/Log10/Power,但渲染服务 `_FUNCTION_NAMES`(`datalab_latex/formula_render_service.py:55-73`)只识别三角/双曲/log/exp/sqrt/abs,其余走 `_escape_identifier`(:435-437)。已验证:`_source_to_latex('Erf[x]', language)`→`Erf\left(x\right)`、`'Zeta[s]'`→`Zeta\left(s\right)`、`'BesselJ[0, x]'`→`BesselJ\left(0, x\right)`、`'Log10[x]'`→`Log10\left(x\right)`(无下标)。`shared/formula_latex_export.py` 的 `_FUNCTION_COMMANDS`(:31-47)更小。 +- **失败场景**: 拟合/导出 'A*Erf[b*x] + Zeta[2]',预览与报告 LaTeX 显示 'Erf(...)'、'Zeta(2)' 为普通词,而非 `\operatorname{erf}`、`\zeta(2)`——恰是计算层宣称的特殊函数能力的保真缺口。 +- **建议**: 扩展 `_FUNCTION_NAMES`(及 `_FUNCTION_COMMANDS`)加白名单特殊函数集(Erf→`\operatorname{erf}`、Zeta→`\zeta`、BesselJ/Y 阶作下标、Log10→`\log_{10}`),由单一表驱动、键自 `list_allowed_functions()`,使计算白名单与 LaTeX 映射不能漂移——镜像 expression_registry 一致性测试模式。 +- **工作量**: M | **来源**: claude | **验证**: CONFIRMED + +### [MEDIUM] SSE fit 墙钟超时形同虚设——deadline 从不中断阻塞的 fit,且进程全局 mpmath 锁被全程持有 + +- **证据**: `_single_fit_events` 中 `deadline = time.monotonic() + MAX_SSE_WALLCLOCK_SECONDS`,但整个 fit 在 `with _MP_SERIAL_LOCK, precision_guard(precision): ... envelope = service_factory().submit(request)` 内(`sse.py:416-435`),**无 deadline 传入、无 cancellation_checker**(核心 `SessionService` 构造时支持 `cancellation_checker`,`submit` 内经 `_CancellationToken` 生效,`session.py:114/158,但 SSE 未传)。`if time.monotonic() > deadline:`(:447)只在 submit 完全返回后执行,仅能事后发个装饰性 'Timeout'。同时 `_MP_SERIAL_LOCK`(应用级 `mpmath_lock`)全程被持,阻塞所有其他 mpmath 视图。`MAX_SSE_INPUT_POINTS=5000`、精度仅上限 1000。 +- **失败场景**: GET `/api/fit/stream?x=<5000 病态点>&...&precision=1000`,1000 dps 下 5000 点线性拟合远超 90s 且持锁,同 worker 每个 `/fit` POST 与其他 SSE 请求阻塞至结束;90s 预算从不中途触发。 +- **建议**: 向核心服务传取消检查器(`create_core_session_service(cancellation_checker=lambda: time.monotonic() > deadline)`),使 fitter 内 `check_cancelled()` 真正中止;或用 `KillableProcessTaskRunner` + `timeout_seconds`。docstring 的 DoS 声明(:82-88、:378-381)当前为假,不应依赖。 +- **工作量**: M | **来源**: claude | **验证**: CONFIRMED + +### [MEDIUM] 可杀子进程在 terminate()+kill() 后仍存活时,worker 预算永久泄漏 + +- **证据**: `_finalize_if_process_dead()`(`shared/parallel_backend.py:305-317`)在 :306-307 `if self._process.is_alive(): return` 提前返回,之后才释放预算并注销句柄(:311-317)。停止路径 `_ensure_stopped()`(:297-303)与 `terminate()`(:277-284)做 `terminate();join(1.0);kill();join(1.0)`,每 join 有限 1.0s。若子进程 1s 内未死(不可中断 syscall、负载下慢回收、C 扩展中),`wait()` 的 finally(:274-275)里 `is_alive()` 仍 True,`_release_budget()` 永不调用。`_GLOBAL_WORKER_BUDGET` 永久递减,够多次后 `try_acquire` 失败、`start_killable` 抛 'worker budget exhausted'(:374-375),进程生命周期内禁用所有子进程 fit/root-solving。 +- **失败场景**: CPU/IO 压力下 fit 子进程忽略 SIGTERM,SIGKILL 后两次 1.0s join 都超时,`_finalize_if_process_dead` 提前返回不释放;预算 -1 无恢复;几次后 `_execute_fit_job_payload_subprocess` 对每个后续自洽/隐式 fit 抛 RuntimeError。 +- **建议**: 用一个 `_budget_released` 标志在 `wait()` 的 finally 中确定性释放一次(不依赖观察到进程已死),或在 SIGKILL 后用更长/重试的 join 再放弃(`Process.kill()` 在 POSIX 上本就发送 SIGKILL,换用 `os.kill` 并非升级)。至少在句柄仍存活时 finalize 记 ERROR 日志使泄漏可见。 +- **工作量**: M | **来源**: claude | **验证**: CONFIRMED + +### [MEDIUM] 非 scan 残差容差在高 dps 下下溢,退化为松散的 1e-10 下限 + +- **证据**: 非 scan 残差容差用 `float(mp.eps)` 与 `math.sqrt`(`root_solving/solver.py:850-855`),高 dps 下下溢/丢精度,实际退回松散的 1e-10 下限;scan 模式用 mp 原生精度缩放容差(:862-870)。 +- **失败场景**: 高 dps 下 `float(mp.eps)` 下溢,非 scan 残差容差坍缩为松散 1e-10 而非精度缩放容差。 +- **建议**: 用 `mp.sqrt(mp.eps)` 计算容差,并加高精度残差测试。 +- **工作量**: —(未提供)| **来源**: codex | **验证**: CONFIRMED + +### [LOW] 主 Run 按钮位于可滚动配置栏底部(需滚动才能找到按钮) + +- **证据**: `left_layout` 是可滚动配置栏(`panels.py:343`,QScrollArea AlignTop 最小宽 320 竖滚动条 AsNeeded,`workbench_layout.py:57-65`)。含主 Run 按钮的 `run_section` 最后添加,在 mode/input/output_setup 之后(`panels.py:725-728,1136`)。数据表+选项卡展开、窗口较矮时 Run 按钮被推出视口下方需滚动。仅由顶部工具栏 Run 与 Ctrl+Return(:1130)部分缓解,二者对新用户不明显。 +- **建议**: 将 `run_section` 移出滚动区,作为配置栏 sticky footer(加到栏 frame 而非滚动内容),主操作始终可见。 +- **工作量**: M | **来源**: claude | **验证**: CONFIRMED + +### [LOW] 校验与运行错误以阻塞式模态弹窗呈现,而非贴近出错字段的内联提示 + +- **证据**: 运行路径对输入/配置问题抛一连串 `QMessageBox.critical` 模态:坏 MC seed(`window_extrapolation_mixin.py:348`)、无效输入包(:190)、通用运行错误(:221,228,235,247,252,262,289,297,499)。每个是脱离字段的 OK-only 弹窗。应用已有内联错误面(`workbench_message_surface_style(kind="error")`、`formula_preview_error_surface_style`,`theme.py:177-193,237-242`)用于公式预览,但主运行校验未复用。 +- **建议**: 字段级校验失败(seed/公式/单位/空数据)用现有错误面在相关配置卡下方内联显示,模态 QMessageBox 保留给真正不可恢复/全局失败。 +- **工作量**: L | **来源**: claude | **验证**: CONFIRMED + +### [LOW] 空态/首次运行结果态是裸单行标签,无下一步引导 + +- **证据**: 结果详情空态为单条居中标签 “暂无结果详情/No result details”(`panels.py:1162-1166`),概览 meta 读 “等待计算/Waiting for calculation”(`workbench_results.py:237`)。均不告诉新用户下一步(选模式、输入数据、按 Run)或指向 Examples。TutorialOverlay 模块虽存在(class 定义于 `tutorial_overlay.py:160`,步骤文案 `TUTORIAL_STEPS` 于 :80),但未被任何生产代码调用——仅测试与 theme.py 样式选择器引用,首次运行实际不显示任何引导,空态亦无 in-context CTA。 +- **建议**: 让结果区空态可操作:短提示 + 内联 “Open an example”/“Run” 链接调现有 `open_example_workspace`/`run_calculation`,复用 theme.py 的 muted description 面。 +- **工作量**: S | **来源**: claude | **验证**: CONFIRMED + +### [LOW] 图标式 “?” 帮助按钮只向辅助技术暴露 “?” + +- **证据**: 帮助按钮建为 `QPushButton("?")`,可访问文本注册为字面 “?”(`views/extrapolation.py:63,74`;`panels.py:765`;`views/helpers.py:103`)。不同于工具栏按钮正确设 `setAccessibleName/Description`(`workbench_toolbar.py:86-95`),这些帮助按钮不告诉屏幕阅读器打开什么主题。`use_file_hint_btn` 还设 `FocusPolicy(NoFocus)`(`panels.py:768`)移出键盘 tab 序。 +- **建议**: 给每个 “?” 按钮描述性 accessibleName/description(如 “Help: extrapolation method”)并保持键盘可达,复用工具栏已用的双字符串接线。 +- **工作量**: S | **来源**: claude | **验证**: CONFIRMED + +### [LOW] direct-statistics 调用点不传 cancellation_checker,尽管机制已存在却不可取消 + +- **证据**: `window_statistics_mixin.py` 六处 `create_core_session_service()`(:368,504,607,800,1158,1254)均无参调用,`SessionService.cancellation_checker` 为 None,`submit()` 创建的 ContextVar 取消令牌(`session.py:156-160`)无外部检查器。而 `workers_core.py` 每条 worker 路径都传 `cancellation_checker=_service_cancel_requested`(如 :945-947,1129-1131,1316-1318,1827,2615)。协作式取消设计对这些 UI 线程统计运行是惰性的。 +- **建议**: 当这些工作流移到 worker 线程时,向下穿 stop-checker,并在六处传 `cancellation_checker`,对齐 `workers_core.py` 惯例。 +- **工作量**: S | **来源**: claude | **验证**: CONFIRMED + +### [LOW] SessionService 的重入 busy-guard 实为死代码,因每个调用点都构造全新服务 + +- **证据**: `SessionService.submit()`(`session.py:146-153`)用 `self._active_request_id` 防并发并返 'busy',但无调用方跨并发任务复用实例:Web 每请求新建(`app_web/logic/extrapolation.py:220` 等),桌面每模式/每统计调用点新建(`workers_core.py` 多处、`window_statistics_mixin.py` 六处)。全局 mp.dps 的跨请求并发安全实际由别处提供(Web: `@mpmath_synchronized` 全局锁 `security.py:190-210`;核心: `precision_guard`)。故 busy-guard、last_result、status 保护不了任何东西。 +- **建议**: 要么将 SessionService 记为有意的单次/每任务并删除 busy-guard + 可变 status/last_result;要么若打算共享长寿命服务,让前端持单实例使 guard 有意义。二选一消歧。 +- **工作量**: M | **来源**: claude | **验证**: CONFIRMED + +### [LOW] per-mode 前端胶水(method_options/请求参数组装 + submit/解码编排 + LaTeX/plot 渲染胶水)在桌面与 Web 各写一遍 + +- **证据**: 外推流程实现两次形状几乎相同:Web `app_web/logic/extrapolation.py:190-236` 建 ExtrapolationOptions/method_options(`_method_options_payload`/`_power_config_payload` :106-157)→ `build_extrapolation_request`→submit→`extrapolation_payload_to_rows/_to_results`;桌面 `app_desktop/workers_core.py:580-625`(`_safe_extrapolation_core_request`/`_extrapolation_method_options`)与 :936-957 手工同构。plot 渲染也重复(`app_desktop/workers_core.py:521-577` vs `app_web/logic/plots.py:15-76`)。method_options schema 两文件手镜像,新增选项须两处改否则静默分歧。 +- **建议**: 把 method_options 组装等剩余每前端胶水(请求构建/payload 解码原语已在 `datalab_core/extrapolation.py`)提升到 `datalab_core`/`shared` 的 UI 中立 helper,两前端调用,仅留真正 UI 关切(表单读取、Qt vs base64 plot 交付)。镜像现有参数控件单源纪律。 +- **工作量**: L | **来源**: claude | **验证**: CONFIRMED + +### [LOW] 并行 seed-solve 在每个 worker 任务内重新 pickle 全观测集并从文本重建模型 + +- **证据**: `_solve_variants`(`fitting/hp_fitter.py:762-777`)每 seed 变体建一 `_SeedSolveTask`,各内嵌整份数据集副本(`observations=tuple(dict(obs) for obs in observations)`)。1+2k 变体 → 同一 N 行观测(每格 mp.mpf)被 pickle 并运 1+2k 次。`_solve_seed_variant_task`(:226-250)在每 worker 内调 `build_model_specification` 重解析表达式、重建 k 个梯度 callable。高精度 mp.mpf 序列化昂贵(`sampling_parallel.py:70-76` 故意走字符串规避)。 +- **建议**: 观测/目标一次性发送(字符串化,镜像 sampling_parallel),经 ProcessPoolExecutor initializer 每 worker 重建一次模型 + 观测,每任务仅传 `(variant_index, seed_variant)`;或提高并行阈值使小 fit 跳过 pool。 +- **工作量**: L | **来源**: claude | **验证**: CONFIRMED + +### [LOW] 相关/协方差矩阵同时算 (i,j) 与 (j,i),且每对重算各列均值/方差 + +- **证据**: `_matrix_from_row_provider`(`statistics_matrix.py:415-424`)双重 `for i/for j in range(size)`,每格从头重算 mean_left/mean_right、var_x/var_y(:448-452)。协方差/相关对称,(j,i) 重复 (i,j),约 2× fsum/乘积。listwise 情况下列均值/方差只依赖该列却重算 size 次。高 dps 多列时 O(size²·n),而 O(size·n) 预计算 + 上三角即可。 +- **建议**: 每列均值/方差预计算一次(listwise),只填上三角并镜像到下三角;pairwise 保留 per-pair 均值但跳过冗余 (j,i)。 +- **工作量**: M | **来源**: claude | **验证**: CONFIRMED + +### [LOW] 未安装 gmpy2——同一 mpmath 代码上免费 2–10x 提速,令大部分 GPU 讨论失去意义 + +- **证据**: 全仓及所有 requirement 文件 grep `gmpy2`/`mp.libmp` 零命中。mpmath 导入时自动探测 gmpy2:有则用 GMP 支撑的整数做尾数算术,无则回退纯 Python int。默认 80 dps(~266 位尾数,`fitting/hp_fitter.py:536` 等多处默认 precision=80)下每次 mp.mpf 乘/加(残差与 Jacobian 循环 `hp_fitter.py:160-166,271-275,334-341`)跑 Python bignum。gmpy2 该区间通常 2–10x,零代码改动,mpmath 透明拾取。 +- **建议**: 加 gmpy2 为可选依赖(extras `[fast]`)并写文档。`python -c "import mpmath; print(mpmath.libmp.BACKEND)"` 应打印 'gmpy'。无源码改动;precision_guard/safe_eval/LM 全自动受益。本仓单一最高性价比加速杠杆,且纯 CPU。 +- **工作量**: S | **来源**: claude | **验证**: CONFIRMED + +### [LOW] sampling_parallel.py 存在但在生产中是死代码——DataLab 已建好的 CPU 并行未接入任何真实路径 + +- **证据**: 模块 docstring 称 “Not yet wired into sample_mp_function by default”(`fitting/sampling_parallel.py:24-27`)。grep `sample_mp_function_parallel`/`sampling_parallel` 只见 `benchmarks/test_sampling_performance.py:58,62` 与测试,无 app_desktop/app_web/datalab_core/fitting 生产调用。实际用的是串行 `fitting.plot_fitting.sample_mp_function` 做密集预览/曲线采样。 +- **建议**: GPU 之前先把 `sample_mp_function_parallel` 接入密集预览/跨模型自动拟合路径(其 `PARALLEL_MIN_POINTS` 守卫已对小输入/不可 pickle callable 回退串行)。兑现已付出的加速,CPU 级,无数值正确性风险。 +- **工作量**: M | **来源**: claude | **验证**: CONFIRMED + +### [LOW] `_snapshot_clean_text` 在 datalab_core 定义 3 次且语义分歧(同名三种行为) + +- **证据**: 三处模块本地同名 helper 对非字符串/falsy 输入行为不同:`fitting_comparison.py:726` `str(value).strip() if value is not None else ""`(`0`→"0"、`False`→"False");`root_solving.py:1037` `str(value or "").strip()`(`0`/`False`/`""`→"");`statistics.py:2733` `value if isinstance(value,str) else ""`(任何非 str 含 `0`→"")。用于 snapshot payload 字段。`datalab_core/statistics_helpers.py` 已是天然共享家。 +- **失败场景**: 携整数 `0`/bool `False` 的 snapshot 字段,经 fitting_comparison 路径渲染为 "0"/"False",经 statistics 路径为 "",同一逻辑值因序列化模块不同而显示不同。 +- **建议**: 把单一 `snapshot_clean_text`(statistics 的 `isinstance(str)` 守卫最严最安全)提升到 `statistics_helpers.py`,删三份本地副本并 import。核对契约一致后同样处理 2× `_snapshot_numeric_text`(`statistics.py:2494` vs `uncertainty.py:1180`)。 +- **工作量**: M | **来源**: claude | **验证**: CONFIRMED + +### [LOW] 双语 ' / ' 分割三处重实现 maxsplit 不一致;Web base.html 截断任何含 ' / ' 的英文串 + +- **证据**: `shared/bilingual.py:28` 规范用 `split(" / ", 1)`,桌面一致(`window_extrapolation_mixin.py:842`、`tutorial_overlay.py:131` 均 `.split(' / ', 1)`)。但 `base.html:98` 做无限制 `raw.split(' / ')` 再取 parts[0]/parts[1]。对 '比率 / ratio a / ratio b',桌面渲染 'ratio a / ratio b',Web 只渲染 'ratio a'。 +- **失败场景**: 翻译写含 ' / ' 的英文标签(如 'mol / L'、'input / output'),Web 只渲染首个 ' / ' 前的文本静默丢弃其余,桌面正确。 +- **建议**: 把 `base.html:98` 改为 `indexOf(' / ')`+slice 取右半为英文半,镜像 maxsplit=1,并加含右半斜杠串的 JS 断言。长期暴露一个规范分割器而非三份副本。 +- **工作量**: S | **来源**: claude | **验证**: CONFIRMED + +### [LOW] `{{DEFAULT_THREE_POINT_FORMULA}}` 占位符替换在共享 facade(桌面帮助路径)与 Web `api_help_specs` 端点各自独立实现 + +- **证据**: `help_specs.json:120,125` 嵌 `{{DEFAULT_THREE_POINT_FORMULA}}`。桌面在 `formula_help.py:61-67` 用递归 `_substitute_placeholders` + `shared.formula_defaults.DEFAULT_THREE_POINT_FORMULA` 解析。Web(`api.py:212-219`)在 `api_help_specs()` 内定义自己逻辑等价(仅变量名不同:value/key/item vs obj/k/v)的递归 `_substitute_placeholders` 重读同 JSON。加第二个占位符 token 时一路替换一路不替换,产生桌面/Web 帮助不一致。 +- **建议**: 让 `api_help_specs` 调共享 `formula_help` facade(已返回替换后内容),或把 `_substitute_placeholders` 移入 shared 两处 import。 +- **工作量**: S | **来源**: claude | **验证**: CONFIRMED + +### [LOW] 无鲁棒/M 估计拟合——仅最小二乘,单个离群点即毁全部拟合 + +- **证据**: 全仓 grep `huber|tukey|bisquare|soft_l1|robust|irls|m_estimator` 在 fitting/、datalab_core/、extrapolation_methods/ 无鲁棒损失实现(fitting/ 内唯一 'robust' 命中是 `plot_fitting.py:736` 的缓存注释(与鲁棒损失无关);datalab_core/statistics.py 另有 'robust' 命中(136–374、2693–2696 行),但均为统计模式的 MAD/修正 z 分数离群点检测,非拟合鲁棒损失)。`hp_fitter.py:1` 是纯 χ²/加权最小二乘 LM。对以高精度曲线拟合为卖点的工具,缺任何抗离群损失(Huber/Tukey/Cauchy/IRLS)是显著科学功能缺口。 +- **失败场景**: 拟合含一个误录点的 Arrhenius/衰减数据集,最小二乘被离群点拽偏,reduced_chi2 爆炸,用户除手删数据外无内建降权手段。 +- **建议**: 给 hp_fitter 加可选损失/鲁棒加权(IRLS + Huber/Tukey 是标准低风险,复用现有 LM 内循环每迭代重加权),经 `shared/ui_specs.py` 暴露给两前端与 CLI,保持 `param_errors_stat`/`param_errors_sys` 语义。 +- **工作量**: L | **来源**: claude | **验证**: CONFIRMED + +### [LOW] 序列加速外推仅 4 个 accelerator 键(实为 3 种算法);缺 Aitken Δ² 与 theta/rho + +- **证据**: `apply_sequence_accelerator`(`extrapolation_methods/accelerators.py:38`)分发 'richardson'、'shanks'、'wynn_epsilon'(与 'shanks' 是同一 `mp.shanks` 调用,:86-90,仅元数据标签不同)、'levin_u'——实为三种不同算法。Aitken Δ²(grep 缺失)、Brezinski θ、ρ 算法均标准、廉价、对 Wynn-ε 表现不佳的对数收敛序列互补,未提供。mpmath 不带 θ/ρ,但 Aitken Δ² 仅数行。 +- **失败场景**: 对数收敛序列(Wynn-ε 已知停滞)用户无备选加速器可试,尽管工具主打序列外推。 +- **建议**: 至少加 Aitken Δ²(trivial 无依赖),可行则加 θ,经 `shared/ui_specs.py` 暴露。并明确文档 shanks/wynn_epsilon 重复以免误导为两独立方法。 +- **工作量**: M | **来源**: claude | **验证**: CONFIRMED + +### [LOW] auto_fit_dataset 仅按 AIC 选最佳模型——无 BIC 选项、无 ΔAIC/Akaike 权重比较输出 + +- **证据**: `auto_fit_dataset`(`fitting/model_selector.py:254-262`)纯按最小 AIC 选(`score = result.fit_result.aic ... if score < best_score`)。BIC 已算并存于每个 FitResult(`model_selector.py:101`),比较表(`model_comparison.py:108-109`)每行带 aic/bic,但自动选择完全忽略 BIC,只报单个 best_model,无 ΔAIC、无 Akaike 权重、无 BIC 选择途径。 +- **失败场景**: 两模型几乎同拟合(ΔAIC≈0.3),工具静默报一为 'best' 而不提示选择在噪声内,导致过度解读。 +- **建议**: 扩展 AutoFitSummary 暴露 per-model ΔAIC/ΔBIC 与 Akaike 权重,加选择准则选项(AIC vs BIC)。输入已全算好,是聚合/呈现而非新拟合。 +- **工作量**: M | **来源**: claude | **验证**: CONFIRMED + +### [LOW] 所有数值 Web 计算在一个进程全局 mpmath 锁上串行——“4 workers”是唯一真实 Web 并发 + +- **证据**: mp.dps 进程全局,故每个模式的核心计算函数(`app_web/logic/{fitting,extrapolation,statistics,root_solving,error_propagation}.py` 中的 `_run_*`,由各视图调用)被 `@mpmath_synchronized` 包裹,全函数体内持单一模块全局 `_mpmath_lock`(`app_web/security.py:190,206-209`);SSE fit 取同锁(`sse.py:70` `_MP_SERIAL_LOCK = mpmath_lock`)全程持有(:416)。单 worker 进程内任一时刻至多一个 mpmath 计算,threaded WSGI(waitress `--threads=8`、gunicorn gthread)对核心工作零并行;一个高精度 fit(SSE 路径 `MAX_SSE_WALLCLOCK_SECONDS=90`,`sse.py:88`,但 deadline 仅在 fit 完成后检查 `sse.py:414,447`,故阻塞可达甚至超过 90s)阻塞该进程内所有数值请求。代码正确(守卫全局 mp.dps 的正确方式),但架构把 Web 吞吐上限锁在(worker 进程数)个并发计算,容量规划未文档化。 +- **失败场景**: waitress `--threads=8`(`deploy.en.md:115`)下 8 个 80 位并发 fit,线程 2-8 阻塞于 `_mpmath_lock`,有效并发 1 非 8。 +- **建议**: 保留锁(对 mpmath 全局 dps 是正确之举)。现代修法是把重计算移出请求 worker:任务队列(RQ/Celery)或专用计算子进程池(扩展 `parallel_backend.py`),每子进程拥有自己的 mp.dps。在 deploy.md 文档化 per-process 串行天花板,令运维按 worker 数=期望并发计算数配置。 +- **工作量**: XL | **来源**: claude | **验证**: CONFIRMED + +### [LOW] PyInstaller spec 头声称“每次构建重生成”而 CLAUDE.md 说“不要重生成”——且开启 `upx=True` + +- **证据**: `DataLab.spec:6-10` docstring 说 'regenerated by PyInstaller on each build, so do NOT edit by hand without verifying the regeneration preserves the relative-path discipline'(条件式警告,并非无条件禁止手改),但项目 CLAUDE.md 说 'spec is hand-tuned — do not regenerate'。二者直接矛盾;但失实的一侧是 CLAUDE.md——构建脚本确实每次重生成 spec(build_mac_data_gui.sh:333 以 `pyinstaller "$ENTRY_FILE" --name DataLab ...` 纯 CLI 标志构建,从不读取 .spec 文件)(53 项精选 PySide6 `excludes`(:115-140,26 行)、INFO_PLIST 文档类型块 :83-103,重生成仅丢失 spec 内的手写文档串与注释;excludes 的规范来源在 build_mac_data_gui.sh:143-203(经 `--exclude-module` 传入,spec:117-119 自述以该脚本为准),INFO_PLIST 文档类型由 build_mac_data_gui.sh:359-389 用 PlistBuddy 在构建后重打——功能配置不会丢)。EXE 与 COLLECT 均 `upx=True`(:155,169)——UPX 是 AV 误报与 macOS codesign/公证损坏的已知源,构建主机不保证有 UPX 二进制,该标志或静默 no-op 或签名隐患。 +- **建议**: 修正矛盾以符实:改 CLAUDE.md:40 的 'spec is hand-tuned — do not regenerate',改述为 spec 由构建脚本每次重生成、规范配置(excludes/文档类型)在 build_mac_data_gui.sh 中维护。对签名/公证的 macOS 与 Windows 构建在两个构建脚本加 `--noupx`(直接改 spec 的 upx=False 会在下次构建被重生成覆盖)(或门控于显式验证 UPX 存在的标志)。 +- **工作量**: S | **来源**: claude | **验证**: CONFIRMED + +### [LOW] `__main__` 总是先尝试 SocketIO/collab,与“未接入默认 web 栈”的姿态矛盾 + +- **证据**: `pyproject.toml:79-83` 记 collab 'Not wired into the default web stack; needs Redis for multi-worker scaling'。但 `server.py:189-200` `__main__` 无条件先调 `create_app_with_socketio()`,仅在 ModuleNotFoundError 回退 `create_app()`。`web_requirements.txt` 装 `.[web,collab,mcmc]`(含 flask-socketio),故默认 `python app_web/server.py` 静默启用 collab websocket 面 + 其内存会话注册表(`async_mode='threading'`,`server.py:157`)。“opt-in、需 Redis”子系统在 dev 默认开启,暴露其多 worker 不安全状态而运维未选择。仅 dev 入口(生产用 WSGI),故严重度受限。 +- **建议**: 将 SocketIO 门控于显式环境变量/开关,默认关闭;使 dev 入口与 pyproject 声明的 opt-in 姿态一致。 +- **工作量**: S(推断)| **来源**: claude | **验证**: CONFIRMED + +> 注:本条证据文本在输入 JSON 中被截断于 `recommendation: "Gate SocketIO behind an e...`。建议部分为按上下文合理补全,落地前请核对原始 finding。 + +## 三、按维度分组的观察 + +**GUI 设计与人性化(desktop UX)**:本维度是 confirmed 发现最密集处,主题一致——**运行态与反馈的缺失**。长任务无进度/耗时反馈(`workbench_results.py:332`),顶部工具栏 Run/Stop 与真实状态脱节甚至反向操作(`workbench_toolbar.py:169`),主 Run 按钮沉在可滚动栏底(`panels.py:728`),错误用模态弹窗而非内联(`window_extrapolation_mixin.py:190`),空态无引导(`panels.py:1162`),“?” 按钮对辅助技术不透明(`panels.py:765`)。这些多为 S/M 工作量,集中在“让用户随时知道系统在做什么、下一步做什么”。 + +**GUI/计算分离(layer boundaries)**:核心裂缝是扩展统计在 UI 线程同步计算(`window_extrapolation_mixin.py:392` + `window_statistics_mixin.py` 六站点),既冻结界面又违反分层;连带这些站点不传 cancellation_checker(机制存在却惰性)。此外 `SessionService` 的 busy-guard 因每次新建实例而成死代码,per-mode 前端胶水桌面/Web 重复——两者都是“分层意图与实际接线不符”的清晰化问题。 + +**后端性能(compute performance)**:全部是“重算/冗余评估”类,无正确性风险。梯度用有限差分而非已有符号偏导(3× 评估 + 精度损失)、系统不确定度重跑两遍并丢弃协方差、bootstrap 每副本算整套描述统计、协方差矩阵算双三角并重算列统计、并行 seed-solve 重 pickle 全数据。真正内层热点是逐点 AST 评估的批处理缺失。 + +**GPU 加速可行性**:见专题第四节。核心结论——GPU 对任意精度 mpmath 基本无用;免费大提速在 gmpy2 与接入死代码 `sampling_parallel.py`。 + +**代码质量**:`ExtrapolationWindow.__init__` 上帝构造函数与时序魔数(`window.py:477`)、`_snapshot_clean_text` 三处分歧副本(`statistics.py:2733` 等)。均为局部清晰化,风险低。 + +**维护性**:统一主题是**“单一数据源”名不副实**——`ui_specs.py` 头部虚假声明(:6-10;桌面专属注册表见 :756-937)、双语 `/` 分割三处 maxsplit 不一致、`{{占位符}}` 桌面/Web 各写、12 mixin 无类型契约。四条叠加显示项目的单源纪律只在参数控件上兑现,其余四类均已知会漂移。 + +**功能支持与完整度**:三个科学能力缺口——无鲁棒/M 估计拟合(离群点毁全拟合)、序列加速器实为 3 算法(缺 Aitken Δ²/θ/ρ)、auto-fit 仅 AIC 无 ΔAIC/BIC/Akaike 权重。均为“对标一个科学工具箱应有的下限”的补全,非 bug。 + +**现代化设计(architecture & stack)**:最高严重度集中于此——文档生产启动命令不存在(HIGH)、多 worker 破坏进程内状态(HIGH),加上全局锁并发天花板、`__main__` 默认开 collab、PyInstaller spec 矛盾 + UPX 隐患。主题是**部署姿态与真实运行时/安全属性不一致**。 + +**LaTeX 输出**:单点但真实的导出中断——误差传递表遇 inf/NaN 抛 ValueError 使整个 PDF 导出失败(`latex_tables_error_propagation.py:226`),而外推/统计路径已有 isfinite 守卫,此处独缺。 + +**公式渲染**:两条保真缺口——暗色模式内联公式近黑不可读(`formula_preview.py:218`)、特殊函数白名单半数无 LaTeX 映射渲染为原文(`formula_render_service.py:55`)。后者恰是计算层宣称能力的表现层缺口。 + +**Bug 与正确性风险**:SSE 超时形同虚设 + 全局锁全程持有(`sse.py:414`)、worker 预算永久泄漏(`parallel_backend.py:305`)、非 scan 残差容差高 dps 下溢退化为 1e-10(codex,`solver.py:850`)。前两条是资源/DoS 相关的真实运行时缺陷,第三条是高精度场景静默精度退化。 + +## 四、GPU 加速专题(诚实结论) + +**核心判断:对本代码库,GPU 加速在其主打的高精度路径上基本无用,且会误导优化投入方向。** + +原因:DataLab 的数值核心是 mpmath 任意精度(默认 80 dps ≈266 位尾数),其瓶颈是**软件 bignum 尾数算术 + AST 解释**(`hp_fitter.py:160-166,271-275,334-341`;`expression_engine._evaluate_ast`),而非可映射到 GPU SIMD 的 float32/float64 密集线代。GPU 擅长的是大规模低精度并行浮点,与“每个 mp.mpf 乘法是一串 Python 层 bignum 运算”的负载画像正交。要让 GPU 有意义,必须先引入一条 **低 dps float64 快路径**(本身是独立的、有数值精度取舍的工程),届时 GPU 才有可批处理的对象——但那时你已经离开了工具箱的核心卖点(高精度)。 + +**真正该做的、按性价比排序(全部 CPU 侧、零/低数值风险)**: + +1. **安装 gmpy2(S 工作量,2–10x,零代码改动)** —— mpmath 导入时透明拾取 GMP 尾数算术,`precision_guard`/`safe_eval`/LM 全自动受益。这是单一最高性价比杠杆,应在任何加速讨论之前完成。 +2. **接入已死的 `sampling_parallel.py`(M)** —— 密集预览/跨模型自动拟合的采样是天然可并行路径,模块已写好、已测、有 `PARALLEL_MIN_POINTS` 守卫与串行回退,却无生产调用者。兑现已付出的加速,无正确性风险。 +3. **模型表达式向量化 / 批处理残差与 Jacobian(L)** —— 把 per-point 的 AST 树遍历解释 + scope-dict 重建(`model_parser.py:169`;AST 本身仅解析一次并缓存)折叠为一趟批评估。这是真正的内层热点,且是 CPU 向量化而非 GPU 的目标。 +4. **消除冗余评估(M,见性能维度)** —— 符号偏导替代有限差分、系统不确定度 params_only 快路径、bootstrap per-target 评估器、协方差上三角。 + +**结论一句话**:先装 gmpy2、接死代码并行、批处理内层循环;GPU 只有在你愿意为它专门建低精度快路径时才谈得上,而那与本工具箱的高精度定位相冲突。 + +## 五、优先级路线图 + +按“风险排序”分波(用户已说明忽略重构难度,故此处只按运行时风险/影响/依赖排序,不按工作量大小)。 + +### P0 — 立即(部署即坏 / 安全 / 数据损坏) +- **[HIGH] 文档生产启动命令指向不存在的 `app_web.server:app`**(`deploy.en.md:59`)——照做即无法启动,最高影响、S 工作量,先修。 +- **[HIGH] 多 worker 破坏进程内 SSE 限流器与 collab 注册表**(`sse.py:104` / `app_web/blueprints/collaborate.py:253`)——功能 + DoS 安全双重问题;至少立即从文档移除 `-w 4` 推荐(文档改动 S),Redis 化为后续。 +- **[MEDIUM] 误差传递表 inf/NaN 抛 ValueError 中止整个 PDF 导出**(`latex_tables_error_propagation.py:226`)——用户可触发的导出崩溃,S 工作量,加 isfinite 守卫。 +- **[MEDIUM] worker 预算永久泄漏**(`parallel_backend.py:305`)——一旦触发则进程内所有子进程 fit 永久禁用;确定性释放修法 M。 +- **[MEDIUM] SSE 超时形同虚设 + 全局锁全程持有**(`sse.py:414`)——单请求可长时间钉死 worker 与全局锁,与 P0 并发主题同源。 + +> P0 的四条现代化/并发条目(启动命令、多 worker 状态、SSE 超时、全局锁天花板 + `__main__` 默认 collab)应作为**一个部署审计波次**统一处理——它们共享同一根因:部署文档与真实运行时/安全属性不一致。 + +### P1 — 近期(用户可见质量 / 分层健康) +- **[MEDIUM] 扩展统计冻结 UI 线程**(`window_extrapolation_mixin.py:392`)——移到 QThread 并接 cancellation。 +- **[MEDIUM] 顶部工具栏 Run 静默停止任务**(`workbench_toolbar.py:169`)——单信号驱动、删幽灵方法名,S。 +- **[MEDIUM] 长任务无进度反馈**(`workbench_results.py:332`)。 +- **[MEDIUM] 非 scan 残差容差高 dps 下溢**(`solver.py:850`,codex)——用 `mp.sqrt(mp.eps)`,加高精度测试。 +- **[MEDIUM] 暗色模式公式不可读**(`formula_preview.py:218`)+ **特殊函数无 LaTeX 映射**(`formula_render_service.py:55`)——渲染保真。 +- **[S 免费提速] 安装 gmpy2** —— 独立、零风险、高回报,可随时插入。 + +### P2 — 择机(清晰化 / 性能重算 / 功能补全) +- 维护性单源修复(`ui_specs.py` 头 / 双语分割 / 占位符 / mixin Protocol)——同一主题批量处理。 +- 性能重算类(符号偏导、系统不确定度 params_only、bootstrap per-target、协方差上三角、seed-solve 序列化、批处理内层循环)+ 接入 `sampling_parallel.py`。 +- 代码质量(`__init__` 上帝构造、`_snapshot_clean_text` 三副本)。 +- 功能补全(鲁棒/IRLS 拟合、Aitken Δ² 等加速器、ΔAIC/BIC/Akaike 权重)。 +- GUI 打磨(主 Run 按钮 sticky、内联校验错误、空态 CTA、“?” 可访问性)。 +- PyInstaller spec 头矛盾修正 + `upx=False`。 +- Web 并发架构升级(任务队列/计算子进程池,XL)——最大工作量,无功能回归压力,最后做。 + +## 六、方法与置信度说明 + +- **来源**:主体由内部蜂群(source=claude,11 个维度审阅员)产出,一条外部来源为 codex(非 scan 残差容差下溢,`solver.py:850`)。 +- **外部模型覆盖(诚实声明)**:初始蜂群阶段计划结合两个外部模型,但当时 Gemini CLI 认证失败(`IneligibleTierError`),初始发现来源实际只有 Codex 一个外部模型。**后续已补齐**:改走 Antigravity CLI(`agy`)通道后,最终文档于 2026-07-03 通过 **Codex + Gemini 3.1 Pro (High)** 双外部模型对抗性审阅——Codex `VERDICT: PASS`(0 异议,全查 HIGH、抽查 13 条 MEDIUM、运行时验证 `mpmath.libmp.BACKEND=python`/gmpy2 不可导入),Gemini 9 项定向反驳尝试全部失败(结论 "100% factual")。 +- **规模**:内部原始 findings 64 条 + 外部来源 codex;候选 86 条;经对抗性验证存活 56 条,反驳/剔除 30 条(refuted=30)。本报告呈现的是其中提供给 lead 的 40 条 CONFIRMED 子集。 +- **验证状态**:本次交付的全部发现均标记 **CONFIRMED**(多条附有直接复现,如 inf/NaN ValueError、`hasattr(s,'app')`→False、`_source_to_latex` 输出、`_snapshot_clean_text` 三态差异)。输入中**未包含任何 PLAUSIBLE 条目**——即无“合理但未证实”的悬置发现进入本报告;未存活的 30 条已在验证阶段剔除,不在此列。 +- **诚实边界**:(1)codex 条目缺 effort 字段,路线图中按影响排入 P1。(2)最后一条 `__main__` 默认 collab 的原始 `recommendation` 文本在输入 JSON 中被截断(`...behind an e`),其证据完整、结论可靠,但建议措辞为按上下文补全,落地前应核对原始 finding。(3)多处“多源印证”标注反映的是同一 source 从不同维度重复触及同一主题(部署并发、单一数据源),据此提升了优先级而非独立置信度。 +- **二次逐条再验证(2026-07-03)**:全部 38 条发现 + §三/§四/§五 章节论述又经过一轮独立的逐条 pedantic 事实核查(每条一个全新验证员,严查 file:line、引用拼写、因果主张、建议与项目不变量的兼容性)。结果:**0 条核心主张被推翻**;19 条完全准确,19 条应用了共 40+ 处精化修正(行号校准、路径全称、措辞限定——最实质的一处:collab 跨 worker 失败场景仅适用于 SocketIO 部署,文档推荐的 `create_app()` 部署不注册 `/collab` 蓝图)。 +- **总体置信度**:高。发现集中在可静态核验的部署配置、分层接线、渲染映射与算法冗余,均可溯源到 file:line;数学正确性层面(除 codex 的容差下溢外)未发现严重缺陷,与该核心层的成熟度评估一致。 \ No newline at end of file diff --git a/docs/superpowers/specs/2026-07-03-adaptive-workbench-section1-design.md b/docs/superpowers/specs/2026-07-03-adaptive-workbench-section1-design.md new file mode 100644 index 00000000..28b7f6b1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-adaptive-workbench-section1-design.md @@ -0,0 +1,104 @@ +# Adaptive Workbench — Section 1: Architecture & new layout contract (DESIGN, not yet built) + +> **STATUS: dual-external-model PASS (2026-07-03), v4.** Codex + Gemini 3.1 Pro both PASS after 4 rounds of adversarial review (each round found real, code-confirmed issues, all fixed): R1 rejected the 4th-pane premise; R2 fixed the options_box inventory + fold-to-0 conflicts; R3 fixed the QVBoxLayout root shape + CurrentPageStack requirement; R4 resolved the workbench_config_content/left_layout test-contract collision. Both models ran live Qt probes confirming CurrentPageStack sizing and setVisible-collapse redistribution. Safe to proceed to Section 2 (batch plan). +> +> **Batch-2 note (Codex, not a blocker):** the schema scanner + `test_desktop_global_options_ui.py:130-131` inspect `window.options_box` (`tools/scan_desktop_gui_schema.py:815-818`). Batch 2's per-control migration must preserve or update that inspection point — consistent with the deferred-migration contract below. + +## Current state (verified in repo) +- `app_desktop/workbench_layout.py:build_workbench_main_splitter(owner)` builds a horizontal `QSplitter` with THREE panes: + - `widget(0)` = `config_scroll` — a `QScrollArea` objectName `workbench_config_rail`, stretch 0, minWidth CONFIG_RAIL_MIN_WIDTH(320)+viewport overhead. + - `widget(1)` = `workspace_scroll` — a `QScrollArea` objectName `workbench_workspace_canvas`, stretch 1, minWidth WORKSPACE_CANVAS_MIN_WIDTH(520). + - `widget(2)` = `result_frame` — a `QFrame` objectName `workbench_result_rail`, stretch 0, minWidth RESULT_RAIL_MIN_WIDTH. + - `splitter.setSizes([CONFIG_RAIL_WIDTH(320), workspace_width, RESULT_RAIL_WIDTH(380)])`; `setChildrenCollapsible(False)`; every pane `setCollapsible(index, False)`. +- `app_desktop/panels.py:336` calls `build_workbench_main_splitter(self)`; then `panels.py:343-347` aliases `left_layout`/`_left_scroll`=`workbench_config_rail` and calls `self._build_left_panel()` to fill the config rail. +- **FULL, VERIFIED `options_box` inventory** (`QGroupBox("选项")` at `panels.py:916`, added at `panels.py:1122`). It is a FREQUENCY-MIXED box, not purely low-freq — Codex round-2 CONFIRMED it holds MORE than precision/parallel: + - **Low-freq (compute config):** `mpmath_precision_spin` (数值精度位数), `uncertainty_digits_spin` (不确定度位数), `parallel_mode_combo` (资源策略), `parallel_max_workers_spin` (最大 workers), `parallel_reserve_cores_spin` (保留核心), `parallel_nested_policy_combo` (嵌套并行策略). + - **LaTeX output group** (`panels.py:1033-1089`, inside `latex_options_widget`): `generate_latex_checkbox`, `output_file_edit`+`output_browse_button`, `latex_input_precision_spin`, `dcolumn_checkbox`, `latex_group_size_spin`, `caption_checkbox`+`caption_edit`. + - **Per-run toggles:** `generate_plots_checkbox` (生成图片, `panels.py:1091`), `verbose_checkbox` (显示详细日志, `panels.py:1097`). + - Then `run_button` (开始执行) is added right after at `panels.py:1124` (relevant to F19). + - All these are wired to schema via `_bind_global_options_schema_fields` (`panels.py:1100`) and read by name in workspace save/load (`workspace_controller.py:745,1112`) — so migration is a per-control CONTAINER move that MUST preserve every `self.` attribute + objectName + the schema binding call. +- **PER-CONTROL migration decision (Batch 2 will finalize; NOT "move the whole box"):** precision + parallel (6 controls) → 选项 panel page (genuinely low-freq). LaTeX-output group + generate_plots + verbose are per-RUN, not low-freq — they likely stay in/near the run area OR move to a 导出 panel page; decided in Batch 2, not assumed here. +- **⚠ CORRECTION (Codex round-1, CONFIRMED):** 显示位数/小数位数 (`display_digits_spin`) and 科学计数 (`scientific_checkbox`) are NOT in options_box — they live in the RESULT NUMERIC TAB (`panels.py:1224-1235`, `numeric_layout`), modeled as `result.numeric` in `DESKTOP_RESULT_VIEWS` (`shared/ui_specs.py:807`). They STAY in the result tab. +- The 5 job modes live in `self.mode_stack` (reparented into the workspace canvas at `panels.py:353`). +- `_refresh_main_splitter_left_min_width()` + `_clamp_workbench_splitter_sizes()` (panels.py) keep min-widths; they tolerate a defensive 4th pane already (tests `test_splitter_refresh_preserves_defensive_extra_panes`, `..._fallback_total_excludes_extra_panes`). +- Pinned layout-contract test: `tests/test_desktop_workbench_layout.py:test_main_area_uses_config_workspace_result_regions` asserts `splitter.count()==3`, `widget(0)` is a QScrollArea named config_rail, `widget(1)` canvas, `widget(2)` result frame. Other tests reference CONFIG_RAIL_MIN_WIDTH etc. `visual_contract_issues(window)` in `workbench_visual_contract.py` also enforces invariants. + +## Proposed new structure (4 zones) +Replace the 3-pane splitter's left side with an icon rail + a collapsible config panel STACK: + +``` +[icon rail] │ [config panel stack] │ [workspace canvas] │ [result rail] + ~52px │ collapsible ~210px │ elastic stretch=1 │ elastic stretch=1 + always on │ QStackedWidget │ (mode_stack etc.) │ + stretch 0 │ stretch 0, foldable │ │ +``` + +- **Icon rail** (NEW, `workbench_icon_rail`): thin always-visible `QFrame` with icon buttons. +- **Config panel stack**: a `QStackedWidget` holding config + low-freq pages. +- **Workspace canvas + result rail**: result rail stretch 0 → **stretch 1** (fold-to-widen). + +## ⚠ REVISED after external review (Gemini FAIL — 3 code-confirmed flaws; ALL adjudicated CONFIRMED against code) +The original "add a 4th splitter pane / icon rail at widget(0)" is UNSAFE and REJECTED: +- **Index shift breaks live logic.** `_refresh_main_splitter_left_min_width()` (panels.py:604-625) hardcodes panes 0/1/2 = config/workspace/result (`sizes[:3]`, `minimums=[left,center,right]`). Icon rail at index 0 shifts every pane → wrong clamping. Its 4th-pane tolerance is TRAILING-only (`sizes[3:]`), never leading. CONFIRMED at panels.py:604-625. +- **count()==3 asserted in THREE test files** (not one): test_desktop_workbench_layout.py:44/72/75, test_desktop_mode_stack.py:136, test_splitter_persistence.py:121/178. And `QSplitter.saveState()` persistence (closeEvent saves it; restore asserts `sizes()[0..2]` + `_left_scroll.horizontalScrollBar().maximum()==0`, test_splitter_persistence.py:121-125) becomes incompatible with a pane-count change. CONFIRMED. +- **Fold-to-0 fights three guards:** `CONFIG_RAIL_MIN_WIDTH=320` via setMinimumWidth (workbench_layout.py:64) + `setChildrenCollapsible(False)`/`setCollapsible(index,False)` (115/134) + `visual_contract_issues` flags `config.width<320` (workbench_visual_contract.py:72). CONFIRMED. + +## Proposed new structure (3 panes PRESERVED — icon rail OUTSIDE the splitter) +Keep the splitter at EXACTLY 3 panes. **CORRECTION (Codex round-2, CONFIRMED):** `workbench_root` is a **QVBoxLayout** (`panels.py:330`) stacking toolbar / splitter / status vertically — NOT an HBox. So the icon rail can't be a "root sibling left of the splitter." Correct shape: introduce an **inner content HBox** that holds `[icon rail | splitter]`, and add THAT hbox to the root VBox in the splitter's current slot (`root_layout.addWidget(self._main_splitter, 1)` at `panels.py:337` → becomes `root_layout.addWidget(content_hbox_container, 1)`). + +``` +workbench_root (QVBoxLayout — unchanged) +├─ workbench_bar (toolbar) +├─ content HBox ← NEW wrapper (replaces the direct splitter row) +│ ├─ [icon rail] ← NEW, ~52px fixed QFrame, LEFT of splitter, NOT a splitter child +│ └─ QSplitter (STILL 3 panes, indices unchanged) +│ ├ widget(0) config zone │ widget(1) workspace │ widget(2) result rail +│ objectName config_rail canvas (unchanged) stretch 0→1 (elastic) +│ hosts a CurrentPageStack +└─ status strip +``` + +- **Icon rail** = sibling of the splitter INSIDE the new content HBox → does NOT change `splitter.count()`, index math, or `_main_splitter.saveState()` (close saves only splitter state, `window.py:3120`). +- **Config zone = pane 0, SAME objectName `workbench_config_rail`** → QSS, `_left_scroll`, persistence, and index-0 `_refresh_*` logic all keep working. Inside it: a **`CurrentPageStack`** (`app_desktop/current_page_stack.py:7`, NOT a plain `QStackedWidget`) — page 0 = current `_build_left_panel` content; new pages = 选项 (the 6 low-freq controls), 历史, 工作区, 导出. +- **⚠ Why CurrentPageStack, not QStackedWidget (Codex round-2, CONFIRMED):** `_refresh_main_splitter_left_min_width()` derives pane-0 min-width from `workbench_config_content.minimumSizeHint()` (`panels.py:595-599`). A plain `QStackedWidget.minimumSizeHint()` is driven by the LARGEST/hidden page, which would inflate pane-0 min-width and could force the very scrollbar we're removing. The repo already has `CurrentPageStack` (a QStackedWidget subclass overriding sizeHint/minimumSizeHint to the CURRENT page) for exactly this — the config stack MUST use it. + +- **⚠ EXPLICIT MIGRATION CONTRACT for `workbench_config_content` / `left_layout` (Codex round-3, CONFIRMED conflict — resolved here):** + Today (`panels.py:343-345`): `left_layout` = `workbench_config_layout`, `left_container` = `workbench_config_content`, `_left_scroll` = `workbench_config_rail`. Load-bearing test contracts on these: + - `test_desktop_shell_layout.py:75-85` asserts `left_layout` directly contains, IN ORDER, the widgets `mode_section` / `input_section` / `output_setup_section` / `run_section`. + - `test_desktop_gui_redesign_scan.py:89-91` injects a probe widget into `window.workbench_config_layout` and expects it to drive the config-rail horizontal-scroll check. + - `test_desktop_workbench_data_area.py:44,327-332` assert config sections are direct children of `workbench_config_content`. + **The collision:** to fix hidden-page min-width, `_refresh_*` must read the STACK's current-page hint — but if `workbench_config_content` simply BECOMES the CurrentPageStack, the 4 sections stop being its direct children and all three test contracts break. + **Resolution (design decision):** DO NOT rename `workbench_config_content`. Instead: + 1. Page 0 of the CurrentPageStack IS today's `workbench_config_content` (holding `left_layout` with the 4 sections, unchanged) → the shell-layout + data-area + scan contracts stay GREEN, `left_layout`/`left_container`/`_left_scroll` aliases unchanged. + 2. Introduce the stack as a NEW attribute `workbench_config_stack` (a `CurrentPageStack`) that CONTAINS `workbench_config_content` as page 0 plus the new pages (选项/历史/工作区/导出). + 3. Update `_refresh_main_splitter_left_min_width()` to derive pane-0 min-width from `workbench_config_stack.minimumSizeHint()` (the current-page hint) when the stack exists, falling back to `workbench_config_content` otherwise. This is a SMALL, explicit code change in Batch 1 — call it out, don't leave it implicit. + 4. The `output_setup_section`/`run_section` stay on page 0 (they're the run controls). Only the low-freq CONTROLS inside `options_box` migrate to the 选项 page in Batch 2 — the SECTION widgets themselves stay where the tests expect them on page 0. This keeps Batch 1 (shell) test-clean and defers control migration to Batch 2. +- **Fold mechanism (NOT width-0):** collapse via `config_rail.setVisible(False)` (a hidden splitter child keeps count()==3 but yields its space to the elastic result rail) OR a collapsed-state flag that relaxes the 320 min ONLY when collapsed. The 320 min-width contract stays for the EXPANDED state; the collapsed state is a separate explicitly-tested mode. MUST be prototyped in Batch 1 to confirm persistence + visual_contract behave. +- **Result rail stretch 0 → 1:** freed space flows to the result. `setSizes`/clamp operate on explicit sizes so stretch mainly affects user-drag redistribution (low risk) — a guard test is required. + +## New layout contract (EXTENDS the 3-pane tests, same PR — existing count()==3 tests STAY GREEN) +- Splitter STILL `count()==3`: widget(0)=config zone (objectName `workbench_config_rail`, now a QStackedWidget host), widget(1)=workspace canvas, widget(2)=result rail (stretch 1). +- Icon rail asserted as a root-HBox sibling of the splitter (new test), NOT a splitter child. +- New guard tests: (a) no main-area vertical scrollbar when controls fit; (b) collapsing the config zone widens the result rail; (c) icon click switches the stack page; (d) saved splitter state round-trips (persistence test stays green); (e) `visual_contract_issues` updated to allow the collapsed state. + +## Tooling/coupling that Batch 1 MUST update (Codex, all CONFIRMED — broader than "one contract test") +Keeping objectName `workbench_config_rail` on pane 0 (the revised plan) means MOST of these keep working unchanged. Still to handle: +- `visual_contract_issues()` hardcodes config/workspace/result objects+order (`workbench_visual_contract.py:49`) → extend to allow the icon rail sibling + collapsed state. +- Screenshot test asserts `workbench_config_rail` width (`test_desktop_workbench_visual_screenshots.py:44`) → still valid in expanded state; add collapsed-state coverage. +- Theme QSS targets `QScrollArea#workbench_config_rail` (`theme.py:716`) → keep the objectName so QSS still applies; add icon-rail QSS. +- Scan tooling searches for the rail + forces 3-pane sizes (`tools/scan_desktop_gui_schema.py:513,625`) → still 3-pane under the revised plan, but the icon rail + stack pages need scan coverage. +- **Splitter-state persistence:** restore rejects+deletes blobs whose stored pane count differs (`panels.py:395`). The revised plan KEEPS count()==3, so existing blobs stay valid — but adding the config-stack inside pane 0 does not change saved geometry. Call out in Batch 1 that any future pane-count change would invalidate blobs (graceful discard already exists). +- **No blocking issue for options_box state binding / workspace save-load** (Codex): `workspace_controller.py:745,1107` use `getattr(...)` on the control attributes, not parentage — so moving the 6 controls into a new panel page is safe as long as their `self.` attributes + objectNames are preserved. + +## Preserved invariants (explicit) +- **All 5 job modes** unaffected: `mode_stack` stays in the workspace canvas; only its left-of-canvas neighbors change. +- **Window mixin composition + MRO guardrails** (`tests/test_window_mixin_composition_guardrails.py`): NO mixin changes — all work is in `panels.py` (shell/panel construction) + `workbench_layout.py` (+ new small modules). No new `__init__` in mixins, no Qt-event overrides, MRO frozen list untouched. +- **Desktop/web sync**: NO semantic change to `shared/ui_specs.py` / `help_specs.json`. Controls move CONTAINERS, not specs; web frontend unaffected. +- **File-size ratchet** (`tests/test_file_size_ratchet.py`): panels.py is already at baseline 2167; moving code OUT of it (into new icon-rail/panel-stack modules) should REDUCE it, not grow it. New modules must stay <800 lines. + +## Explicit questions for the external reviewers +1. Is adding a 4th splitter pane safe given `_clamp_workbench_splitter_sizes`/`_refresh_main_splitter_left_min_width` already handle N-pane, or does anything assume exactly 3 panes beyond the one contract test we plan to rewrite? +2. Does moving the options_box controls to a new panel-stack page risk breaking any state-binding (`_bind_workbench_state_roles`, `STATE_ROLE_MODEL_PATHS`) or the workspace save/load round-trip, given the widgets keep the same objectNames/attributes? +3. Is `visual_contract_issues()` (workbench_visual_contract.py) going to fail on the new structure, and is that in-scope to update in the same PR? +4. Any risk to `test_desktop_gui_screenshot_smoke` / `..._visual_screenshots` from the new zone? +5. Is making result rail stretch=1 (from 0) going to fight the existing setSizes/clamp logic? diff --git a/docs/superpowers/specs/2026-07-03-adaptive-workbench-section2-plan.md b/docs/superpowers/specs/2026-07-03-adaptive-workbench-section2-plan.md new file mode 100644 index 00000000..64994b78 --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-adaptive-workbench-section2-plan.md @@ -0,0 +1,350 @@ +# Section 2 — 自适应工作台分批实施计划 + +> Lead synthesis of five verified/adversarially-fact-checked batch plans for the DataLab Adaptive Workbench (desktop, PySide6). All file:line citations below were re-verified against live code on branch `main` at synthesis time. Where a source plan's citation was wrong, the corrected coordinate is used and the discrepancy is flagged. Frozen ratchet baselines: `panels.py`=2167, `window.py`=3181, `window_extrapolation_mixin.py`=1132 (`tests/test_file_size_ratchet.py:27` `_BASELINE`, `_HEADROOM`=40 at :22, `_SOFT_LIMIT`=800 at :19). **Current actual** line counts (verified): `panels.py`=2169, `window.py`=3198, `window_extrapolation_mixin.py`=1129, `workbench_layout.py`=154, `workbench_toolbar.py`=234, `workbench_visual_contract.py`=97, `settings_store.py`=460, `formula_preview.py`=295, `workbench_formula_panel.py`=789. + +--- + +## 概览 + +Five batches. **Batch 1 is the structural keystone**; Batches 2–4 have hard or soft ordering dependencies on it. **Batch 5 (Polish) is functionally independent** of 1–4 and can land in any slot, but it touches `window.py` and adjacent formula files, so it is sequenced to avoid merge conflicts. + +| # | Delivers | Depends on | Why this order | +|---|----------|-----------|----------------| +| **1 — Shell scaffold** | Icon rail as a root-HBox sibling of the splitter; wraps today's config content in a `CurrentPageStack` (page 0 = existing config); flips result-rail stretch 2:0→2:1; functional 折叠(⌘[ / Ctrl+[) + page-switch wiring. **ZERO controls moved.** | — | Establishes `workbench_config_stack` + `workbench_icon_rail`, the surfaces every later batch mounts into. Must land first so Batch 2 has a real page host and Batch 4's persisted `active_config_page` key is not a dead no-op. | +| **2 — Control migration** | Extracts the 6 compute controls (precision/uncertainty/parallel) into `workbench_options_page.py`; relocates export/workspace entry points into secondary pages. | **Batch 1 (hard, see 未决 Q-A)** | Cannot safely orphan visible controls. If Batch 1's stack exists, the extracted page mounts into 选项 page 1; otherwise it must mount into the existing visible rail (interim fallback). | +| **3 — Run/Stop toolbar state** | F04: toolbar Run/Stop reflect run state (Run visible+enabled idle; Stop visible+enabled running); deletes two ghost dispatch names; F19: pins toolbar Run as the always-visible Run (defers `run_section` relocation). | Soft: references `workbench_config_rail` (exists today, `workbench_layout.py:123`); no code dependency on Batch 1's stack. | Independent of the stack; ordered after 1–2 only to avoid `window.py` merge churn. | +| **4 — Fold-to-widen + focus + memory** | Fold-to-widen (config collapse → result rail widens via explicit `setSizes`), focus mode (Ctrl+Shift+F), layout memory (3 new QSettings keys). View menu with checkable actions. | Soft on Batch 1: `workbench_icon_rail`/`workbench_config_stack`/`active_config_page` are `getattr`-guarded no-ops until Batch 1 lands. | Fold operates on `workbench_config_rail` + `_main_splitter`, both present today, so it can precede Batch 1 — but the persisted `active_config_page` key is dead until Batch 1 (未决 Q-D). | +| **5 — Polish (F03/F14/empty-state)** | F03 compute-run progress feedback; F14 dark-mode-aware formula preview color; result-area empty-state load-example card. | None functional. | Touches `window.py` + formula files; sequence last (or in a parallel worktree) to minimize conflict with 1–4's `window.py` edits. | + +**Recommended landing order: 1 → 2 → 3 → 4 → 5**, each in its own worktree/branch, merged only after the shared gate (below) is green. + +--- + +## 全局不变量与验证策略 + +### 不变量 (every batch MUST preserve — verified anchors) + +1. **3-pane splitter, `count()==3`.** `build_workbench_main_splitter` adds exactly three children — `config_scroll`, `workspace_scroll`, `result_frame` (`app_desktop/workbench_layout.py:130-132`), each `setCollapsible(index, False)` (`:133-134`). No batch may `addWidget`/`removeWidget` on the splitter. Guarded by `tests/test_desktop_workbench_layout.py:44`, `tests/test_splitter_persistence.py:121`, `tests/test_desktop_mode_stack.py:136`. **Fold/focus (Batch 4) hides a child via `setVisible(False)` — never removes it.** +2. **Config-rail `QScrollArea` keeps `objectName == CONFIG_RAIL_OBJECT` (`"workbench_config_rail"`).** Set in `make_config_rail` (`workbench_layout.py:57-66`, stored on owner at `:123`). Constant defined `workbench_visual_contract.py:9`. Nesting a stack *inside* the scroll is allowed; renaming the scroll is not. Guarded `tests/test_desktop_workbench_layout.py:45-46`. +3. **MRO / mixin composition frozen.** No new class or base-order change; new behavior goes onto the existing `ExtrapolationWindow` main class or an existing mixin. `closeEvent` (`window.py:3107`) is on the main class, not a mixin — extending it is legal. Guarded `tests/test_window_mixin_composition_guardrails.py` (incl. `test_no_mixin_overrides_a_qt_event_handler`). +4. **`options_box` schema-clean.** `find_unbound_required_widgets(window.options_box) == []` (`tests/test_desktop_global_options_ui.py:131`; function at `app_desktop/ui_schema_binder.py:76`). Widget↔schema binding is by `self.` and is parent-independent (`panels.py` bind calls), so re-parenting a bound widget does not unbind it. +5. **Schema keys are single-owner.** No widget may double-bind an existing schema key (e.g. `results.export.csv` / `results.image.export`, bound at `panels.py:2102-2106`). A relocated button reuses the existing bound method; it does not re-bind. +6. **`FitResult` uncertainty split** (`param_errors_stat` vs `param_errors_sys`) and **precision discipline** (`with precision_guard(dps)` at every mpmath entry) — untouched by all five batches (no compute-path edits), but must not be regressed by any new worker glue (Batch 5 F03). +7. **desktop/web sync.** None of these five batches change `shared/ui_specs.py` or `shared/help_specs.json` — all are desktop-only chrome/layout. No web mirror needed; no drift. +8. **Bilingual strings** use `_dual_msg(zh, en)` / `_register_text(widget, zh, en, setter)` (signature at `window_i18n_mixin.py:314`). New user-facing menu items and cards must follow this. +9. **Persistence blob shape.** `KEY_MAIN_SPLITTER_STATE` save/restore round-trips byte-identically (`shared/settings_store.py:411`). New keys are additive under the allowlisted `MainWindow/` prefix (`_ALLOWED_KEY_PREFIXES` at `:83`, `_validate_key` at `:119`). + +### 共享验证门 (shared gate — per batch, in order; no step skipped) + +Each batch runs in **its own worktree/branch**; the default branch stays untouched until the user confirms a merge. + +1. **TDD.** RED test first (must actually fail), then minimal GREEN, then REFACTOR. Qt tests run under `QT_QPA_PLATFORM=offscreen`. +2. **`ruff check .`** (select E,F,W) + **`mypy`** on the strict set where the touched file qualifies (`shared` is strict → Batch 4's `settings_store.py` and Batch 5's Qt-free helper modules get mypy). +3. **Codex + Gemini adversarial review** of the diff (prefer the Claude-CLI path to preserve main-account quota). Default every finding to spurious unless grounded in file:line evidence. +4. **Full suite** `QT_QPA_PLATFORM=offscreen pytest -q` green, including `tests/test_file_size_ratchet.py`. +5. **User-confirmed merge**, then **`graphify update .`**. + +**Offscreen layout caveat (applies to any pane-width assertion — Batches 1, 3, 4):** an offscreen splitter reads `sizes()==[0,0,0]` until laid out. Any test asserting pane widths MUST first run `win.resize(1400,900); win.show(); QApplication.processEvents()` (pattern already used at `tests/test_splitter_persistence.py:89-91`). Prefer `isHidden()`/explicit-flag assertions over `isVisibleTo()` for visibility. + +--- + +## Batch 1 — Shell scaffold (icon rail + config stack, ZERO test breakage) + +**Files touched** +- `app_desktop/workbench_icon_rail.py` — **NEW** (~120-160 lines, <800). `make_icon_rail(owner)`. Collapse button via `_call_owner(owner, '_toggle_config_rail')` + `setShortcut('Ctrl+[')` (Q-E: `Ctrl+[`, not `Ctrl+B`). **Page-switch buttons must use `lambda`/`functools.partial` capturing the index — NOT `_call_owner`**, which passes no index arg (`workbench_toolbar.py:43-62`, confirmed). +- `app_desktop/workbench_layout.py` — `make_config_rail` returns a **4-tuple** (adds the `CurrentPageStack`); build the stack, `scroll.setWidget(stack)` **directly** (do not route through `_scroll_wrapper`, which renames its content arg — objectName-clobber risk). `build_workbench_main_splitter` stores `owner.workbench_config_stack` and flips `setStretchFactor(2, 0)` → `setStretchFactor(2, 1)` (currently `:137`, verified — result rail is child index 2, stretch 0 today). +- `app_desktop/panels.py` — in `build_ui`, replace `addWidget(_main_splitter, 1)` (currently at `:337`) with an HBox host `[icon_rail | splitter]`; add `workbench_icon_rail`. Leave `_refresh_main_splitter_left_min_width` (defined `panels.py:583`) unchanged. +- `app_desktop/window.py` — add `_toggle_config_rail` (alias `_toggle_config_collapsed` used by Batch 4) and `_show_config_page(index)` delegators, bounds-guarded `0 <= index < stack.count()`. +- `tests/test_desktop_workbench_icon_rail.py` — **NEW**. + +**Ordered TDD steps** +1. **RED** — write the test file with the *corrected* collapse assertion (see risk C1 below): do NOT assert `visual_contract_issues == []` after collapse. +2. **GREEN (layout)** — `make_config_rail` builds `config_content` (explicit `objectName`), wraps in `CurrentPageStack` (page 0 = config_content), `scroll.setWidget(stack)` directly; return 4-tuple; splitter stores `owner.workbench_config_stack`, flips stretch `(2,0)→(2,1)`. +3. **GREEN (icon rail)** — collapse button via `_call_owner`; page buttons via `lambda`/`partial(index)`. +4. **GREEN (panels)** — HBox host replaces the `addWidget(_main_splitter,1)` at `panels.py:337`. +5. **GREEN (window)** — `_toggle_config_rail` + `_show_config_page` with the `0 <= index < stack.count()` guard. +6. **VERIFY** — new test + pinned suite under offscreen; watch `tests/test_desktop_workbench_layout.py:44-46`. +7. **REFACTOR** — ruff the five files; confirm ratchet headroom. + +**New/updated tests** +- `test_icon_rail_is_root_hbox_sibling_not_splitter_child` — icon rail lives in the HBox host; `splitter.count()==3`; `splitter.indexOf(icon_rail)==-1`. +- `test_config_stack_page0_is_config_content` — the stack is a `CurrentPageStack`, is the config scroll's `.widget()`, and `widget(0) IS workbench_config_content`. +- `test_collapse_hides_config_and_widens_result` — after collapse: `config_rail.isVisible()` False; `count()==3`; result width increased; visual-contract issues limited to the single config missing-region entry (**NOT `== []`** — see C1). +- `test_splitter_state_still_round_trips` — save/restore keeps 3 panes + left-min-width invariant. + +**Behavior preservation (file:line)** +- 3-pane count preserved: icon rail goes into the new HBox host, never `splitter.addWidget` (`workbench_layout.py:130-132` unchanged). +- Config scroll keeps `CONFIG_RAIL_OBJECT`; the stack is nested *inside* it (`workbench_layout.py:57-66`). +- No mixin/MRO edits → `tests/test_window_mixin_composition_guardrails.py` unaffected. +- `options_box` stays a child of config_content page 0; `window.options_box` still resolves (`tests/test_desktop_global_options_ui.py:131`). +- Splitter save/restore blob shape unchanged (nesting deeper, but splitter children identical) — `tests/test_splitter_persistence.py:121`. +- left-min-width math unchanged: `CurrentPageStack.minimumSizeHint` delegates to config_content (`current_page_stack.py:16-20`); leave `panels.py:597` untouched. + +**Risks + mitigations** +- **C1 (CONFIRMED DEFECT in the naïve test):** after collapse, `visual_contract_issues` is NOT `[]` — the `missing_workbench_region` check (`workbench_visual_contract.py:66-67`, verified) fires for the hidden config rail because it is *not* gated by visibility. → Rewrite the assertion to expect exactly the config missing-region entry. **(Batch 4 later relaxes this check to make a hidden config rail a legal state — see cross-batch section.)** +- **C2 (CONFIRMED):** `_call_owner` passes no index; page-switch buttons need `lambda`/`partial`. +- **C3 (CONFIRMED):** objectName clobber if the stack is routed through `_scroll_wrapper` (renames content arg). → Set config_content name explicitly + `scroll.setWidget(stack)` directly. +- **C4 (CONFIRMED):** `_show_config_page` needs the `0 <= index < stack.count()` guard (out-of-range `setCurrentIndex` warns; not a clean no-op). + +**File-size impact:** `panels.py` 2169 → ~2175 (limit 2207, PASS). `workbench_layout.py` 154 → ~164 (<800). NEW `workbench_icon_rail.py` ~120-160 (<800). `window.py` 3198 → ~3212 (limit 3221, PASS, ~9-line headroom — tight). Test file exempt. + +--- + +## Batch 2 — Control migration + frequency tiering + +> **Adjudication (contradiction between the two Batch-2 readings resolved in favor of the fact-checked version):** the original plan's mitigation of "leave the extracted page parentless on owner until Batch 1" is **rejected as a user-visible regression** — it removes 6 live controls from the running GUI. This batch **hard-depends on Batch 1** (未决 Q-A); if the orchestrator insists on landing it before Batch 1, the extracted page MUST mount into the existing visible rail (`workbench_config_layout` / `output_setup_section_layout`) as an interim fallback. + +**Files touched** +- `app_desktop/workbench_options_page.py` — **NEW**. Extract `panels.py:920-1032` (the 6 compute controls + parallel restore/save wiring). **Do NOT move `panels.py:916-919`** — that is `options_box = QGroupBox('选项')` + `self.options_box = options_box` + title registration + `options_layout = QVBoxLayout(options_box)`; `options_box` must stay in `panels.py` as the schema-scanned container. **`build_options_stack_page(owner)` must return `tuple[QWidget, dict]` where the dict carries all 8 bind inputs**: `label_precision`, `unc_label`, `lbl_parallel_mode`, `lbl_parallel_workers`, `lbl_parallel_reserve`, `lbl_nested_policy`, **`parallel_mode_items`**, **`nested_policy_items`** — the last two are consumed by the bind call (`panels.py:1108-1109`). Returning only labels raises `TypeError` at the bind call. +- `app_desktop/panels.py` — `build_left_panel` (698-1137). Source all 8 bind inputs from the returned dict at the `_bind_global_options_schema_fields` call (`panels.py:1100-1113`, takes 11 kwargs). `_bind_global_options_schema_fields` itself (defined `panels.py:1772`) is **not** moved — only called. Mount the returned page widget into a **visible** container this batch. +- `app_desktop/workbench_history_page.py` — **NEW** (secondary pages). Reuse existing handlers — verified names: `self.new_workspace` / `self.open_workspace` / `self.save_workspace` / `self.save_workspace_as` / `self.open_example_workspace` (workspace QActions in `build_menu`, `panels.py:207-243` — the **menu bar**, not toolbar buttons), and `self._export_csv_data` / `self._export_result_plot` (export buttons at `panels.py:1247` / `:1275`). **NOT** `self.export_csv` / `self.export_*` as an earlier draft stated. Do not re-bind `results.export.csv` / `results.image.export` on relocated buttons (already owned, `panels.py:2102-2106`). +- `tests/test_desktop_options_page_migration.py` — **NEW**. +- `tests/test_file_size_ratchet.py` — **OPTIONAL** baseline lower for hygiene; **not test-forced** (growth-only check at `:108`; actual 2169 already ≤ 2167+40). + +**Ordered TDD steps** +1. **RED (attribute preservation)** — assert the 6 widgets survive with identical `objectName`/range/default/schema_key. Verified ranges: precision `MIN..MAX_MPMATH_DPS` default 16 (`panels.py:924-925`); uncertainty 1..12 default 1 (`:935-937`); max_workers 0..1024 default 0 (`:966-968`); reserve 0..1024 default 1 (`:970-972`). Schema keys: `options.precision_digits`, `options.uncertainty_digits`, `parallel.mode`, `parallel.max_workers`, `parallel.reserve_cores`, `parallel.nested_policy` (`panels.py:1789-1855`). **`datalab_schema_required` is True only for precision/uncertainty/mode/nested_policy**; `max_workers` + `reserve_cores` are `required=False` (`panels.py:1830,1840`) — do NOT assert required=True on those two. +2. **GREEN (extract)** — move `panels.py:920-1032`; return `tuple[QWidget, dict]` with all 8 inputs. +3. **RED (schema binding intact)** — `find_unbound_required_widgets(window.options_box) == []` still holds (moved required widgets are no longer Qt-children of `options_box`; `ui_schema_binder.py:76-87`). +4. **GREEN (rewire bind)** — source all 8 values from the returned dict. Inline LaTeX-group labels (`panels.py:1049,1055,1063`) stay in `options_box`, passed unchanged. +5. **RED (page hosts controls, visibly)** — assert each of the 6 controls' parent-ancestry reaches **`window.workbench_config_rail`** (the visible pane-0 scroll area), NOT `workbench_config_content`. **⚠ CORRECTION (Codex, CONFIRMED against `current_page_stack.py:7`):** page 0 of the stack IS `workbench_config_content`; a NEW 选项 page mounted in `workbench_config_stack` is a **sibling** of `workbench_config_content` and a **child of the stack**, so migrated controls are NOT descendants of `workbench_config_content`. Asserting ancestry to `workbench_config_content` would FALSE-FAIL. Assert ancestry to `workbench_config_stack` (Batch-1-present) or `workbench_config_rail` (always valid, covers the interim mount too). This still catches the orphan regression (a parentless page fails the rail-ancestry check). +6. **GREEN (placement)** — mount the page widget into the 选项 stack page (Batch 1 present) or into `output_setup_section_layout` (interim). Same batch — do not defer mounting. +7. **RED (secondary pages reuse handlers)** — assert workspace buttons connect to `self.new_workspace`/`open_workspace`/`save_workspace` and export buttons to `self._export_csv_data`/`self._export_result_plot`. +8. **GREEN (thin relocation)** — connect new `QPushButton`s to the exact existing bound methods; **do not re-bind** export schema keys. Mount into a stack page only if `getattr(owner,'workbench_config_stack',None)` exists. +9. **Regression sweep** — include `tests/test_desktop_global_options_ui.py` (`:131` options_box schema-clean). +10. **Ratchet (optional)** — `wc -l`; lower `_BASELINE['app_desktop/panels.py']` only for hygiene. +11. `graphify update .` + +**New/updated tests** +- `test_moved_compute_controls_preserved` — 6 widgets survive identical `objectName`/range/default/schema_key; NOT asserting required=True on `parallel.max_workers`/`parallel.reserve_cores`. Guards workspace save/load getattr at `app_desktop/workspace_controller.py:752-753` (load) / `:1113-1114` (save) — **corrected path/lines** (an earlier draft's `workspace_controller.py:745,1112` implying `datalab_core/` is wrong on both file and line). +- `test_options_box_has_no_unbound_required_widgets` — `find_unbound_required_widgets(window.options_box) == []` (function `ui_schema_binder.py:76`; assertion mirrors `test_desktop_global_options_ui.py:131`). +- `test_compute_controls_remain_visible` (**ADDED**) — parent-ancestry of each of the 6 controls reaches `window.workbench_config_rail` (or `workbench_config_stack` when Batch 1 present), NOT `workbench_config_content` (migrated controls are siblings of page 0, not its descendants — see step 5). +- `test_secondary_pages_reuse_existing_entrypoints` — reuse of `self._export_csv_data`/`_export_result_plot` and `self.new_workspace`/`open_workspace`/`save_workspace`. + +**Behavior preservation (file:line)** +- `options_box` created at `panels.py:916-919`, added to `output_setup_section_layout` at `:1122`; kept in `panels.py`. Moving `920-1032` is safe for the schema-clean test (`:131` checks only `options_box`'s own Qt-child subtree). +- `_bind_global_options_schema_fields` (`panels.py:1772-1786`) requires 11 kwargs incl. `parallel_mode_items`+`nested_policy_items` (`:1781-1782`) → extraction must return them. +- Widget binding is parent-independent (`bind_field` by `self.`, `panels.py:1930-1956`). +- 3-pane splitter untouched this batch (`count()==3`). +- Export buttons carry schema (`panels.py:2102-2106`) → reuse handler, no re-bind. + +**Risks + mitigations** +- **Orphaned controls (rejected mitigation):** leaving the page parentless removes 6 visible controls. → Mount into the visible rail in-batch; treat Batch 1's stack re-parent as a later no-op. +- **Wrong return signature:** `dict[str,QLabel]` omits the two item-lists → `TypeError` at bind. → Return all 8. +- **Wrong citations (corrected):** `workspace_controller` is `app_desktop/` at `752-753`/`1113-1114`/`1474-1476`; `find_unbound_required_widgets` is `ui_schema_binder.py:76`; schema-clean assertion is `test_desktop_global_options_ui.py:131`. +- **Wrong handler names (corrected):** `self._export_csv_data`, `self._export_result_plot`; workspace actions are menu `QAction`s (`panels.py:207-243`). No double-bind of export schema keys. + +**File-size impact:** `panels.py` 2169 → ~2056 after moving ~113 lines (well under limit). Two new modules <800. Ratchet update optional (growth-only check, actual already under baseline+40). + +--- + +## Batch 3 — Layout-coupled GUI fixes (F19 Run placement, F04 toolbar Run/Stop state) + +**Files touched** +- `app_desktop/workbench_toolbar.py` — Run method list `['run_extrapolation','run_calculation']` (`:175-176`); Stop list `['stop_calculation','_stop_current_worker']` (`:186-187`). **Neither `run_extrapolation` nor `stop_calculation` is a desktop OWNER method** — toolbar dispatch resolves only against the owner (the window) via `_call_owner` (`workbench_toolbar.py:43`), and the window has no such attribute (`getattr(window,'run_extrapolation',None) is None`), so both are no-op fall-throughs and safe to delete. **⚠ Precision (Codex, CONFIRMED):** `run_extrapolation` is NOT literally "zero defs anywhere" — it exists as a core service function at `datalab_core/extrapolation.py:103` (unrelated to toolbar dispatch); `stop_calculation` genuinely has zero defs. Deleting the two toolbar STRINGS is safe regardless, because dispatch never reaches the core function. Delete both ghost strings → Run `['run_calculation_start']`, Stop `['_stop_current_worker']`. Add `dynamic_owner.workbench_stop_button.setVisible(False)` after `:192`. +- `app_desktop/window.py` — overrides `_set_button_to_stop_mode` (`:677`) and `_set_button_to_run_mode` (`:683`, verified). Append `apply_workbench_run_toolbar_state(self, running=True/False)` at each tail (lazy import inside the method, matching existing style). +- `app_desktop/workbench_run_toolbar_state.py` — **NEW** (<60 lines). `apply_workbench_run_toolbar_state(owner, *, running)` with `getattr` None-guards on `workbench_run_button`/`workbench_stop_button`. running: stop visible+enabled, run hidden; idle: reverse. +- `app_desktop/window_extrapolation_mixin.py` — `run_calculation` at `:180-184` **is a toggle** (`if self._has_running_worker(): self._stop_current_worker(); return`); `_has_running_worker` at `:112-119`. Add `run_calculation_start(self)` to **this same mixin** (no MRO change): `if self._has_running_worker(): return; self.run_calculation()`. +- `tests/test_desktop_workbench_toolbar.py` — EXISTS (133 lines); **ADD** F04/F19 tests, do not overwrite. +- `app_desktop/theme.py` — OPTIONAL `#workbench_stop_button` rule mirroring the run-button active style (`:698-702`); `theme.py` is not ratchet-baselined. Skip if default styling acceptable. + +**Ordered TDD steps** +1. **STEP 0 (orient)** — confirmed: neither ghost is a desktop OWNER method (`getattr(window,...) is None`), so both toolbar strings are safe to delete (note `run_extrapolation` DOES exist as a core service fn at `datalab_core/extrapolation.py:103`, unrelated to toolbar dispatch; `stop_calculation` has no def); `run_calculation` toggle at mixin `:180-184`; window overrides `:677`/`:683`; config-panel run_button (`panels.py:1124-1136`) unchanged. Correction: config rail + splitter are built in `workbench_layout.py:57-66,110-123`, NOT `panels.py`; only `run_section` is in `panels.py:717-721`. Toolbar is added to `workbench_root` (`panels.py:334-335`) BEFORE `_main_splitter` (`:336`) → toolbar is outside the splitter. +2. **RED (F04 state)** — idle: run `isHidden()==False`, stop `isHidden()==True` (use `isHidden()`, not `isVisibleTo`, under offscreen). Then `_set_button_to_stop_mode()` → stop shown/run hidden; `_set_button_to_run_mode()` → reverted. **Idle correctness depends entirely on STEP 6.** +3. **RED (F04 no-toggle)** — stub `_has_running_worker→True` (plain bool), record `_stop_current_worker`, click `workbench_run_button`, assert `_stop_current_worker` NOT called. Genuinely RED today (Run's first method resolves to the toggle). +4. **GUARD (not RED)** — assert `getattr(window,'run_extrapolation',None) is None` and `getattr(window,'stop_calculation',None) is None`. Already None today — a green regression guard, not RED-first. +5. **GREEN** — create `workbench_run_toolbar_state.py` with None-guarded getattr. +6. **GREEN (choke point)** — append `apply_workbench_run_toolbar_state(self, running=True)` after `window.py:681`; `running=False` after `:685`. +7. **GREEN (initial state — MANDATORY)** — add `workbench_stop_button.setVisible(False)` after `workbench_toolbar.py:192`. This is the ONLY thing establishing idle state at build; STEP 2 depends on it. +8. **GREEN (no-toggle Run)** — add `run_calculation_start` to the mixin; flip the two toolbar lists; delete both ghosts. `_call_owner` passes `clicked(bool)` then falls back to no-arg on `TypeError` (`workbench_toolbar.py:52-53`) — behavior-neutral. +9. **VERIFY (shortcut)** — config-panel `run_button.clicked→run_calculation()` (`panels.py:1135`) + `setShortcut('Ctrl+Return')` (`:1130`) UNCHANGED. +10. **F19 (defer relocation)** — do NOT reparent `run_section`. ADD test asserting `window.workbench_config_rail.isAncestorOf(window.workbench_run_button) is False` (rail attr at `workbench_layout.py:123`). Relocating `run_section` would break `tests/test_desktop_shell_layout.py` left_layout order pin — deferred to a later batch that updates that test. +11. **REGRESSION** — offscreen pytest on `test_desktop_workbench_toolbar.py`, `test_desktop_shell_layout.py`, `test_desktop_workbench_layout.py`, `test_file_size_ratchet.py`, `test_window_mixin_composition_guardrails.py`. Confirm `test_toolbar_language_switch_keeps_actions` stays green — the new helper NEVER calls `setText` (visibility only). +12. `graphify update .` + +**New/updated tests** (append to `tests/test_desktop_workbench_toolbar.py`) +- `test_toolbar_run_stop_reflect_run_state` — idle run visible/stop hidden (via STEP 7); after stop-mode stop shown/run hidden; reverted after run-mode. Use `isHidden()`, not `isVisibleTo`. +- `test_toolbar_run_does_not_stop_running_job` — stub running, click Run, assert `_stop_current_worker` NOT called. RED today. +- `test_toolbar_stop_button_stops_running_worker` — stub running, click Stop, assert `_stop_current_worker` called once. +- `test_toolbar_no_ghost_dispatch_names` — `run_extrapolation`/`stop_calculation` attrs None AND Run/Stop resolve real callables (`run_calculation_start`/`_stop_current_worker`). +- `test_toolbar_run_button_is_outside_config_scroll` — `workbench_config_rail.isAncestorOf(workbench_run_button) is False`. Pins F19. + +**Behavior preservation (file:line)** +- 3-pane count==3: splitter (`workbench_layout.py:110-123`) untouched; toolbar edits are outside the splitter (`panels.py:334-335`). +- `run_calculation_start` on the EXISTING `WindowExtrapolationMixin` (owns `run_calculation` `:180`, `_has_running_worker` `:112`) — no new class/base-order → `test_window_mixin_composition_guardrails.py` unaffected. +- Config-panel run_button + Ctrl+Return unchanged (`panels.py:1130,1135`); `run_calculation` still a toggle for the in-config button. Toolbar helper is additive, visibility-only. +- i18n: `_apply_language` (`window.py:650-663`) re-invokes `_set_button_to_(stop|run)_mode`; the helper runs inside those, so visibility re-applies on language switch. Helper never `setText` → `test_toolbar_language_switch_keeps_actions` green. +- No widget attr renamed/removed; `workbench_run_button` (`:169`), `workbench_stop_button` (`:180`), `run_button` (`panels.py:1124`) preserved. +- No `shared/ui_specs.py`/`help_specs.json` edit → no drift. `options_box` untouched. + +**Risks + mitigations** +- Ghosts confirmed non-resolvable as owner methods (`run_extrapolation` exists only as a core service fn, not on the window; `stop_calculation` has no def) → deleting the toolbar strings is behavior-neutral. +- `_has_running_worker` returns a truthy short-circuit chain, not strict bool → `run_calculation_start` guard uses truthiness; STEP 3 stub returns plain `True`. +- Idle assertion has no existing initializer → STEP 7 `setVisible(False)` is MANDATORY. +- `apply_...toolbar_state` may run before buttons exist → getattr None-guards; transitions fire only post-build. +- `run_calculation_start` reintroducing a toggle → guard before delegating; `run_calculation` stop-branch (`:182-184`) unreachable once guard passes; test pins it. +- Stop unstyled when shown → optional `theme.py` rule (outside ratchet). +- **F19 relocation batch (future)** must edit `workbench_layout.py` and WILL break `test_desktop_shell_layout.py` left_layout order pin — that test updates in that batch. (未决 Q-F: confirm the always-visible toolbar Run/Stop pair satisfies "always-visible Run" for Batch 3.) + +**File-size impact:** `window.py` 3181 baseline (+~4, safe). `panels.py` no edit this batch (stays 2169). `window_extrapolation_mixin.py` 1132 baseline, actual 1129 (+~4 `run_calculation_start`, safe). NEW `workbench_run_toolbar_state.py` ~50 (<800). `workbench_toolbar.py` (234) and `theme.py` not baselined. + +--- + +## Batch 4 — Fold-to-widen + focus mode + layout memory + +> **Adjudication (contradiction with Batch 1's "never call setSizes" rule resolved):** the fold-to-widen mechanism must be **deterministic and testable offscreen**, which stretch-factor redistribution is NOT (it depends on live geometry / resize events). [Codex's own probe confirmed `setStretchFactor(2,1)`+hide DOES widen result in a live window `[0,862,530]`, but it's non-deterministic offscreen — so stretch handles interactive drag, and Batch 4 uses an explicit `setSizes` for the testable fold target.] Snapshot sizes and call `setSizes([~0, workspace, enlarged_result])` with a **length-3** list (`==count()`). This does not violate the splitter invariant — existing tests only forbid *wrong-length* `setSizes`. `count()==3` is preserved by `setVisible(False)`, never add/remove. + +**Files touched** +- `app_desktop/workbench_fold.py` — **NEW** (~130-180 lines). Pure free functions on the window owner: `toggle_config_collapsed` / `set_config_collapsed` / `toggle_focus_mode` / `set_focus_mode` / `save_layout_state` / `restore_layout_state`. Fold-to-widen via snapshot + length-3 `setSizes`. +- `app_desktop/workbench_visual_contract.py` — **line numbers corrected:** `visual_contract_issues()` at `:62`; missing-region check at `:66-67` (`if not metric.visible or metric.width <= 0 or metric.height <= 0`); config.visible-gated width check at `:74`; region_order at `:92`. **Relaxation:** in the `:66` loop, skip the `missing_workbench_region` emission for `CONFIG_RAIL_OBJECT` when that widget's `isHidden()` is True. `visual_contract_issues(root)` takes only `root` — read live `isHidden()`, not a passed-in flag. ~4-6 lines; file is 97 lines, no ratchet concern. **This is the relaxation that turns Batch 1's C1 config-collapsed state into a legal `== []` state.** +- `app_desktop/panels.py` — (a) call `workbench_fold.restore_layout_state(self)` at **~L432, AFTER the splitter-restore try/except block that ends at `:431`** (restoring earlier is clobbered by `splitter.restoreState`/`setSizes`). (b) `build_menu`: add a View `QMenu` with two checkable `QAction`s (`Ctrl+[` collapse, `Ctrl+Shift+F` focus) via `_register_text(widget, zh, en, 'setText'|'setTitle')` (signature `window_i18n_mixin.py:314`). If the menu grows, move it into `workbench_fold.build_view_menu`. +- `app_desktop/window.py` — non-mixin delegators mirroring the `_refresh_main_splitter_left_min_width` delegator at `window.py:593-595` (pattern `from . import workbench_fold; workbench_fold.(self, ...)`). Extend `closeEvent` (**def at `:3107`**, on `ExtrapolationWindow` main class L467, NOT a mixin) to also call `workbench_fold.save_layout_state(self)`. +- `shared/settings_store.py` — add `KEY_MAIN_CONFIG_COLLAPSED` / `KEY_MAIN_FOCUS_MODE` / `KEY_MAIN_ACTIVE_CONFIG_PAGE` next to `KEY_MAIN_SPLITTER_STATE` (`:411`), under the `MainWindow/` prefix (`_ALLOWED_KEY_PREFIXES` at `:83`). Reuse `save_bool`/`load_bool` (`:318`/`:328`) and `save_int`/`load_int` (`:267`/`:278`). +3 constants only. +- `tests/test_desktop_workbench_fold.py` — **NEW** (must run `resize(1400,900); show(); processEvents()` before any width assertion). +- `tests/test_desktop_workbench_visual_contract.py` — UPDATE (additive): `visual_contract_issues(window) == []` after `set_config_collapsed(win, True)`. +- `tests/test_splitter_persistence.py` — UPDATE (additive): one new test round-tripping the 3 keys, reusing `_fake_settings` (`:37`). Existing 3 tests unchanged (note `:123-124` reads `sizes()[0]`/`[2]` post-layout — valid, do not disturb). + +**Ordered TDD steps** +1. **STEP 0 (orient)** — confirmed absent: `workbench_icon_rail`, `workbench_config_stack`, View menu, `Ctrl+[`/Ctrl+Shift+F. Fold operates on `self.workbench_config_rail` (`workbench_layout.py:123`) + `self._main_splitter`. `mode_stack` is in `workbench_workspace_layout` (center pane, `panels.py:353`), NOT a splitter child → `count()==3` regardless of fold. +2. **RED (collapse)** — `resize/show/processEvents`; snapshot pre-collapse result width; `_toggle_config_collapsed()`; assert `config_rail.isVisible() is False`, `count()==3`, `len(sizes())==3`, result width ≥ pre-collapse. Toggle back → visible True, count 3. +3. **GREEN (collapse)** — `set_config_collapsed(win, True)`: snapshot `cur = splitter.sizes()` (len 3); `config_scroll.setVisible(False)`; build length-3 sizes putting ~0 (or config min) at index 0, adding freed width to result (index 2), keeping workspace (index 1) ≥ min; `splitter.setSizes(new_sizes)`. Expand: `setVisible(True)` + restore snapshot. Add window.py delegators per `:593-595` pattern. +4. **RED (focus)** — same setup; `_toggle_focus_mode()`; assert focus flag True, config hidden, result is widest (`max(sizes())` index==2), `count()==3`; toggle off restores prior config visibility. +5. **GREEN (focus)** — `set_focus_mode(win, True)`: snapshot `_pre_focus_config_collapsed` + sizes; hide config rail; hide `getattr(win,'workbench_icon_rail',None)` if present; `setSizes` pushing max width to result (index 2). Exit: restore config to `_pre_focus_config_collapsed` + restore snapshot; re-show icon rail if previously shown. `mode_stack` untouched (5-mode invariant, `test_desktop_mode_stack.py` indices 0-4). +6. **RED (visual contract)** — `visual_contract_issues(window) == []` with config collapsed; normal window still `== []`. +7. **GREEN (visual contract)** — in the `:66` loop, skip `missing_workbench_region` for `CONFIG_RAIL_OBJECT` when its live `isHidden()` is True. `:74`/`:92` checks are already config.visible-gated, auto-skip a hidden rail. +8. **RED (memory)** — set collapsed+focus, `save_layout_state(win)`, restore into a fresh window / re-read keys, assert flags restored (via `_fake_settings`). +9. **GREEN (memory)** — `save_layout_state` writes `KEY_MAIN_CONFIG_COLLAPSED` (save_bool), `KEY_MAIN_FOCUS_MODE` (save_bool), `KEY_MAIN_ACTIVE_CONFIG_PAGE` (save_int from `getattr(config_stack,'currentIndex',lambda:0)()`) via `win._settings_store` (cached in `build_ui`, `panels.py:383`). `restore_layout_state`: load_bool default False, load_int default 0 (min 0/max pages); apply set_config_collapsed/set_focus_mode; apply active page only if `workbench_config_stack` exists. Wiring: closeEvent save (`window.py:3107`) + build_ui restore at `panels.py:~432` after `:431`. +10. **INTEGRATION** — offscreen pytest on listed files + `test_file_size_ratchet.py`; ruff + mypy on `shared/settings_store.py` (mypy strict covers `shared`); `graphify update .` +11. **STEP 11 (animation)** — animation is OFF by default (Q-E): the `set_*` collapse/focus path is the non-animated path and is what tests exercise; any 150ms fold animation is an optional, off-by-default enhancement layered on top, never in the test path. + +**New/updated tests** +- `test_config_collapse_hides_rail_keeps_count_three` (show/resize/processEvents before width asserts). +- `test_focus_mode_maximizes_result` (`max(sizes())` index==2, needs layout cycle). +- `test_focus_exit_restores_prior_collapse`. +- `test_layout_state_round_trips` (via `_fake_settings`; save_bool/load_bool + save_int/load_int). +- `test_shortcuts_registered` (`Ctrl+[` / Ctrl+Shift+F QActions present & checkable). +- `test_collapsed_config_rail_is_a_legal_state` (isHidden()-gated skip). +- `test_layout_flags_round_trip` (additive; existing 3 splitter-persistence tests unchanged). + +**Behavior preservation (file:line)** +- 3-pane count: collapse = `setVisible(False)` on config child + length-3 `setSizes`; never add/remove, never wrong-length `setSizes`. `count()==3` (`test_splitter_persistence.py:121/178`, `test_desktop_mode_stack.py:136`). +- MRO: only free functions + non-mixin delegators mirroring `window.py:593-595`; `closeEvent` extension on the main class (`:3107`), not a mixin → `test_no_mixin_overrides_a_qt_event_handler` green. +- `options_box` untouched (only View menu + restore call added). +- workspace/`.datalab` path untouched; layout memory uses separate `MainWindow/` keys. +- No `shared/ui_specs.py`/`help_specs.json` change → no drift. +- Persistence: `KEY_MAIN_SPLITTER_STATE` save/restore byte-identical; new keys additive under allowlisted prefix (`_validate_key` at `:119`). + +**Risks + mitigations** +- **setStretchFactor redistribution is non-deterministic offscreen** (Codex probe: live window `[0,862,530]` DOES widen, but not reliably in headless tests) → Batch 4 uses an explicit length-3 `setSizes` for a deterministic, testable fold target. (Overrides Batch 1's blanket "never setSizes" → narrowed to "never wrong-length setSizes".) +- **Offscreen sizes read `[0,0,0]` until show/resize/processEvents** → every width-asserting test runs the layout cycle first. +- **`isHidden()` distinguishes explicit `setVisible(False)` from off-screen parent** → STEP 7 relaxation sound. +- **Corrected line numbers:** `visual_contract_issues` `:62`; window delegator template `:593-595`; `closeEvent` def `:3107`; `_refresh_main_splitter_left_min_width` def `:583` (called `:363`/`:418`); splitter-restore block spans `:373-431` → restore at `:432`. +- **Ratchet math (frozen baselines):** `panels.py` current 2169, baseline 2167, limit 2207, headroom LEFT 38; `window.py` current 3198, baseline 3181, limit 3221, headroom LEFT 23 — keep window additions terse. +- icon_rail/config_stack absent → getattr-guarded no-ops. +- `Ctrl+[` / Ctrl+Shift+F custom `QKeySequence` strings, no in-app collision; mirrored in View menu. + +**File-size impact:** NEW `workbench_fold.py` ~130-180 (<800). `panels.py` 2169 → ~2187 (limit 2207, 38-line cushion). `window.py` 3198 → ~3208 (limit 3221, 23-line cushion). `workbench_visual_contract.py` 97 → ~103. `settings_store.py` +3 constants (460 → ~463). No baseline raise required. + +--- + +## Batch 5 — Polish (F03 progress feedback, F14 dark-mode formula preview, empty-state card) + +> Functionally independent of Batches 1–4. Touches `window.py` + formula files, so sequence last (or a parallel worktree) to avoid `window.py` merge churn. **`window.py` ratchet headroom is tight (23 lines) — see file-size impact.** + +**Files touched** +- `app_desktop/formula_render_color.py` — **NEW** (<60 lines, Qt-free). `preview_formula_color(dark: bool) -> str` → `'#111827'` (light) / a light gray (e.g. `'#E5E7EB'`) (dark). Single source for F14. +- `app_desktop/formula_preview.py` — add an optional `color` param (default `'#111827'` — keeps legacy/dialog callers byte-identical) to `render_formula_pixmap()` (def `:198`; `RenderRequest` built `:218`) AND `update_formula_preview_with_empty_text()` (def `:237`; `RenderRequest` built `:258-264`). Pass `color` into BOTH `RenderRequest` constructions. Both call sites currently omit `color`, so `RenderRequest.color` falls back to its dataclass default `'#111827'`. +- `app_desktop/workbench_formula_panel.py` — in `refresh_formula_workspace_panel()` (def `:408`) compute `color = preview_formula_color(is_dark_theme())` and pass into the `update_formula_preview_with_empty_text(...)` call (`:453-462`). **`is_dark_theme` is NOT currently imported here** (theme import block `:24-32` omits it) → add `is_dark_theme` + `preview_formula_color` imports. +- `app_desktop/window.py` — **F14:** `_apply_desktop_theme()` (def `:2135`) does NOT currently refresh the formula preview (the `refresh_workbench_formula_panel` calls at `:2202-2203`/`:2345-2346` live in `_on_mode_change` etc.) → adding a refresh is genuinely new behavior. Reuse the already-computed `new_dark` (`:2144`) + already-imported `is_dark_theme` (`:2137`); add the `clear_formula_renderer_cache` import (not yet imported). Call the WINDOW method `self.refresh_workbench_formula_panel()` (def `:605`, hasattr-guarded like sibling refreshes `:2161-2172`) — NOT the module-level `refresh_formula_workspace_panel(self)`. **F03:** `_start_worker_with_workbench_result_state()` (def `:2741`) currently only connects `worker.failed` via `_install_workbench_worker_failure_guard` (`:2746`/`:2753`) with a try/except marking failed → wire the progress helper here. +- `app_desktop/workbench_run_progress.py` — **NEW** (<200 lines). Progress-feedback helper for F03 compute runs. +- Empty-state load-example card — result-area widget shown when no result exists, offering a load-example action. + +**Ordered TDD steps** +1. **RED (F14 color source)** — unit-test `preview_formula_color(True)` != `preview_formula_color(False)`; light == `'#111827'`. +2. **GREEN** — create `formula_render_color.py`. +3. **RED (formula_preview threads color)** — assert both `render_formula_pixmap` and `update_formula_preview_with_empty_text` accept `color` and pass it into `RenderRequest`; default `'#111827'` keeps `FormulaPreviewDialog._render_formula` (`:122`) byte-identical. +4. **GREEN** — add the param + thread into both `RenderRequest` constructions. +5. **RED (panel uses theme color)** — assert `refresh_formula_workspace_panel` passes a dark-aware color; requires the new imports. +6. **GREEN** — add imports + compute `preview_formula_color(is_dark_theme())`. +7. **RED (theme change refreshes preview)** — assert `_apply_desktop_theme` calls `self.refresh_workbench_formula_panel()` (hasattr-guarded). +8. **GREEN** — reuse `new_dark`/`is_dark_theme`; add `clear_formula_renderer_cache` import; call the window method. +9. **RED (F03 progress)** — assert `_start_worker_with_workbench_result_state` wires progress feedback without regressing the existing failure guard (`:2746`/`:2753`). +10. **GREEN** — create `workbench_run_progress.py`; wire it in. +11. **RED/GREEN (empty-state card)** — result area shows the load-example card when no result; the card's action reuses an existing example-load handler. +12. **REGRESSION** — offscreen pytest on formula/window/result tests + `test_file_size_ratchet.py`; ruff + mypy on the Qt-free `formula_render_color.py`; `graphify update .` + +**New/updated tests** +- `test_preview_formula_color_is_theme_aware` (Qt-free unit). +- `test_formula_preview_threads_color_into_render_request` (both functions; default byte-identical). +- `test_formula_panel_uses_dark_aware_color`. +- `test_apply_desktop_theme_refreshes_formula_preview` (hasattr-guarded window method call). +- `test_start_worker_wires_progress_without_regressing_failure_guard`. +- `test_result_area_shows_empty_state_card_when_no_result`. + +**Behavior preservation (file:line)** +- Default `color='#111827'` keeps `FormulaPreviewDialog._render_formula` (`:122`) and all legacy callers byte-identical. +- `_apply_desktop_theme` reuses existing `new_dark` (`:2144`) / `is_dark_theme` (`:2137`); the new preview refresh is additive and hasattr-guarded (mirrors `:2161-2172`). +- F03 wiring is additive to `_start_worker_with_workbench_result_state`; the existing failure guard (`:2746`/`:2753`) stays connected. +- No compute-path edit → precision discipline + `FitResult` split untouched. +- No `shared/ui_specs.py`/`help_specs.json` change → no drift. +- 3-pane splitter untouched (result-area card is a result-rail child, not a splitter child). + +**Risks + mitigations** +- **Wrong refresh call:** `refresh_formula_workspace_panel(self)` is the module-level func; the window delegates via `panels.refresh_workbench_formula_panel` → call `self.refresh_workbench_formula_panel()` (def `:605`). +- **Missing imports:** `is_dark_theme` (in `workbench_formula_panel.py`) and `clear_formula_renderer_cache` (in `window.py`) are not yet imported → add them. +- **`window.py` ratchet:** current 3198, limit 3221, only 23-line cushion. F14+F03 additions must be terse; if `_apply_desktop_theme` + `_start_worker...` glue exceeds budget, push logic into `workbench_run_progress.py` / a helper rather than inline. +- Multi-line `RenderRequest` at `:258-264` (an earlier draft cited only `:259`) — edit the whole construction. + +**File-size impact:** NEW `formula_render_color.py` <60, `workbench_run_progress.py` <200 (both <800). `formula_preview.py` 295 → ~300 (not baselined). `workbench_formula_panel.py` 789 → ~793 (approaching 800 soft limit — watch it; if it crosses, the empty-state helper must go elsewhere). `window.py` 3198 → keep under 3221 (tight, ~23-line budget for F14+F03 glue combined). Not ratchet-baselined: `formula_preview.py`, `workbench_formula_panel.py` (789 is under the 800 soft limit but any new file/split must stay under 800). + +--- + +## 跨批次一致性 + +**Shared attributes / objectNames established once, consumed later (do NOT rename after creation):** + +| Symbol | Created in | Consumed by | +|--------|-----------|-------------| +| `owner.workbench_config_stack` (a `CurrentPageStack`) | Batch 1 (`workbench_layout.py` `build_workbench_main_splitter`) | Batch 2 (mount 选项/历史/工作区 pages), Batch 4 (`active_config_page` restore) | +| `owner.workbench_icon_rail` | Batch 1 (`panels.py` HBox host) | Batch 4 (hide/show in focus mode, getattr-guarded) | +| `_toggle_config_rail` / `_toggle_config_collapsed` / `_show_config_page(index)` | Batch 1 (`window.py`) | Batch 4 (fold/focus reuse the collapse path) | +| `CONFIG_RAIL_OBJECT == "workbench_config_rail"` | Existing (`workbench_visual_contract.py:9`, `workbench_layout.py:123`) | **Must remain unchanged** — Batch 1 nests a stack inside it; Batch 3 asserts ancestry against it; Batch 4 gates the visual-contract relaxation on its `isHidden()`. | +| `owner.workbench_run_button` / `owner.workbench_stop_button` | Existing (`workbench_toolbar.py:169`/`:180`) | Batch 3 flips their dispatch lists + visibility; must not be renamed. | +| `run_calculation_start` | Batch 3 (`window_extrapolation_mixin.py`) | Toolbar Run dispatch | +| `KEY_MAIN_CONFIG_COLLAPSED` / `KEY_MAIN_FOCUS_MODE` / `KEY_MAIN_ACTIVE_CONFIG_PAGE` | Batch 4 (`settings_store.py`) | Layout memory round-trip | +| `preview_formula_color` | Batch 5 (`formula_render_color.py`) | `formula_preview.py` + `workbench_formula_panel.py` | + +**Ordering constraints:** +- **Batch 2 hard-depends on Batch 1** for the page host (未决 Q-A). If landed out of order, Batch 2 uses the interim visible-rail mount. +- **Batch 4's `active_config_page` key is a dead no-op until Batch 1** provides the stack (未决 Q-D). +- **Batch 4's visual-contract relaxation should land after (or with) Batch 1**, because Batch 1's collapse path first creates the hidden-config state that trips the un-relaxed `missing_workbench_region` check (Batch 1 risk C1). If Batch 4 precedes Batch 1, its relaxation is harmless (no hidden config exists yet) but its `test_collapsed_config_rail_is_a_legal_state` needs the collapse path — so Batch 4's own `set_config_collapsed` (which it defines) satisfies this independently of Batch 1. +- **Batch 3's F19 relocation is explicitly deferred**; the future relocation batch must edit `workbench_layout.py` and update `tests/test_desktop_shell_layout.py`'s left_layout order pin. + +**⚠ CUMULATIVE `window.py` ratchet budget (Codex, CONFIRMED — was budgeted per-batch, must be cross-batch):** +`window.py` is 3198 lines today; ratchet baseline 3181 + 40 headroom → hard limit **3221** (`test_file_size_ratchet.py:27,108`). Batches 1/3/4/5 each add glue to `window.py` and were EACH budgeted against 3198 in isolation — but the ratchet is cumulative, so their combined additions can exceed 3221 and fail a LATER batch's suite even though each looked fine alone. **Rule:** track a shared running total. Est. additions: B1 ~23, B3 ~15, B4 ~20, B5 ~12 → 3198+70 = ~3268 > 3221. **Mitigation (mandatory):** each `window.py`-touching batch must either (a) move its new glue into a NEW <800-line module (preferred — e.g. `workbench_fold_controller.py`, `workbench_run_state.py`) and keep `window.py` a thin caller, or (b) consciously raise the baseline in `test_file_size_ratchet.py` in that batch's PR with a one-line rationale. Default to (a). Batch 1's `_toggle_config_*`/`_show_config_page` and Batch 4's fold/focus controller are the biggest — put them in new modules, not `window.py`. + +**What CANNOT change until which batch:** +- The 3-pane splitter's child set and `count()==3` — **never** (all batches). +- `CONFIG_RAIL_OBJECT` — **never**. +- `options_box`'s identity + schema-clean status — must survive Batch 2's extraction unchanged (only its 6 compute children move; `options_box` itself stays in `panels.py:916-919`). +- The two ghost dispatch strings `run_extrapolation`/`stop_calculation` — removed **only in Batch 3**; earlier batches must not depend on them (they are already no-ops). +- `run_section` placement in `left_layout` — **frozen through Batch 3** (F19 relocation deferred); do not reparent until the dedicated relocation batch. + +**Explicitly-flagged contradiction between source plans (adjudicated + corrected by external review):** +- Batch 1 asserts "never call `setSizes`" as a splitter-safety rule; Batch 4 needs fold-to-widen. **Resolution:** the real invariant is "never a *wrong-length* `setSizes`, never add/remove children." A length-3 `setSizes` is safe and is the deterministic Batch-4 mechanism. +- **⚠ CORRECTION (Codex, CONFIRMED via its own offscreen probe):** the plan's justification "`setStretchFactor` alone does NOT widen the result rail, probe `[0,109,69]`" is **FALSE for the live window**. Codex's probe: hide-only keeps result unchanged (`[0,1072,320]`), but `setStretchFactor(2,1)` + hide DID widen result (`[0,862,530]`). So the result-rail `stretch 0→1` (Section 1) already contributes to fold-to-widen. The reason to STILL use an explicit length-3 `setSizes` in Batch 4 is **determinism** (stretch redistribution depends on live geometry / resize events and is not reliable offscreen for tests), NOT because stretch "doesn't work." Fix the plan's wording to say: stretch handles interactive redistribution; Batch 4 uses length-3 `setSizes` for a deterministic, testable fold target. + +--- + +## 已决问题 (RESOLVED via external dual-model adjudication — Codex + Gemini 3.1 Pro, 2026-07-03) + +Both models adjudicated all 6. Q-A/C/D/F: both AGREED. Q-B/E: models split → adjudicated against code (below). + +- **Q-A → HARD-GATE Batch 2 on Batch 1** (both agree). Clean mount into the `workbench_config_stack` 选项 page; the interim visible-rail mount stays documented only as an emergency fallback if forced out of order. +- **Q-B → DO NOT seed empty pages; keep page 0 only, make page-switch buttons for non-existent pages disabled/no-op until Batch 2** (Codex; adjudicated over Gemini's "seed empty pages"). *Reasoning:* empty pages would show a blank panel when clicked (worse UX than a disabled button) and add inert widgets; disabled buttons are simpler and honest. `CurrentPageStack.minimumSizeHint()` follows the current page (`current_page_stack.py:16`) so a single-page stack sizes correctly. +- **Q-C → Relocate ONLY existing entry points; reuse the workspace menu `QAction` handlers (`panels.py:207-243`) and existing export buttons; NO history/compare logic and NO duplicate schema-bound surfaces in Batch 2** (both agree). Duplicating a schema-bound export widget would create two widgets competing for one key (`panels.py:2102-2106`). +- **Q-D → Ship `KEY_MAIN_ACTIVE_CONFIG_PAGE` only in the layout-memory batch (Batch 4), getattr-guarded — NOT before Batch 1's stack exists** (both agree; Codex: persisting a constant 0 today proves nothing). +- **Q-E → (1) freed width goes to the RESULT rail (index 2) via explicit length-3 `setSizes`; (2) shortcut = `Ctrl+[` (not `Ctrl+B`); (3) animation OFF by default.** *Reasoning:* Codex CONFIRMED all text editors are `QPlainTextEdit`/`NumberedTextEdit` (no built-in Ctrl+B bold — that's `QTextEdit`), so `Ctrl+B` has no ACTUAL conflict; but Gemini's UX point stands that `Ctrl+B` reads as "bold" to users, and `Ctrl+[` has zero downside — adjudicated to `Ctrl+[`. Result-rail target and animation-off: both agree. +- **Q-F → Toolbar Run/Stop pair SATISFIES F19 for this batch; `run_section` relocation deferred; config-panel `run_button` stays as-is (its `_set_button_to_stop_mode` toggle unchanged this batch)** (both agree). Expanding the config button's toggle logic would widen Batch 3's scope/risk. + +## 审阅记录 (methodology) +Section 2 plan passed the external gate after: Gemini **PASS** (all anchors/invariants/contradictions verified); Codex **FAIL → 4 findings, all adjudicated CONFIRMED against code and fixed in-place**: (1) Batch-2 ancestry test must target `workbench_config_stack`/`workbench_config_rail` not `workbench_config_content`; (2) cumulative `window.py` ratchet budget (est. 3268 > 3221 limit → move glue to new <800-line modules); (3) the `setStretchFactor` "doesn't widen" justification was false (use length-3 `setSizes` for DETERMINISM, not because stretch fails); (4) `run_extrapolation` wording (it exists in `datalab_core/extrapolation.py:103`, just not as a desktop owner method — toolbar deletion still safe). A re-review confirms the corrected plan (below). diff --git a/docs/superpowers/specs/2026-07-04-iconified-menubar-design.md b/docs/superpowers/specs/2026-07-04-iconified-menubar-design.md new file mode 100644 index 00000000..d9d171f8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-iconified-menubar-design.md @@ -0,0 +1,129 @@ +# DataLab Desktop GUI — Icon-ified Menu Bar Redesign (user-confirmed, 2026-07-03) + +Baseline: clean `main` (original 3-pane layout). The abandoned page-switch sidebar +(Batch 1/2/4) is discarded — tag `abandoned/adaptive-workbench-sidebar`. + +## Confirmed direction (option B) +- 3-pane layout UNCHANGED. Every config control stays IN PLACE in the config rail — + reachable directly, never switched away, never hidden behind a non-default page. +- Menu bar becomes ICON-IFIED and gains two option menus. The current real menu bar is + 文件 · 示例 · 语言 · 主题 · 帮助 (build_menu, panels.py:201-318). Add icons to all, + and add TWO new icon menus placed after 文件: + - **计算 (Compute)** — two groups separated by a separator: + - 精度: mpmath_precision_spin (精度位数, panels.py:923), uncertainty_digits_spin + (不确定度位数, :935) + - 并行/资源: parallel_mode_combo (资源策略, :950), parallel_max_workers_spin + (最大 workers, :966), parallel_reserve_cores_spin (保留核心, :970), + parallel_nested_policy_combo (嵌套策略, :985) + - **LaTeX** — generate_latex_checkbox (生成 LaTeX 文件, :1033), output_file_edit + (输出路径, :1041), dcolumn_checkbox (:1057), latex_group_size_spin (分组位数, :1060), + caption_checkbox (使用标题, :1065). NOTE: latex_engine_combo (编译引擎) is NOT included — + it is a result-output control living in the LaTeX result tab and is only reachable after a + result exists (see the RESULT-ONLY note in the acceptance criterion); it stays where it is. +- **Menu = ADDITIONAL entry point, NOT the only one.** The controls stay in the config + rail. Menu items reflect/drive the SAME widget (two-way sync — literally the same + QWidget's value, or a menu action bound to the same model path). Never a duplicate + widget competing for one schema key. + +## ⚠ CORRECTIONS from external dual-model review (Codex + Gemini, both CONFIRMED against code) +1. **LaTeX controls ARE schema-bound** (the spec's "LaTeX controls are plain widgets" was WRONG). They carry schema keys / FormFieldSpec bindings (`panels.py:1856-1956`, plus `results.latex.*` at `:1396/1407`). Treat them like the other schema-bound controls for sync. +2. **`latex_engine_combo` is a RESULT-OUTPUT control in the LaTeX result tab (right panel), NOT a config-rail option** (`panels.py:1466`, moved there per the comment at `:1115`). Its outer container `self.tabs` is hidden until a result exists (`workbench_results.py:362`), so it is NOT reachable pre-result even by switching the subtab. → Removed from the config menu; it stays in the result tab where it belongs (see acceptance criterion RESULT-ONLY note). +3. **Result popover MUST be a separate top-level popup** (`QWidget(window, Qt.WindowType.Popup)` or `QMenu`/`QFrame` popup) positioned near the overview card. Qt clips a layout-managed child to its parent's bounds, so a card cannot "progressively enlarge" over its siblings in-layout. Do NOT reparent/move the existing overview widgets — Codex: reusing/moving `workbench_result_status_badge` (`workbench_results.py:45-103`) or the shell footer strip (`workbench_layout.py:93-108`) would recreate the one-parent hiding problem. CREATE NEW popup + status widgets that READ from the same status source; don't move existing ones. +4. **Reachability test must also assert parent/identity UNCHANGED** (not just `isVisible()`): after the visible gate action, assert `widget.isVisibleTo(window)` True AND `widget.parent()` is the same as before (no reparent). This is the stronger guard against the prior hiding bug. +5. Menu build order / lazy sync: build the two new icon menus in `build_menu` (panels.py:201-319) AFTER 文件; the checkable-action↔checkbox sync signals must be connected AFTER both the menu action and the target checkbox exist (lazy/after-build), guarded with `blockSignals`. + +## Result overview + status strip +- Result overview card → click/hover opens a POPOVER that progressively enlarges, + showing the full overview (method / value / uncertainty / elapsed / #points), and + disappears on mouse-away / click-outside. +- A MINIMAL always-visible status strip (result area footer): status badge + (waiting/running/done/error) + method + elapsed. Visible even when panels collapse, + so calculation status is always judgable. +- (Result maximization / fold can be a later, separate increment — NOT bundled here to + keep this change surgical. This spec covers the menu bar + popover + status strip.) + +## HARD acceptance criterion (the bug class the user caught) +Add an automated **reachability test**: for EVERY config control, assert it is reachable +via a VISIBLE, user-operable gate — i.e. after performing the visible action that reveals +it (check the gate checkbox / switch the input mode / open the menu), **`widget.isVisibleTo(window)` +is True AND `widget.parent()` is UNCHANGED** from before the action (no reparent). NEVER behind +a non-default page. Baseline on clean main (verified by probe): +- Always visible: mode_combo, method_combo, mpmath_precision_spin, uncertainty_digits_spin, + parallel_* (4), generate_latex_checkbox, generate_plots_checkbox, verbose_checkbox, run_button. +- Gated-but-reachable (must STAY reachable): manual_data_edit (input-mode QStackedWidget), + LaTeX config group (revealed by checking generate_latex_checkbox → verified: + latex_input_precision_spin becomes visible), display_digits_spin/scientific_checkbox (result + numeric tab, revealed by switching to that tab). +- **RESULT-ONLY, reachable only after a result exists** (Codex, CONFIRMED by probe): + `latex_engine_combo` (panels.py:1466) lives in the LaTeX result subtab, and its OUTER + container `self.tabs` is HIDDEN in the empty-result state (`tabs.setVisible(not is_empty)`, + workbench_results.py:362). Probe: even after `result_tabs.setCurrentIndex(latex)`, + `latex_engine_combo.isVisibleTo(window)` stays False until a result populates the tabs. + → This is a RESULT-OUTPUT control, not a config-time option. HANDLING: do NOT put it in the + 计算/LaTeX config menu as if it were reachable pre-result. Either (a) put a LaTeX-engine item + in the menu that is DISABLED with a tooltip ("compute a result first") until `self.tabs` is + visible, enabling it via the same result-state signal that shows the tabs; or (b) omit it from + the menu entirely (it already lives, correctly, in the LaTeX result tab). Prefer (b) — + keep the menu to genuinely config-time options; the engine picker stays where results are. + The reachability test for latex_engine_combo asserts it becomes visible ONLY in the + non-empty-result state (drive a fake result / _update_result_visibility(is_empty=False)). +The redesign MUST keep all of these reachable AND must make the menu path reach the config-time +ones. It must NOT claim the result-only engine picker is reachable from a config menu pre-result. + +## Implementation notes +- Icons: Qt has QStyle.StandardPixmap / theme icons; match existing toolbar icon style. + Menu QActions get icons via action.setIcon(...). +- Two-way sync (RESOLVED — controls are schema-bound, verified): precision/uncertainty/ + parallel are bound via FormFieldSpec with schema keys (options.precision_digits, + options.uncertainty_digits, parallel.mode/max_workers/reserve_cores/nested_policy; + panels.py:_bind_global_options_schema_fields). **LaTeX controls are ALSO schema-bound** + (Codex/Gemini CONFIRMED: config-LaTeX bindings at panels.py:1856/1947; results.latex.* at + :1396/1407; latex_engine_combo bound via latex.engine at :1982/1991) — treat them like the + other schema-bound controls, do NOT assume plain widgets. + DECISION — do NOT reparent or duplicate any widget (that is exactly what hid controls + last time). The menu items are NAVIGATION, not second copies: + - For every control: the menu action does `focus + ensureVisible/scrollTo` the SAME + in-rail widget (reveal its gate first if gated — e.g. check generate_latex_checkbox, + switch input mode — then focus). One source of truth: the in-rail widget. + - Checkboxes (dcolumn, caption, generate_latex, scientific, verbose, generate_plots) + MAY additionally be mirrored as a `checkable` QAction kept in two-way sync via + signals (action.toggled ↔ checkbox.toggled, guarded against recursion). This is the + only place a menu item carries state; it drives the SAME checkbox, never a copy. + This keeps a single widget per option, so nothing can be "hidden on the wrong page". +- Bilingual via _register_text; no shared/ui_specs change unless a control genuinely needs it. +- File-size ratchet: keep menu-building code in panels.py within baseline or extract to a + new <800-line module (e.g. app_desktop/menu_options.py) if it grows. + +## Gate per increment +TDD (RED reachability + behavior test first) → ruff/mypy → Codex + Gemini adversarial → +full desktop suite → CodeRabbit → user test → user-confirmed merge → graphify update. + +--- + +## AMENDMENT (2026-07-04, user-confirmed): in-menu editors, not navigation + +User tested the navigation-style menu and wants the OPTIONS to be ADJUSTABLE IN THE MENU +(a small inline editor / popup), not a shortcut that jumps to the config rail. + +**Mechanism (safe — no reparenting):** each 计算/LaTeX value item becomes a `QWidgetAction` +hosting a NEW mirror widget (a fresh QSpinBox/QComboBox/QCheckBox matching the real +control's range/items), two-way synced to the real in-rail control via signals with +recursion guards (blockSignals). The real control STAYS in the config rail — the menu +shows an editable copy. This preserves the single-parent invariant (the reachability +test still passes — no widget is reparented) AND gives real in-menu adjustment. + +- Compute controls (verified): mpmath_precision_spin (QSpinBox 10..1000000), uncertainty_digits_spin + (1..12), parallel_max_workers_spin (0..1024), parallel_reserve_cores_spin (0..1024) → + mirror QSpinBox; parallel_mode_combo (自动/串行优先/线程优先/进程优先), parallel_nested_policy_combo + (嵌套时串行/允许嵌套) → mirror QComboBox. +- LaTeX: generate_latex_checkbox/dcolumn_checkbox/caption_checkbox → mirror checkable (already + done as checkable QAction); output_file_edit (QLineEdit) → mirror QLineEdit or a "browse…" that + drives the real one; latex_input_precision_spin/latex_group_size_spin → mirror QSpinBox. +- Sync: mirror.valueChanged/currentIndexChanged/textChanged → real.set*, and real's signal → + mirror, both blockSignals-guarded to prevent loops. The real control is the source of truth. +- Gated LaTeX editors: setting them still reveals the gate (check generate_latex_checkbox) as today. +- The menu must not close on every keystroke — a QWidgetAction keeps the menu open while editing. + +The reachability test is UNAFFECTED (real controls not moved). Add tests: the mirror editor +in the menu changes the real control's value and vice versa (two-way), no recursion, menu stays +open while editing. diff --git a/docs/superpowers/specs/2026-07-04-toolbar-options-popup-design.md b/docs/superpowers/specs/2026-07-04-toolbar-options-popup-design.md new file mode 100644 index 00000000..ceacd2f8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-toolbar-options-popup-design.md @@ -0,0 +1,196 @@ +# DataLab Desktop — Toolbar Options Popups (QFrame) Design + +**Date:** 2026-07-04 **Status:** approved (user confirmed 2026-07-04) +**Supersedes:** the icon-ified *menu-bar* approach (`2026-07-04-iconified-menubar-design.md`) + +## Why this pivot + +The prior redesign put 计算/LaTeX options in `self.menuBar()`. On macOS `QMenuBar` is +pulled into the **global system menu bar** (top of screen), so the options were +invisible in the window — and the left "选项" panel (`options_box`) stayed in place, so +no space was freed and the result area never grew. The user caught this in a screenshot: +"并没有按照我的要求实现GUI". + +**Dual-model adversarial (Codex + Gemini, serial) returned `VERDICT: FRAME`**, with a +live Cocoa probe by Codex: +- A `QComboBox` embedded in a `QMenu` (via `QWidgetAction`) is **fragile**: the menu's + auto-close grab fights the combo's own popup; in Codex's automation the menu behaved + inconsistently. +- A **`QFrame(Qt.Popup | Qt.FramelessWindowHint)`** hosting the same combo **stayed open** + when the combo dropdown opened — Qt's popup stack handles the nested popup correctly. + +So the fix is not "move the menu to the toolbar" — it is **replace the QMenu host and move +the REAL controls into a toolbar-triggered container**. + +## ⚠ AMENDMENT (2026-07-04, dual-model VERDICT: INLINE) — supersedes "QFrame popup" below + +A second adversarial round (Codex + Gemini, both with live offscreen probes) reversed the +earlier "QFrame(Qt.Popup)" choice once two recon facts were on the table: +- **Cocoa-grab bug is real & untestable.** A `QComboBox` inside **any** `Qt.Popup` + top-level (QFrame(Qt.Popup) included) can be dismissed by the native macOS grab when its + own dropdown (also a Qt.Popup) opens. Offscreen QPA no-ops the grab, so this **always + passes CI and only fails in production on Mac** — an unacceptable unautomatable failure + mode for the app's primary test platform. +- **Reachability friction.** A `Qt.Popup` is a separate top-level window; the reachability + test's `isVisibleTo(window)` + stable-parent invariants don't map cleanly onto it. + +**Decision: INLINE.** Each toolbar button toggles a **normal `QWidget` child panel** (NOT +`Qt.Popup`), laid out just under the toolbar, shown/hidden via `setVisible`. Because it is +an ordinary layout child: no Cocoa grab (combos open safely), `isVisibleTo(window)` is +meaningful, parent is stable from build time, and the reachability sweep needs only a +trivial "click button → panel visible" gate. Codex's probe confirmed an inline child frame +is `isWindow()==False`, `window() is main_window`, `isVisibleTo(window)==True` when shown. +Trade-off accepted: while open the panel occupies vertical space under the toolbar (it is a +drop-down *panel*, not a floating overlay); when closed the rail is gone and the result +area is maximized. Read every "QFrame(Qt.Popup)"/"popup" below as **inline toggle panel**. + +## Goal + +Move low-frequency options out of the left config rail into two **in-window toolbar +dropdown buttons**, shrinking the rail so the result area maximizes — the user's original +request. No option may become hidden or unusable (the single-parent invariant that the +abandoned sidebar violated). + +## Confirmed decisions (user, 2026-07-04) + +1. **Two buttons: 计算 + LaTeX** (not one combined "选项" button). +2. **计算 popup** holds: 精度位数, 不确定度位数 · *sep* · 资源策略, 最大 workers, + 保留核心, 嵌套策略 · *sep* · 生成图片, 显示详细日志. +3. **LaTeX popup** holds: 生成 LaTeX 文件 (gate), 输出路径, 输入列位数, dcolumn, 分组位数, + 使用标题 (+ caption edit). `latex_engine_combo` stays in the LaTeX **result** tab + (compile-time, result-only) — NOT in this popup. +4. **Freed space → result area grows.** + +## Architecture (units, each independently testable) + +### 1. `app_desktop/workbench_options_panel.py` (NEW, <200 lines) — INLINE, not popup +A reusable **inline toggle panel** host, no DataLab-specific knowledge: +- `build_options_panel(owner, object_name, text_zh, text_en, icon, tooltip_*) -> + (QToolButton, QWidget)`: + - A `QToolButton` on the toolbar (matching `make_toolbar_button` style: + `ToolButtonTextUnderIcon`, 20×20 icon, `autoRaise`, bilingual via `_register_text`), + made `checkable` so its checked state mirrors panel visibility. + - A **normal `QWidget` child** (NOT `Qt.Popup`), object name `_panel`, + holding a `QVBoxLayout` the caller fills. It lives in the window's layout **directly + under the toolbar**: a dedicated `options_panels_row` (a `QWidget` with an `QHBoxLayout` + or `QVBoxLayout` holding the two panels) inserted into the shell VBox `root_layout` + (`panels.py:342`) at **index 1** — i.e. `root_layout.insertWidget(1, options_panels_row)`, + between the toolbar (`workbench_bar`, added at :347) and the 3-pane splitter + (`_main_splitter`, added at :349). `setVisible(False)` initially; the row itself may be + zero-height when both panels are hidden. + - Toggle: button `toggled(checked)` → `panel.setVisible(checked)`. Because the panel is + a layout child, showing it drops the row down and (when closed) reclaims the space — + no floating window, no `Qt.Popup`, so **no macOS combo-grab bug**. + - Only ONE panel open at a time is NOT required (both may be open); but toggling one does + not force-close the other unless we choose to (decide during impl — default: independent). + - Auto-close-on-outside-click is **not** provided (a Qt.Popup freebie we forgo); the + button is a toggle. Acceptable for low-freq options. (Optional later: an event filter + to collapse on click-outside — YAGNI for now.) +- `add_form_row(panel_layout, label_widget, field_widget)` / `add_separator(panel_layout)` + helpers so the caller lays controls out with the existing labels. +- **No control creation here** — it only hosts widgets handed in by `panels.py`. + +### 2. `app_desktop/panels.py` (MODIFIED — surgical) +- **Keep every control-creation line as-is** (creation order, ranges, signal wiring, + `_register_text`, `_bind_global_options_schema_fields`). This preserves schema binding + and parallel-prefs persistence exactly. +- Replace the `options_layout.addWidget(...)` / `addLayout(...)` chain and the final + `self.output_setup_section_layout.addWidget(options_box)` (line 1147) with: hand the + assembled control groups to the two toolbar popups' frame layouts. + - The LaTeX sub-controls are already grouped in `self.latex_options_widget` (a + self-contained `QWidget`) — move that whole widget into the LaTeX popup as one unit; + `generate_latex_checkbox` + the caption row go above it. +- `options_box` is **not added to the rail**. Two existing consumers must be handled + (audited — these are the only references besides the docs): + - `tests/test_desktop_global_options_ui.py:131` calls + `find_unbound_required_widgets(window.options_box)` — repoint at the new popup + container(s) (the compute + LaTeX popup frames) so the "all required widgets bound" + guarantee is preserved, not lost. + - `tests/test_desktop_shell_layout.py:34` lists `"options_box"` as an expected shell + widget — update the expected-widget list to the new toolbar buttons/popups. + - Decision: **keep `self.options_box` as the popup-content container** rather than + deleting the attribute — simplest way to preserve the two consumers and the schema + audit. It just moves from the rail into the 计算 popup frame (or the frame holds it). + Confirm during implementation whether a QGroupBox reads well inside a popup; if not, + reparent its children into the frame's layout and drop the box. +- `window.py:1279` (`self.latex_options_widget.setVisible(checked)` in + `_toggle_latex_options`) is **unaffected** — `latex_options_widget` stays intact, only + reparented into the LaTeX popup; the visibility toggle keeps working. +- The popups are built during toolbar construction (see unit 3); `panels.py` fills them + after the controls exist. Build order: controls created in `build_ui` as today → popups + filled at the same point `options_box` used to be added. + +### 3. `app_desktop/workbench_toolbar.py` (MODIFIED) +- After 停止 (line 192), before `addStretch`, add the two popup buttons via unit 1, + storing them on the owner (`owner.compute_options_button`, + `owner.compute_options_popup`, `owner.latex_options_button`, + `owner.latex_options_popup`). Icons: 计算 = `SP_ComputerIcon`, LaTeX = + `SP_FileDialogDetailedView` (match the prior menu icons). +- The toolbar builds the empty popups; `panels.py` fills their layouts once controls exist + (lazy/after-build, same pattern the old `wire_option_menus` used). + +### 4. DELETE the old QMenu/mirror approach +- `app_desktop/menu_options.py`, `app_desktop/menu_option_editors.py` +- `tests/test_desktop_option_menu_editors.py`, `tests/test_desktop_option_menus.py` +- Remove the `build_option_menus` / `wire_option_menus` calls in `panels.py` (≈:249/:381) + and the imports. +- **KEEP** `app_desktop/result_overview_popover.py` + `result_status_strip.py` and their + tests — unaffected, already fixed (left-click guard landed). + +### 5. `tests/test_desktop_option_reachability.py` (teach the sweep a panel-open gate) +- The sweep's `_record()` skips `not isVisibleTo(window)` (`:259`). With INLINE panels + hidden by default, add an **open-panel gate**: before the sweep (or as a gate the sweep + tries), toggle each options button checked so `panel.setVisible(True)`, exactly like the + existing combo/checkbox gates. Then every moved control is `isVisibleTo(window)` (INLINE + panel is a layout child → meaningful) with parent == its panel container (stable from + build; **no reparent-on-open**, satisfying the four parent-invariant asserts at + ~:435/:478/:506/:538). +- LaTeX gated controls (`latex_input_precision_spin` etc.): reachable after opening the + LaTeX panel AND ticking `generate_latex_checkbox` inside it. +- Assert `options_box` no longer sits in the left rail (not a descendant of + `output_setup_section` / the config rail). +- Repoint the two other consumers (see §2 note): `test_desktop_global_options_ui.py:131` + container arg, `test_desktop_shell_layout.py:34` widget-name entry. +- Keep the existing per-mode + result-only sweeps for controls that did NOT move. + +## The load-bearing risk test (write FIRST, RED) — INLINE makes it real offscreen + +Dual-model VERDICT: INLINE precisely because the combo-in-`Qt.Popup` dismissal is +untestable offscreen. With an INLINE (non-Popup) panel there is **no Cocoa grab**, so the +combo test is meaningful in CI. First failing tests: + +``` +test_options_panel_hidden_until_button_toggled: + panel is not visible initially; after button.setChecked(True) → panel.isVisible() True, + and every moved control isVisibleTo(window) True with parent == panel container. + +test_combo_in_inline_panel_opens_without_closing_panel: + open the 计算 panel; parallel_mode_combo.showPopup(); assert the panel is STILL visible + (panel.isVisible() True) and combo.parent() is unchanged (combo NOT reparented). Because + the panel is a normal layout child (not Qt.Popup), this assertion is meaningful offscreen + — it fails if code regresses to a Qt.Popup container. + +test_options_box_left_the_left_rail: + options_box is not a descendant of the config rail / output_setup_section. +``` + +These gate everything. A light **manual on-screen macOS check** is still listed (open each +panel, open a combo, confirm nothing collapses) — but it is now a confirmation, not the +sole guard, since INLINE removes the untestable failure mode. + +## Non-goals (YAGNI) +- No result-area fold/maximize toggle beyond the natural growth from a narrower rail. +- No change to the 5 job modes, mixin MRO, or web frontend. +- No hover-to-open; click-to-toggle only (simpler, matches a dropdown button). + +## Bilingual / conventions +- All popup button + label text via `_register_text(zh, en)`; combos already registered. +- Match `make_toolbar_button` visual style; popup frame themed via `theme.py` if needed + (add a `#_popup` selector only if the default frame looks wrong — decide during + implementation, not speculatively). + +## Gate (project CLAUDE.md, per round) +TDD (RED combo-in-popup + reachability first) → ruff → **Codex + Gemini serial adversarial** +→ full desktop suite (offscreen) → CodeRabbit → user test on real macOS window → +user-confirmed merge → `graphify update .`. `main` stays untouched; work in the +`feat/iconified-menubar` worktree/branch (rename optional). diff --git a/docs/superpowers/specs/2026-07-05-latex-ondemand-4.2-integration-notes.md b/docs/superpowers/specs/2026-07-05-latex-ondemand-4.2-integration-notes.md new file mode 100644 index 00000000..04af86b7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-latex-ondemand-4.2-integration-notes.md @@ -0,0 +1,85 @@ +# 4·2 Integration notes — on-demand LaTeX result-snapshot extension (per mode) + +Grounded drafts from the latex-snapshot-drafts swarm (2026-07-05). root + fitting drafts +came through complete; extrapolation/error/statistics need completing from the earlier +recon (scratchpad/latex_recon_full.json) + Codex corrections in the spec. + +## Foundation (shared, do first) + +- Add `self._last_latex_inputs: dict[str, dict] = {}` — a SEPARATE store, NEVER splatted + by `_refresh_display_format` (which iterates `_last_result_payloads`, window.py:2911-2914). +- Init next to `_last_result_payloads = {}` (window.py:2900) + reset at every reset site: + window.py:924, :2788; workspace_controller.py:1789, :2054; history_panel.py:317. +- Populate in each mode's finish handler right after `_remember_last_result(...)`. +- On-demand builder reads RESULT-DATA from the store + FORMAT-OPTS live from widgets + (caption/digits/group_size/dcolumn/language) → calls the SAME tex builder the run used + → byte-parity. Serialize into the semantic snapshot for cross-restore durability. + +## statistics — DRAFT FAILED (stub); complete from recon + spec + +## fitting (single-fit path) (risk=medium) + +### snapshot_change +TEX BUILDER SIGNATURE (exact inputs it needs). Run-time single-fit tex = `_write_fitting_latex` (window_fitting_residuals_mixin.py:132-165) → `_fit_latex_preamble` + `_fit_latex_block`. `build_fit_latex_preamble(*, use_dcolumn, digits, latex_group_size)` (fitting_latex_writer.py:41). `build_fit_latex_block(*, headers, rows, sigma_rows, fit_result, expression, substituted, image_path, use_dcolumn, digits, latex_group_size=3, batch_index=None, target_column="", variable_pairs=None, caption_text=None, default_uncertainty_digits=None, cleaned_substituted=None, units=None)` (fitting_latex_writer.py:89-108). + +LIVE-WIDGET READS the rebuild must reproduce FROM THE RUN (not edited widgets): +- `digits` ← latex_input_precision_spin.value() (residuals_mixin:145) — ALREADY on job as job.latex_digits (models_mixin:462; FitJob workers_core.py:1510). OK. +- `group_size` ← latex_group_size_spin.value() (residuals_mixin:146) — MISSING from FitJob. GAP. +- `use_dcolumn` ← job.use_dcolumn (residuals_mixin:534; FitJob:1506). OK. +- `default_unc_digits` ← self._uncertainty_digits_value() (formatters_mixin:528; def window.py:3074) — MISSING from FitJob. GAP. +- `target_column` ← self.fit_target_edit.text().strip() (formatters_mixin:529) — RE-READ from widget though job.target_column exists (FitJob:1491). GAP (must use job value). +- `variable_pairs` ← self._ordered_variable_pairs(headers) (formatters_mixin:531; def window_data_mixin.py:584-600, reads live variable_rows widgets) — ORDERING re-derived from widgets; job.variable_map (FitJob:1488, dict preserves order) exists. GAP (must derive pairs from job.variable_map, not widgets). +- `caption_base` ← self._caption_value() (formatters_mixin:534) — job.caption exists (FitJob:1507, set models_mixin:459). OK but currently re-read; prefer job.caption. +- expression / substituted / units ← FitResultPayload (residuals_mixin:487-488, persisted in display payload residuals_mixin:540). OK. + +CONFIRMED KNOWN GAP + 1 ADDITIONAL: The four stated (latex_group_size, uncertainty_digits, variable_pairs ordering, target_column) are all confirmed above. ADDITIONAL finding: there is NO single-fit semantic snapshot builder at all — datalab_core/fitting_comparison.py only has build_fitting_comparison_result_snapshot (line 260); _capture_semantic_result_snapshot (workspace_controller.py:1481-1540) has no single-fit branch. On workspace restore, _last_result_payloads is cleared to {} (workspace_controller.py:1789) so the in-memory job dies; only the serialized `semantic` snapshot survives (line 1738-1739). => cross-restore tex-rebuild needs a NEW serialized snapshot family, not just extra FitJob fields. + +SNAPSHOT_CHANGE (two-part): +(A) FitJob dataclass (workers_core.py:1479-1519): add two fields to mirror FittingComparisonJob (1570-1572): `latex_group_size: int = 3` and `uncertainty_digits: int = 1`. Populate in _prepare_fit_job's FitJob(...) return (models_mixin:432-476, alongside latex_digits at line 462) with `latex_group_size=self.latex_group_size_spin.value() if hasattr(self,'latex_group_size_spin') else 3` and `uncertainty_digits=self._uncertainty_digits_value()`. This lets in-session rebuild read everything off job. +(B) For cross-restore durability, add a NEW `build_fitting_single_result_snapshot(kind, payload, ...)` in datalab_core/fitting_comparison.py (new schema `datalab.result_snapshot.fitting_single` v1) that serializes the tex-rebuild inputs: headers, target_column (from job.target_column), variable_pairs (ordered list from job.variable_map.items()), latex_group_size, uncertainty_digits (default_uncertainty_digits), latex_digits, use_dcolumn, caption, plus a serialized FitResult + expression/substituted/units and the numeric rows/sigma_rows. Wire it into _capture_semantic_result_snapshot (workspace_controller.py:1481) as a new branch after the comparison branch (after line 1509), and add "fitting_single" to the family allow-set at 1530-1535 and _SEMANTIC_SNAPSHOT_KIND_BY_FAMILY. + +### separate_store +CRASH SURFACE: single-fit display payload = {"fit_result","expression","substituted","job","units"} (residuals_mixin:540), splatted as self._format_fit_display(**payload) (window.py:2969). NOTE: _format_fit_display (formatters_mixin:353) DOES accept **_ignored, so THIS one signature would tolerate extras — but per the Codex-verified cross-mode constraint (other formatters reject extras), do NOT add tex-rebuild keys to the remembered display payload dict. Keep that dict exactly as-is. + +DO instead: hold tex-rebuild inputs in a SEPARATE in-memory store `self._last_latex_inputs: dict[str, dict]` keyed by result kind. Set it in _on_fit_finished (residuals_mixin:538-541), immediately after _remember_last_result("fit_single", {...}): self._last_latex_inputs["fit_single"] = {"headers": job.headers, "rows": job.data_rows, "sigma_rows": job.sigma_rows, "target_column": job.target_column, "variable_pairs": list(job.variable_map.items()), "latex_group_size": job.latex_group_size, "uncertainty_digits": job.uncertainty_digits, "latex_digits": job.latex_digits, "use_dcolumn": job.use_dcolumn, "caption": job.caption, "units": units, "fit_result": fit_result, "expression": expression, "substituted": substituted}. The 生成TeX handler reads self._last_latex_inputs["fit_single"] and calls build_fit_latex_preamble/build_fit_latex_block with those exact values — NEVER touching fit_target_edit / variable_rows / spins. Because _last_latex_inputs lives outside _last_result_payloads it is never splatted (_refresh_display_format only iterates _last_result_payloads, window.py:2911-2914) so no TypeError. Clear it wherever _last_result_payloads is cleared/reset (window.py:924,2788,2900-2901; workspace_controller.py:1789,2054) to avoid stale reuse. For cross-restore, the serialized snapshot from (B) is the durable source — the on-demand builder should prefer _last_latex_inputs[kind] if present, else reconstruct inputs from window._last_result_semantic_snapshot (family "fitting_single"). + +### golden_test +Goal: prove on-demand rebuild == run-time tex, byte-for-byte, and that it is immune to post-run widget edits. + +Test 1 (in-session, exercises target_column + variable_pairs + group_size + uncertainty_digits gaps): +1. Build ExtrapolationWindow offscreen (QT_QPA_PLATFORM=offscreen). Load a small 2-variable dataset headers=["A","x1","x2","B"], ~5 rows with sigma on B, so _ordered_variable_pairs yields [("x1","x1_col"),("x2","x2_col")] in a specific order and target_column="B". +2. Set latex_group_size_spin=4, uncertainty_digits_spin=2, latex_input_precision_spin=6, fit_target_edit="B"; run a custom-model single fit to completion; capture RUN tex T0 by intercepting _write_fitting_latex output (write to a temp .tex and read the string) — OR call _write_fitting_latex to a StringIO/temp path. +3. MUTATE the widgets to wrong values AFTER the run: fit_target_edit="A", latex_group_size_spin=3, uncertainty_digits_spin=5, clear/reorder variable_rows. +4. Invoke the on-demand builder (build preamble+block from self._last_latex_inputs["fit_single"]) → tex T1. +5. assert T1 == T0 exactly. This FAILS today because the current _fit_latex_block re-reads the mutated widgets (formatters_mixin:529,531,528) and residuals_mixin:146 re-reads the group-size spin — proving the gap; PASSES after the fix reads job/_last_latex_inputs. + +Test 2 (cross-restore, exercises snapshot durability): run the same fit, save workspace (.datalab), new window, restore; assert _last_latex_inputs is repopulated from the serialized fitting_single semantic snapshot and rebuilt tex == T0. + +Fixture note: use a MULTI-VARIABLE (2 vars) fit so variable_pairs ordering is load-bearing; single-var would not exercise the ordering gap. (Multi-BLOCK table_segments is the batch path _write_fitting_latex_batches, residuals_mixin:180 — out of scope for single-fit; single-fit fixture = one segment, 2 variables.) + +### integration_notes +MAIN THREAD must wire: +1. FitJob: add latex_group_size:int=3 + uncertainty_digits:int=1 (workers_core.py:1519 area); populate in _prepare_fit_job FitJob(...) (window_fitting_models_mixin.py:432-476, next to latex_digits line 462) from latex_group_size_spin.value() and self._uncertainty_digits_value(). (Comparison job already does this: models_mixin:591-593.) +2. Add self._last_latex_inputs store: init to {} wherever _last_result_payloads is initialized (window.py:924; and defensively in _remember_last_result path); populate in _on_fit_finished right after _remember_last_result (window_fitting_residuals_mixin.py:538-541); clear in every _last_result_payloads reset site (window.py:2788,2900; workspace_controller.py:1789,2054). +3. The 生成TeX / on-demand handler: build tex from self._last_latex_inputs["fit_single"] via build_fit_latex_preamble + build_fit_latex_block (fitting_latex_writer.py:41,89) — pass target_column/variable_pairs/latex_group_size/uncertainty_digits(as default_uncertainty_digits)/latex_digits/use_dcolumn/caption FROM THE STORE, NOT from fit_target_edit/_ordered_variable_pairs/spins. Reuse the exact preamble+block+"\\end{document}" assembly of _write_fitting_latex (residuals_mixin:149-165) so byte-parity holds. +4. For durability: new build_fitting_single_result_snapshot in datalab_core/fitting_comparison.py; dispatch branch in _capture_semantic_result_snapshot after the comparison branch (workspace_controller.py:1509); add "fitting_single" to family allow-set (1530-1535) + _SEMANTIC_SNAPSHOT_KIND_BY_FAMILY + _semantic_snapshot_matches_kind. On restore, rehydrate _last_latex_inputs["fit_single"] from window._last_result_semantic_snapshot when family=="fitting_single" (restore path ~workspace_controller.py:1786-1789). +5. HARD CONSTRAINT: do NOT add any of these keys to the {"fit_result",...,"job","units"} dict passed to _remember_last_result("fit_single",...) (residuals_mixin:540) — it is splatted at window.py:2969. Keep tex-rebuild data only in _last_latex_inputs and the serialized semantic snapshot. + +## root_solving (risk=low) + +### snapshot_change +TEX BUILDER SIGNATURE (Task 1) — the on-demand rebuild target is `app_desktop/root_latex_writer.py:11` `write_root_latex(*, output_path, rows, caption="", digits=16, uncertainty_digits=1, group_size=3, include_dcolumn=False, language="zh", root_units=None) -> Path`, which is a thin wrapper over `datalab_latex/latex_tables_root.py:17` `build_root_latex_document(*, rows, caption, digits, uncertainty_digits, group_size, include_dcolumn, language, root_units) -> str`. Of these 8 args, exactly TWO are result-data (must persist from the run): `rows` (list of raw root rows) and `root_units` (per-name unit map). The other six — `output_path, caption, digits, uncertainty_digits, group_size, include_dcolumn, language` — are OUTPUT/FORMATTING options that in the on-demand model are read LIVE from widgets at click time (latex_output_path_for_run / caption_edit / latex_input_precision_spin / uncertainty_digits_spin / latex_group_size_spin / dcolumn_checkbox / language toggle), NOT persisted. + +KNOWN GAP CONFIRMED = NONE MISSING (Task 2). The current run-time rebuild `_write_root_latex_if_requested` (app_desktop/window_extrapolation_mixin.py:684-710) already sources its two data inputs from the stashed payload: `raw_rows = payload.get("raw_rows")` (line 689) and `root_units = _root_units_for_rows(raw_rows, payload.get("units"))` (line 705, helper at :88). Both keys are in the worker payload — `raw_rows` at workers_core.py:1913 (serialized by `_serialize_root_batch_raw_rows`, workers_core.py:1970-2000, each row a flat str->str dict with input_row_index / input_* / failure / root_index / name / value / uncertainty / backend / mode / residual_norm) and `units` at workers_core.py:1936 — and the whole payload is stashed verbatim via `self._remember_last_result("root_solving", dict(payload))` (window_extrapolation_mixin.py:676; store set in window.py:2894-2901). No ADDITIONAL missing input found: the tex builder consumes only rows+root_units for data, and both survive in the stash. The ONLY behavioral change is that the on-demand builder must STOP gating on `payload.get("generate_latex")`/`payload.get("output_path")` (window_extrapolation_mixin.py:685-687 — these are run-time-only intent flags) and instead read the output_path + formatting options from LIVE widgets. + +SNAPSHOT_CHANGE (Task 3). No new fields are strictly REQUIRED on `build_root_result_snapshot` (datalab_core/root_solving.py:253) because raw_rows+units already persist in the separate payload stash. RECOMMENDED (belt-and-suspenders, keeps the snapshot self-describing for workspace round-trips): add a non-splatted `latex_inputs` sub-dict to the snapshot dict built at datalab_core/root_solving.py:299-334, inserted alongside `batch`/`display` (e.g. after line 309): `snapshot["latex_inputs"] = {"raw_rows": deepcopy(payload.get("raw_rows") or []), "units": units_config}` (units_config already computed at :298). This is inert to the display path (root display at window.py:3001-3010 uses `payload.get(...)`, never `**snapshot`), and lets a workspace-restored session rebuild tex with no live payload. If you prefer the minimal change, SKIP the snapshot edit entirely and rely solely on the payload stash (see separate_store) — the run-time data is already there. + +### separate_store +HARD CONSTRAINT restated: never add tex-rebuild keys to the DISPLAY-splatted payload for modes whose `_refresh_display_format` branch does `formatter(**payload)` — that is extrapolation (window.py:2917), statistics_single/batches (2937/2944), fitting (~3000). Extra keys → TypeError, crashing the display. NOTE root_solving itself is SAFE from the splat (its branch, window.py:3001-3010, uses `payload.get("markdown"/"csv_rows"/"csv_headers")`, no splat), and the existing root payload already carries raw_rows/units/latex_* without crashing — so for THIS mode the constraint is already satisfied by the current stash. To keep the design uniform across modes and avoid ever tempting a splat regression, hold the tex-rebuild inputs in a DEDICATED store keyed by mode, set inside `_remember_last_result` (window.py:2894), NOT inside the `_last_result_payloads` dict that `_refresh_display_format` reads. Concretely: (1) init `self._last_latex_inputs: dict[str, dict] = {}` next to `self._last_result_payloads = {}` at window.py:2900 and at the reset sites window.py:924 / :2788 / workspace_controller.py:1789 / :2054 / history_panel.py:317; (2) in `_remember_last_result`, when kind=="root_solving", populate `self._last_latex_inputs["root_solving"] = {"raw_rows": payload.get("raw_rows"), "units": payload.get("units")}`. The on-demand builder reads from `self._last_latex_inputs["root_solving"]` (data) + live widgets (formatting), never touching the display payload. This store is never splatted anywhere, so it cannot trigger the TypeError. (Alternative already-working path: since root's display branch does not splat, the on-demand builder MAY read raw_rows/units directly from `self._last_result_payloads["root_solving"]` — but the dedicated store is the safer template for the other modes and is what the main thread should standardize on.) + +### golden_test +GOLDEN TEST (tex equality run-time vs on-demand). Fixture (exercises group_size AND multi-block/multi-root, the inputs most sensitive to raw_rows completeness): a 2-equation root problem run in batch/scan mode producing >=2 source rows, each yielding multiple named roots, so `_serialize_root_batch_raw_rows` emits several blocks (multi-block input) — e.g. equations `("x**2 - a", "y - x")` scanned over `a in {2, 3}`, so each block has roots x=+/-sqrt(a) and y — guaranteeing >1 root per block and >1 block, plus set group_size=3 and include_dcolumn=True and a non-empty caption to cover every formatting arg. Test body (headless Qt, `QT_QPA_PLATFORM=offscreen`): (A) build the window, drive a real root run with generate_latex=True to a temp output_path, and capture the RUN-TIME tex — read the file written by `_write_root_latex_if_requested` (window_extrapolation_mixin.py:696) OR capture the string returned by `build_root_latex_document`. (B) WITHOUT recomputing, call the NEW on-demand builder (which reads raw_rows/units from `self._last_latex_inputs["root_solving"]` and formatting from the same widget values used in the run) to produce tex2. (C) `assert tex1 == tex2` byte-for-byte. Because `build_root_latex_document` is a pure function of (rows, root_units, caption, digits, uncertainty_digits, group_size, include_dcolumn, language) and all data inputs are the persisted raw_rows/units while formatting inputs are unchanged widget values, the output must be byte-identical. Add a SECOND assertion that flips a live widget between run and rebuild (e.g. group_size 3->4 or toggle dcolumn) and asserts tex2 != tex1 AND tex2 == build_root_latex_document(same rows/units, new group_size) — proving the rebuild honors LIVE options rather than stale run-time ones. Place under tests/ mirroring existing root-latex tests; keep digits small (e.g. 16) for stable mp.nstr output. + +### integration_notes +MAIN THREAD wiring: (1) In `_capture_semantic_result_snapshot` (workspace_controller.py:1481) the dispatch already calls `build_root_result_snapshot` (line 1510) — if you adopt the optional `latex_inputs` snapshot field, no dispatch change is needed; the builder change is entirely inside datalab_core/root_solving.py:299-334. (2) Add the dedicated `self._last_latex_inputs` store: init at window.py:2900 (and reset at window.py:924/:2788, workspace_controller.py:1789/:2054, history_panel.py:317 wherever `_last_result_payloads` is reset), populate in `_remember_last_result` (window.py:2894) for kind=="root_solving" from payload raw_rows+units. (3) Refactor `_write_root_latex_if_requested` (window_extrapolation_mixin.py:684) into a STASH-READER on-demand builder: instead of gating on `payload.get("generate_latex")`/`payload.get("output_path")` (lines 685-687), a new `_generate_root_latex_on_demand()` reads raw_rows/units from `self._last_latex_inputs["root_solving"]` and reads output_path + caption + digits + uncertainty_digits + group_size + include_dcolumn + language from the SAME live widgets the run path uses (latex_output_path_for_run / _caption_value / latex_input_precision_spin / uncertainty_digits_spin / latex_group_size_spin / dcolumn_checkbox / language), then calls `write_root_latex(...)` (root_latex_writer.py:11) and `_load_latex_into_editor(tex_path)` (as at line 708). Keep the existing run-time auto-write call at window_extrapolation_mixin.py:675 for backward compat, OR remove it in favor of the on-demand-only flow per the broader plan. (4) Wire the "生成 TeX" button/menu action to `_generate_root_latex_on_demand()` and enable it only when `self._last_latex_inputs.get("root_solving")` is present. (5) The units->rows bridging helper `_root_units_for_rows` (window_extrapolation_mixin.py:88) is reused unchanged. No datalab_core service recompute is invoked — pure tex regeneration from stash + widgets. + diff --git a/docs/superpowers/specs/2026-07-05-latex-ondemand-4.4b-result-tabs-removal.md b/docs/superpowers/specs/2026-07-05-latex-ondemand-4.4b-result-tabs-removal.md new file mode 100644 index 00000000..c070f2d1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-latex-ondemand-4.4b-result-tabs-removal.md @@ -0,0 +1,114 @@ +# 4·4b — Remove TeX/PDF tabs from `result_tabs` (keep widgets off-screen) + +**Date:** 2026-07-05 +**Branch:** `feat/toolbar-options-popup` (worktree `DataLab-menubar`; `main` untouched) +**Prereq:** on-demand LaTeX feature complete (4·2/4·3), 4·4a landed (`4f4c7c6`). + +## Problem + +The result details area's `result_tabs` still shows two tabs — **TeX** (`result.latex`) +and **PDF** (`result.pdf`) — that are now visually redundant: the on-demand LaTeX +**preview dialog** (`latex_preview_dialog.py`) is the real viewer, opened by the +result-panel 生成 TeX / 预览 PDF buttons. The two inline tabs duplicate it. + +User decision (2026-07-05): **移除页签,组件转后台** — remove the two visible tabs, but +KEEP the underlying widgets alive (they are load-bearing, see below). + +## Why the widgets must stay (cannot just delete the tabs) + +`latex_widget` / `pdf_widget` build widgets that other code paths read: + +- `latex_edit` (`NumberedTextEdit`, schema `results.latex.source`) — written by the + on-demand builders via `_load_latex_into_editor`; read by the preview dialog + (`window.latex_edit.toPlainText()`), the workspace controller (persist/restore of + `latex_source`), i18n placeholder refresh, and the compile mixin. +- `latex_engine_combo` (schema `latex.engine`), `latex_engine_path_button`, + `latex_compile_button`, `latex_view_pdf_button`, `latex_open/save/reload_button`, + `latex_status_label` — read by `window_latex_compile_mixin`. +- `pdf_scroll`, `pdf_container(_layout)`, `pdf_zoom_spin` (schema `pdf.zoom_percent`), + `pdf_zoom_in/out/reset_button`, `pdf_status_label` — read by `window_pdf_preview_mixin`. + +Deleting them would break the preview dialog, workspace round-trip, and compile paths. + +## Reachability contract (the load-bearing constraint) + +`tests/test_desktop_option_reachability.py` requires every schema-**input** widget be +reachable through a real user gate. In the removed tabs the input-typed widgets are +exactly three (everything else is QLabel/QPushButton/QScrollArea — documented +non-inputs, already exempt via `_NON_INPUT_SCHEMA_TYPES`): + +| widget | schema key | type | +|---|---|---| +| `latex_edit` | `results.latex.source` | QPlainTextEdit | +| `latex_engine_combo` | `latex.engine` | QComboBox | +| `pdf_zoom_spin` | `pdf.zoom_percent` | QDoubleSpinBox | + +Today `_reveal_result_only_control` reveals these by switching `result_tabs` to +`indices["latex"]` / `indices["pdf"]`. After removal those indices no longer exist, so +the reveal helper must switch to making the **off-screen holder** visible. + +The `latex`/`pdf` prefixes stay in `_RESULT_ONLY_PREFIXES` (still result-only state), +and the three keys stay enumerated inputs — we change only HOW they are revealed, not +whether they are required reachable. This keeps the anti-masking guards intact. + +## Approach + +1. **`panels.py` — off-screen holder.** Build `latex_widget` and `pdf_widget` exactly + as now (all widgets, schema keys, bindings, signals unchanged). Instead of + `self.result_tabs.addTab(latex_widget, …)` / `addTab(pdf_widget, …)`, add both to a + new hidden holder: + ```python + self._offscreen_result_views = QWidget() + self._offscreen_result_views.setObjectName("offscreen_result_views") + _holder = QVBoxLayout(self._offscreen_result_views) + _holder.addWidget(latex_widget) + _holder.addWidget(pdf_widget) + self._offscreen_result_views.setVisible(False) + # parented to the details panel so it is a child of the window (findChildren sees it) + # but never shown as a tab. + ``` + Remove the two `addTab` + `setTabToolTip` calls and the now-unused `latex_index` / + `pdf_index` locals. Keep `_bind_result_latex_pdf_schema_fields(...)` — the widgets + still exist, the bindings are unchanged. + +2. **`_RESULT_VIEW_ORDER`** → drop `"result.latex"`, `"result.pdf"` (leaves + numeric/image/log). This automatically shrinks `result_view_specs`, + `datalab_schema_tabs`, and `result_tabs_indices` (built by enumerating the order). + +3. **Reveal-helper update (test).** In `_reveal_result_only_control`, replace the + `indices["latex"]` / `indices["pdf"]` branches with: + ```python + elif key in {"results.latex.source", "latex.engine", "pdf.zoom_percent"}: + window._offscreen_result_views.setVisible(True) + ``` + (a real, if internal, visibility gate — the widgets become `isVisibleTo(window)`). + Keep `pdf.zoom_percent` classified result-only. + +4. **i18n / titles.** `result_view_tab_title`/`tooltip` for latex/pdf are no longer + used for tabs; the `_register_text` calls on the inner widgets stay (labels still + need retranslation). Verify `result_tabs_indices` consumers (`window.py:657` + language-restore, `_reveal_result_only_control`) don't index `["latex"]`/`["pdf"]` + anywhere else. + +## Tests (TDD) + +- **RED first:** a new test asserting `result_tabs` has exactly the numeric/image/log + tabs (no TeX/PDF tab titles), AND `window.latex_edit` / `window.pdf_zoom_spin` still + exist and carry their schema keys, AND `_offscreen_result_views` hosts them. +- Update `test_desktop_option_reachability.py::_reveal_result_only_control` per step 3. +- Regression: full reachability suite, `test_desktop_gui_workflows.py` (root round-trip + reads `latex_edit`), `test_desktop_latex_preview_dialog.py`, workspace round-trip, + the on-demand golden tests, shell-layout. +- Any test asserting a latex/pdf **tab** in `result_tabs` gets updated to the new + reality (the display moved to the dialog). + +## Out of scope (later 4·4 items) + +- `generate_latex_checkbox` full removal (still a non-gate state-holder in the dialog). +- Bottom 「开始执行」 run_button deletion (re-point run state machine). +- Cross-restore `_last_latex_inputs` rehydration. + +## Gate + +Desktop suite green + ruff clean → dual-model (Codex + Gemini serial) → CodeRabbit → +user test → user-confirmed merge → `graphify update .`. diff --git a/docs/superpowers/specs/2026-07-05-latex-ondemand-4.4d-remove-generate-checkbox.md b/docs/superpowers/specs/2026-07-05-latex-ondemand-4.4d-remove-generate-checkbox.md new file mode 100644 index 00000000..7751a476 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-latex-ondemand-4.4d-remove-generate-checkbox.md @@ -0,0 +1,72 @@ +# 4·4d — Remove generate_latex_checkbox (LaTeX options become always-visible) + +**Date:** 2026-07-05 · **Branch:** `feat/toolbar-options-popup` · `main` untouched. +**Prereq:** 4·4a/b/c landed (4f4c7c6, 1afab05, df94b00). + +## Problem + +`generate_latex_checkbox` ("生成 LaTeX 文件") no longer gates anything: the run never +writes tex (on-demand generation replaced it, 4224fad). It survives only as (a) a +visibility toggle for `latex_options_widget` in the LaTeX 选项 dialog, (b) a schema-bound +input `output.latex.enabled`, and (c) a workspace-persisted flag `generate_latex`. User +decision: **彻底移除,选项改常驻** — delete the checkbox; the LaTeX options +(dcolumn / group_size / caption / input_digits) become always-visible in the dialog. + +## Key facts (recon) + +- The gating is **UI-only** (`_toggle_latex_options` → `latex_options_widget.setVisible`), + NOT a schema `visible_when` rule — so `output.latex.*` fields have no schema gate parent + to update. They were only visually hidden by the Qt toggle. +- The run trigger already passes `generate_latex=False` regardless of the checkbox, so the + checkbox is inert at run time. Tests that set it (+ empty output path) to trigger a + validation path are vestigial — the assertions don't depend on the checkbox. +- `output_file_edit` stays a detached compat widget (already the case); untouched. + +## Changes + +**panels.py** +- Delete `generate_latex_checkbox` creation + `_toggle_latex_options` connect + its + `options_layout.addWidget` + the `removeWidget` line + the `latex_content_layout.addWidget`. +- `latex_content` (LaTeX 选项 dialog content) now holds just `latex_options_widget` + (always visible). +- Delete the `generate_latex_field` FormFieldSpec (`output.latex.enabled`) + its entry in + the checkbox binding loop. + +**window.py** +- Delete `_toggle_latex_options` (method) + the init call at ~569. +- Drop `"generate_latex_checkbox"` from the dirty-tracking checkbox list (~829). + +**workspace_controller.py** +- Capture (~1115): drop the `"generate_latex"` key from the common config dict. +- Restore (~755): drop the `_set_checked_if(window, "generate_latex_checkbox", ...)` line. +- Back-compat: an old `.datalab` with `generate_latex` in common config is simply ignored + on restore (no crash — `_set_checked_if` gone; the extra key is dropped). + +**Reachability (test_desktop_option_reachability.py)** +- `_reveal_output_gates`: drop `window.generate_latex_checkbox.setChecked(True)` — the + LaTeX options are always visible now; keep the caption gate (`caption_checkbox`). +- The triply-gated caption_edit test (~668-678): drop the checkbox line, keep the panel-open + + caption_checkbox gates. +- `output.latex.enabled` disappears from the enumerated inputs (widget gone), so no + reachability entry is needed for it. + +**Other tests** +- test_desktop_global_options_ui.py:67 — drop the `output.latex.enabled` schema-key assert. +- test_desktop_options_dialogs.py:53 — drop `generate_latex_checkbox` from `_LATEX_CONTROLS`. +- test_desktop_workbench_results.py (1040/1055/1115) — drop the vestigial + `generate_latex_checkbox.setChecked(True)` + `output_file_edit.setText("")` setup lines + (the assertions about the result overview don't depend on them). +- test_desktop_example_workspace_menu.py:265 — drop the `setChecked(False)` line. + +## Tests (TDD) + +- RED: assert `not hasattr(window, "generate_latex_checkbox")`, `latex_options_widget` + is visible when the LaTeX dialog opens, and `output.latex.enabled` is absent from the + enumerated schema keys. +- Regression: reachability suite, global-options, options-dialogs, workbench-results, + example-workspace, workspace round-trip, on-demand golden tests — then full desktop suite. + +## Gate + +Full desktop + workspace suite green + ruff → this completes 4·4 → dual-model (Codex + +Gemini serial) → CodeRabbit → user test → user-confirmed merge → `graphify update .`. diff --git a/docs/superpowers/specs/2026-07-05-latex-ondemand-refactor-design.md b/docs/superpowers/specs/2026-07-05-latex-ondemand-refactor-design.md new file mode 100644 index 00000000..39e94f8b --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-latex-ondemand-refactor-design.md @@ -0,0 +1,205 @@ +# DataLab Desktop — On-Demand LaTeX Generation (result-panel model) + +**Date:** 2026-07-05 **Status:** draft (pending dual-model + user review) +**Supersedes Module 4 of:** `2026-07-05-latex-pdf-window-cleanup-design.md` +**Builds on landed modules:** 2 (tectonic-only), 3 (options→dialogs), 1a (temp-path), 1b +(LaTeX preview dialog). + +## Goal (user, 2026-07-05) + +Change LaTeX from "pre-check 生成 LaTeX + configure in a toolbar dialog, tex built during +the compute run" to an **on-demand, result-panel model**: + +1. **No 生成 LaTeX checkbox.** The user never pre-decides whether to generate tex. +2. **No LaTeX toolbar options button/dialog.** LaTeX options (dcolumn / 分组位数 / caption / + 输入列位数) move to a **separate "LaTeX 选项" entry in the RESULT area**. KEEP the 计算 + toolbar button (precision/parallel/plots/verbose stay there). +3. **On-demand:** click **生成 TeX** (result panel) → tex is built ON DEMAND from the current + result (NOT during the run) → the LaTeX preview window opens showing the source. Click + **预览 PDF** → auto-compiles via tectonic (no compile checkbox, no engine picker, no + visible compile step). + +The user chose this over the cheaper "run always writes tex to a temp file" alternative so +that changing a LaTeX option regenerates the tex WITHOUT re-running the compute. + +## Verified feasibility (5-mode parallel recon, 2026-07-05) + +The tex builders need compute-derived data (`headers`, `data_rows`, `results`, +`table_segments`, per-mode extras) + format params (caption/dcolumn/digits/group_size). +Recon result — feasibility per mode: + +| Mode | Feasibility | Gap to close | +|---|---|---| +| **root_solving** | **easy** | None — `_write_root_latex_if_requested` (`window_extrapolation_mixin.py:684`) ALREADY rebuilds tex post-run from a stashed payload. This is the PROVEN PATTERN to replicate. | +| **extrapolation** | **easy** | `_last_result_payloads['extrapolation']` (`:807`) drops `table_segments` (it's in the worker payload `workers_core.py:1013`). Add it to the remembered dict + thread through `_show_extrapolation_results`. | +| **error_propagation** | **moderate** | Window path (`_show_error_results`, `:998-1007`) builds a trimmed payload omitting `table_segments` + `constants` + `used_columns` that the worker's rich payload (`workers_core.py:1199-1217`) has. Retain them. | +| **statistics** | **moderate** | Plain sub-mode `_remember_last_result('statistics_single', …)` (`window_statistics_mixin.py:1653`) doesn't remember rows/units for the tex builder; grouped is nearly complete (units on the semantic snapshot). Retain rows+units for plain. | +| **fitting** | **moderate** | Single-fit `FitJob` lacks `latex_group_size`/`uncertainty_digits` fields (`workers_core.py:1479-1519`) — read live from widgets today; comparison path is complete. Snapshot or re-read at gen time; fix `variable_pairs` ordering. | + +**No mode needs a deep recompute.** Format params are re-readable from persistent widgets +(dcolumn_checkbox, latex_input_precision_spin, latex_group_size_spin, uncertainty_digits_spin, +caption field) at generation time — matching the root_solving precedent. The gaps are all +compute-derived fields the window path drops; each is a small "retain N more keys" fix. + +## ⚠ CRITICAL corrections from Codex design review (all confirmed against code) + +1. **Do NOT add LaTeX-only data to the remembered display payload — use a SEPARATE stash.** + `_refresh_display_format` splats the remembered payload into the display formatter: + `_format_extrapolation_display(**payload)` (`window.py:2918`) and + `_format_statistics_display(**payload)` (`:2938`). Those formatters accept ONLY + `{headers,data_rows,results,ref_col}` (`window_extrapolation_mixin.py:854`) / + `{result,value_col,n,units}` (`window_statistics_mixin.py:1569`). Adding `table_segments` + / `rows` to the splatted dict → `TypeError`, crashing the display refresh. **Store the + LaTeX-rebuild data in a separate `self._last_latex_inputs[mode]` dict**, never in the + display payload. +2. **`render_pdf()` async race — BUG in the already-committed Module-1b code.** + `compile_latex_to_pdf` runs a `_LatexCompileWorker` QThread; `last_pdf_path` is set only + in the completion callback (`_on_latex_compile_completed`), NOT synchronously. But + `latex_preview_dialog.render_pdf()` (`:126-139`) reads `last_pdf_path` IMMEDIATELY after + calling compile → reads a stale/None path. **Fix: render in the compile-completion + callback** (hook the dialog's PDF render to the worker's `completed` signal), not + synchronously. This must be fixed as part of this work. +3. **root_solving is a post-worker rebuild, not a stash-reader.** + `_write_root_latex_if_requested` (`window_extrapolation_mixin.py:684`) rebuilds from the + PASSED payload and depends on run-time `generate_latex`/`output_path`. The retained data + IS sufficient (the same payload is stashed at `:675`), but the on-demand builder must + READ from the stash, not depend on run-time args — adapt, don't copy verbatim. +4. **error_propagation `used_columns` is LOCAL-ONLY** (not in the worker rich payload — the + recon overstated this). It must be added to what's retained. +5. **Workspace restore clears `_last_*` stashes** (`workspace_controller.py:1784-1795, + 2037-2054`). A restored result snapshot cannot rebuild tex unless we persist the LaTeX + inputs (or the tex source) into the workspace. **Decision needed** (see Open Question). +6. **`generate_latex_checkbox` removal blast radius:** run gate + (`window_extrapolation_mixin.py:193-203, 234-243`), init/visibility (`window.py:568-570, + 1278-1281`), construction/dialog reparent (`panels.py:1065-1069, 1167-1194`), schema + `output.latex.enabled` (`panels.py:1927-1933, 2002-2011`), dirty tracking + (`window.py:822-845`), workspace capture/restore (`workspace_controller.py:745-766, + 1111-1129`), scanner (`scan_desktop_gui_schema.py:822`). + +## RESOLVED (user decision, 2026-07-05): persist a full result snapshot; rebuild tex from it +The user wants the tex to be **rebuildable from the input data + computed result** at any +time — live OR after restoring a `.datalab` — so that adjusting LaTeX options regenerates +WITHOUT recompute, and `.datalab` stores NO tex config (click 生成 → tex). + +**Foundation already exists (verified):** the workspace ALREADY captures a per-mode +`result_snapshot` — `_capture_semantic_result_snapshot` (`workspace_controller.py:1481`) +reads `_last_result_payloads` and builds per-mode snapshots via +`build_statistics_result_snapshot` / `build_root_result_snapshot` / +`build_fitting_comparison_result_snapshot` / `build_uncertainty_result_snapshot` +(in `datalab_core/`), persisted as `result_snapshot` in the `.datalab` and restored on load. + +**So the design becomes:** the on-demand tex builder reads from this **result snapshot** +(the single source that works both live and post-restore), NOT a transient `_last_*` stash. +Concretely: +1. **Extend the snapshot builders** to carry EVERY tex-rebuild input the recon/Codex found + missing (extrapolation: `table_segments`; error: `table_segments`+`constants`+ + `used_columns`; statistics-plain: `rows`+`sigma_rows`; fitting-single: + `latex_group_size`+`uncertainty_digits`+`variable_pairs` order + `target_column`). +2. **Add an extrapolation snapshot** — there is currently NO + `build_extrapolation_result_snapshot` (only statistics/fitting/root/uncertainty exist); + add one so extrapolation results also persist + rebuild. +3. **On-demand `generate_latex_for_current_result()`** reads the current-mode snapshot + + live LaTeX-option widgets (dcolumn/group_size/caption/input_digits — these are OPTIONS, + deliberately re-read live so changing them regenerates) → calls the per-mode tex builder. +4. This satisfies "restored results can 生成 TeX" for free (the snapshot is in the `.datalab`) + and "no tex config in the workspace" (only the semantic result snapshot is stored, which + already exists for other reasons — history/compare). + +This is a bigger change than the transient-stash version but matches the existing snapshot +architecture, so it reuses proven machinery rather than inventing a parallel store. + +## Architecture + +### A. Retain compute data per mode (`window_*_mixin.py` + `workers_core.py`) +**Store in a SEPARATE `self._last_latex_inputs[mode]` dict (NOT the display payload — see +correction 1).** Per mode, capture the tex-builder inputs at result-display time: +For each mode, ensure `self._last_result_payloads[mode]` (or the equivalent stash) retains +EVERY compute-derived input the tex builder needs (per the recon gaps above). Concretely: +- extrapolation: add `table_segments` to the remembered dict (`:807`) + thread through + `_show_extrapolation_results`. +- error: retain `table_segments`, `constants`, `used_columns` in the window path (`:998-1007`) + from the worker rich payload. +- statistics: retain rows + units in `statistics_single`. +- fitting: add `latex_group_size`/`uncertainty_digits` to the single-fit stash (or read + live at gen time); fix `variable_pairs` ordering source. +- root_solving: no change (already complete). + +### B. Per-mode on-demand tex builder (new `build_latex_for_current_result()`) +A dispatcher `generate_latex_for_current_result(self) -> str | None` that, based on the +current mode, reads the stashed compute data + live format-param widgets and calls the +SAME per-mode tex builder the worker used (`generate_latex_table`, +`generate_error_propagation_table`, `generate_statistics_latex`/`_grouped`, the fitting + +root writers), writing to a temp `.tex` and returning the source string. This mirrors +`_write_root_latex_if_requested` — no compute, pure rebuild. Returns None (with a friendly +message) if there is no current result. + +### C. Drop the compute-time tex gate (`workers_core.py`, run trigger) + +**Lead-verified consumers of the run-time tex write (what breaks if the run stops writing +tex):** +- `_load_latex_into_editor(latex_path)` (`window_extrapolation_mixin.py:580`, fitting + `:170/217/257`) — populates the result LaTeX editor after a run. In the new model the + on-demand 生成 TeX populates the editor instead, so this run-time load is simply removed + (or the on-demand builder feeds the editor). NOT a blocker. +- The CSV `"latex"` column (`result_csv_spec.py:23`, extrapolation) is a per-ROW latex + SNIPPET (via `format_uncertainty_display_latex`), NOT the full tex table — it is built in + the display/CSV path independent of the run-time full-tex write. So dropping the full-tex + write does NOT affect the CSV latex column. VERIFIED non-issue. +- `result.latex_path` becomes unused by the desktop run path; the on-demand builder writes + its own temp path. Confirm no other consumer reads `result.latex_path` post-drop. + +- Remove `generate_latex_checkbox` from the UI (Module 3 put it in the LaTeX options + dialog — that dialog + button are removed, unit E). +- The compute worker NO LONGER writes tex during the run: the `if job.generate_latex:` + blocks (`workers_core.py:985/1228/1414`, fitting/root writers) are bypassed for the + desktop on-demand path. Simplest: the run always passes `generate_latex=False` (tex is + built later on demand) — OR keep the worker capability but stop calling it from the + desktop run. Decide during impl to minimize churn; the KEY is the desktop no longer + needs the run to produce tex. (Web frontend unaffected — separate path.) + +### D. Result-panel buttons + LaTeX-options entry (`panels.py`) +- Add **生成 TeX** + **预览 PDF** buttons to the result rail. 生成 TeX → build tex on demand + (unit B) → `open_latex_preview_dialog(self, initial_tab='tex')`. 预览 PDF → build tex → + `open_latex_preview_dialog(self, initial_tab='pdf')` (auto-compiles). +- Add a **LaTeX 选项** entry in the result area (a small button opening a + `LatexOptionsDialog` — reuse the Module-3 `options_dialogs` machinery) holding dcolumn / + 分组位数 / caption / 输入列位数. Changing an option + clicking 生成/预览 regenerates. +- Remove the TeX + PDF tabs from `result_tabs` (`panels.py:1549/1600`), from + `_RESULT_VIEW_ORDER` (`:128`), re-index result tabs, update reachability + scanner + (the deletion blast radius from the prior spec's Module 4). + +### E. Remove the LaTeX toolbar button + generate-checkbox (`workbench_toolbar.py`, Module-3 dialogs) +- Remove `workbench_latex_options_button` + `latex_options_dialog` from the toolbar (the + LaTeX options now live in the result-side entry, unit D). KEEP + `workbench_compute_options_button` + `compute_options_dialog`. +- Remove `generate_latex_checkbox` (no longer a gate). Any code reading it + (`window_extrapolation_mixin.py:194/234`, `_toggle_latex_options`) updated: tex is always + buildable on demand, so the checkbox is gone. + +### F. Result-panel cleanup (from the prior Module 4 — still in scope) +- Delete `run_button`/`run_section` (bottom 开始执行) — re-point run shortcut/state/lang- + restore to `workbench_run_button` (the toolbar 运行). Blast radius: `window.py:657`, + `window_extrapolation_mixin.py:129-142`, `test_desktop_shell_layout.py:133/163`. +- Delete the empty `output_setup_section` (also from `_config_card_sections`, `panels.py:693`). +- Collapse the result-overview HISTORY section by default (a click-to-expand header). + +## Load-bearing risks (test FIRST) +1. **Post-run tex rebuild matches the old run-time tex** for each of the 5 modes (golden: + run with generate_latex on the OLD path, capture tex; on the NEW path, build on demand, + assert byte-identical or semantically-equal source). The retained-data gaps (table_segments, + constants, used_columns, rows/units, group_size) are exactly where a rebuild could DIVER GE + — each mode gets a test. +2. **生成 TeX with no result** → friendly message, no crash. +3. **Changing a LaTeX option then 生成 TeX** regenerates with the new option (no recompute). +4. **No 生成 LaTeX checkbox anywhere**; the compute run does not write tex. +5. **Deletions safe** (run_button state machine, result_tabs indices, toolbar LaTeX button). + +## Non-goals (YAGNI) +- No change to the compute math or the tex BUILDERS themselves (reused as-is). +- Web frontend untouched (own latex path). +- No new PDF features beyond the current render. + +## Gate (project CLAUDE.md) +spec → **Codex + Gemini serial adversarial** → TDD (golden per-mode rebuild tests first) → +ruff → full desktop suite → CodeRabbit → user test → user-confirmed merge → graphify update. +main untouched; branch `feat/toolbar-options-popup`. diff --git a/docs/superpowers/specs/2026-07-05-latex-pdf-window-cleanup-design.md b/docs/superpowers/specs/2026-07-05-latex-pdf-window-cleanup-design.md new file mode 100644 index 00000000..13bdfd1b --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-latex-pdf-window-cleanup-design.md @@ -0,0 +1,256 @@ +# DataLab Desktop — LaTeX/PDF Window + Toolbar/Result-Panel Cleanup + +**Date:** 2026-07-05 **Status:** draft (pending dual-model + user review) +**Builds on:** `2026-07-05-two-pane-layout-design.md` (2-pane layout + toolbar options landed) + +## Goal (user, 2026-07-05) + +Pull LaTeX/PDF out of the result tabs into a dedicated window, make PDF compile +tectonic-only (no local TeX), turn the toolbar option panels into real windows, and +remove dead/redundant UI in the result and merged panes. + +## Verified current state (live probe + code, 2026-07-05) + +- `result_tabs` = `[数值, 图像, 日志, TeX, PDF]` — TeX/PDF are tabs 3/4 (`panels.py:1433`, + latex tab at `:1564`). +- Options live in two INLINE toolbar panels (`workbench_options_panel.py`), populated in + `panels.py:1167-1196`: compute panel = precision + parallel + generate_plots + verbose; + latex panel = `generate_latex_checkbox` + `latex_options_widget` (which wraps + `output_file_edit`, `dcolumn_checkbox`, `latex_group_size_spin`, `caption_checkbox`, + `latex_input_precision_spin`). +- PDF compile (`window_latex_compile_mixin.compile_latex_to_pdf` `:94`) picks + `latex_engine_combo` engine, tries tectonic-no-prompt, then FALLS BACK to local + pdflatex/xelatex (`:108-158`). tectonic is bundled + auto-downloadable + (`shared/latex_engine.py`: `ensure_tectonic_installed` `:331`, `tectonic_compile_argv` + `:512`, SHA256-verified 0.15.0). +- Run trigger reads options at run time: `generate_latex_checkbox.isChecked()` + + `output_file_edit.text()` in `window_extrapolation_mixin.py:194-210`, threaded as + `generate_latex=` / `output_path=` into every mode's run method. +- `output_setup_section`: 0 children, 20px — DEAD empty widget above 开始执行. +- `run_button` (开始执行, bottom) AND `workbench_run_button` (toolbar 运行) both exist. +- History overview buttons are WIRED (`history_panel.py:120-121`…), disabled until a row is + selected — NOT broken; the complaint is they take space. + +## Confirmed decisions (user, 2026-07-05) + +1. **New windows are QDialogs** (like `FormulaPreviewDialog`), resizable/non-modal — NOT + `Qt.Popup`. +2. **Two DIFFERENT widget strategies by window type (this resolves the apparent + contradiction Codex flagged):** + - **Options dialogs (计算 / LaTeX-options):** hold the REAL schema-keyed option widgets, + reparented ONCE at build time into the dialog (a stable single parent). Required + because the reachability test enumerates every schema-keyed input and forbids hidden + state-holders; and because the run pipeline reads `self.` directly. NO mirror, + NO fresh duplicates for these. + - **LaTeX-PREVIEW window (TeX/PDF):** uses FRESH display widgets (a new + `NumberedTextEdit` for TeX, a new scroll+label for PDF) that reuse the underlying + tex-source string and a PURE pdf-render helper. These are display widgets, not + schema-keyed inputs, so fresh-widget + reuse-logic is correct and avoids reparenting + result-display widgets out of the (removed) result tab. +3. LaTeX window = ONE dialog with TWO tabs (TeX source / PDF preview). +4. 计算 button also opens a real window; LaTeX-options button opens a real window. +5. Delete bottom 开始执行 + the empty `output_setup_section`. +6. History section collapses to a header by default, click to expand. + +## Architecture + +### Module 1 — `app_desktop/latex_preview_dialog.py` (NEW) — TeX/PDF window +A `QDialog` (resizable, non-modal, own lifecycle; pattern from `formula_preview.py`) with a +`QTabWidget` of two tabs: +- **TeX tab**: a NEW `NumberedTextEdit` + `LatexHighlighter` (same classes the current + `latex_edit` uses), read-only-ish, populated from the generated tex SOURCE (see reuse + below). Footer: **复制** (copy tex → clipboard) + **保存** (save tex → `QFileDialog` + getSaveFileName, `.tex`). +- **PDF tab**: a NEW `QScrollArea` + label with the dialog's OWN render state (zoom, dpi). + **Codex #1 (confirmed):** `_render_pdf_preview` (`window_pdf_preview_mixin.py:92`) is + coupled to main-window state — `self.pdf_zoom`/`self._pdf_base_dpi` (`:116`), + `self.pdf_container_layout`/`self.pdf_scroll` (`:186,:198,:220`), `self.last_pdf_path` + (`:222`), and result-tab auto-select (`:229`). Passing a target scroll is NOT enough. + **Refactor to a PURE helper:** `shared/pdf_preview_raster.py:234` ALREADY has + `convert_pdf_to_images(...)` (pdftoppm/gs rasterizer) — reuse it; the "pure helper" is + largely wiring, not net-new. The dialog owns its zoom/dpi and lays the images into its own + scroll. The main-window `_render_pdf_preview` (if still needed) also calls the same helper. + NO result-tab auto-select from the dialog path. + **Note on "tectonic-only":** it applies to the tex→PDF COMPILE step (tectonic). The + PDF→images RASTERIZE step for on-screen preview still uses `convert_pdf_to_images` + (pdftoppm/ghostscript) as today — that is a preview rasterizer, unrelated to the TeX + engine, and is out of scope for the tectonic-only change. +- Opened by two result-panel buttons (Module 4). Passing `initial_tab` selects TeX or PDF. +- **tex SOURCE (Codex #2, confirmed):** `results.latex.source` is only a schema KEY on + `latex_edit` (`panels.py:1494`) — NOT a `CalcResult` payload (`CalcResult` carries only + `latex_path`, `workers_core.py:710`). Modes are FILE-FIRST: they write tex to + `job.output_path` (extrapolation `workers_core.py:985`, error `:1228`, statistics `:1419`) + and root/fitting SKIP writing when `output_path` is empty (`window_extrapolation_mixin.py:714`, + `window_fitting_residuals_mixin.py:524`). Therefore the dialog's tex source = the string + currently in `latex_edit` (populated post-run by `_load_latex_into_editor(latex_path)`), + which REQUIRES the run to have written tex to SOME path → see the mandatory temp-path + resolution below. + +### Module 2 — tectonic-only compile (`window_latex_compile_mixin.py`) +- `compile_latex_to_pdf` always uses tectonic: `ensure_tectonic_installed()` → + `tectonic_compile_argv()`. Remove the `latex_engine_combo` engine selection and the + pdflatex/xelatex FALLBACK branch (`:108-158`, `:205`). +- Remove `latex_engine_combo` from the UI (it lives in the LaTeX result tab today, + `panels.py:1147` note). Any code referencing `self.latex_engine_combo` + (`compile_latex_to_pdf`, tests) updated to the fixed tectonic path. +- Error messages: tectonic download/run failures only; drop "install pdflatex/xelatex" + copy (`:276-277`). +- First-run auto-install (Codex #5, confirmed): `resolve_engine("tectonic")` + (`shared/latex_engine.py:289`) only finds an ALREADY-installed binary; the actual install + today is the PROMPT-based `_offer_tectonic_install()` (`window_latex_compile_mixin.py:447`). + For tectonic-only, the compile path must call `ensure_tectonic_installed` DIRECTLY (via + the existing `EnsureTectonicWorker`, `workers_qt.py:581`) with a progress notice — no + yes/no prompt, no local-TeX escape. Offline first-run failure surfaces a clear "could not + download the TeX engine; check your connection" error (acceptable per the tectonic-only + decision — there is intentionally no local-TeX fallback). +- The worker-level fallback to local engines (`workers_qt.py:665,:706`) is removed too. + +### Module 3 — options as dialogs (`app_desktop/options_dialogs.py` NEW) +- `ComputeOptionsDialog` (QDialog): precision digits, uncertainty digits, resource policy, + max workers, reserve cores, nested policy, generate_plots, verbose. New controls, + two-way synced to the SAME underlying option STATE the run trigger reads. +- `LatexOptionsDialog` (QDialog): generate_latex, dcolumn, group_size, caption, + input_precision — **NO 输出路径 field** (removed; path chosen at save-time in Module 1). +- The toolbar `workbench_compute_options_button` / `workbench_latex_options_button` now + OPEN these dialogs (not toggle inline panels). The inline-panel row + (`workbench_options_panel.py`) + its population (`panels.py:1155-1210`) is removed; + `workbench_options_panel.py` may be deleted if nothing else uses it. +- **State model (RESOLVED — the dialog widgets ARE the option controls; no hidden + state-holders, no mirror).** The reachability test (`test_desktop_option_reachability.py`) + enumerates EVERY schema-keyed input and asserts each is `isVisibleTo(window)` via a user + gate, with `_ALLOWLIST_UNREACHABLE` empty. So we CANNOT keep the real option widgets as + hidden state-holders (a hidden `generate_latex_checkbox` = an unreachable schema-keyed + input → test fails). Therefore: + - The dialog's controls (`mpmath_precision_spin`, `generate_latex_checkbox`, …) are the + ONE real instances — the SAME widget objects, reparented into the dialog once at build + (a dialog is a stable single parent; unlike the abandoned QStackedWidget page, a + QDialog does not "hide on the wrong page" — it is either open or closed, and its + children are `isVisibleTo(window)` when open). + - The run pipeline keeps reading `self.generate_latex_checkbox.isChecked()` etc. + unchanged — same objects, just housed in the dialog. + - Reachability gate: the sweep opens the dialog (like the current "open the panel" gate) + → the option widgets are `isVisibleTo(window)` with a stable dialog parent. Add the + dialog-open gate to the sweep's selector list. + - This is the SAME single-real-widget principle as the current inline panels (which + reparent the real controls, `panels.py:1172-1196`) — we swap the inline panel host for + a QDialog host. NO mirror widgets, NO hidden duplicates. + +### Module 4 — result-panel + merged-pane cleanup (`panels.py`) +- **Result panel buttons:** add **生成 TeX** + **预览 PDF** buttons at the top of the + result rail; each opens the Module-1 dialog on the right tab. Remove the TeX + PDF tabs + from `result_tabs` (`panels.py` latex/pdf addTab sites) → `result_tabs` = `[数值, 图像, + 日志]`. +- **Delete** `run_button`/`run_section` (bottom 开始执行) — toolbar 运行 is the single + trigger. Any `run_button.clicked` wiring re-pointed to the toolbar button (already + wired). Update `_config_card_sections` / tests that reference `run_section`. +- **Delete** `output_setup_section` (empty 20px widget) from the merged pane. +- **History:** wrap the history section in a collapsible header (default collapsed). Reuse + or add a small collapsible container; `build_history_panel` gains a collapsed-by-default + header toggling `entry_list` + buttons visibility. + +### output_path decoupling (cross-module, RESOLVED — refined after recon) +**Verified flow today:** the run WRITES tex to a file (`result.latex_path`, +`window_extrapolation_mixin.py:514`) then `_load_latex_into_editor(latex_path)` reads it +into `latex_edit` (`:607, :735`; fitting `residuals_mixin:170/217/257`). `compile_latex_to_pdf` +→ `_persist_latex_editor` (`:296`) which writes `latex_edit.toPlainText()` to +`current_latex_path`, and **pops a save dialog if `current_latex_path` is None** +(`:299-308`). So naively removing `output_file_edit` would make every PDF preview pop a +save dialog — wrong. + +**Resolution (three points):** +1. **Run always materializes tex to a TEMP path when no user path is set.** At the call + site the run's `output_path` becomes a per-run temp file (not "" — a temp `.tex` under a + tempdir), so `result.latex_path` exists and `_load_latex_into_editor` still populates + `latex_edit`. The tex SOURCE is thus always retained as a string in `latex_edit` + (`results.latex.source`). Confirm each of the 5 modes materializes tex when + `generate_latex` is on regardless of a user path (fitting/extrapolation/statistics/ + root-solving/error). +2. **PDF PREVIEW compiles from a TEMP file, never the save dialog.** Refactor the compile + path so preview writes `current tex source` to a temp `.tex`, tectonic-compiles to a + temp `.pdf`, renders — WITHOUT touching `current_latex_path` or prompting to save. The + save dialog is ONLY reachable via the 保存 button. +3. **保存 button** (TeX tab) = `QFileDialog.getSaveFileName` → write the current tex source + to the chosen path (the ONLY user-path write). **复制** = tex source → clipboard. No + `output_file_edit` anywhere. + +So no mode-run *signature* changes, but the `output_path` VALUE at the call sites +(`window_extrapolation_mixin.py:197,249`, and the other modes) changes from +`output_file_edit.text()` to a per-run temp path, and the compile/preview path is +refactored to use a temp file rather than `_persist_latex_editor`'s save-or-prompt. + +## Deletion blast radius (Codex #4 — complete, audited) + +Deleting these is bigger than the naive list; each site must be handled: +- **`run_button` / `run_section`:** drives shortcut + button-state in + `window_extrapolation_mixin.py:129,:137,:147`; language-state restoration reads it at + `window.py:657`; tests click/assert it at `test_desktop_shell_layout.py:133,:163`. + → Re-point the run shortcut + state logic to `workbench_run_button` (the toolbar 运行); + update the two tests to the toolbar button; keep the state-transition (run↔stop) working. +- **`latex_engine_combo`:** used by compile (`window_latex_compile_mixin.py:105,:361`), + workspace capture/restore (`workspace_controller.py:766,:1128`), and schema binding + (`panels.py:2067`). → Remove the combo; workspace capture/restore must tolerate its + absence, drop the schema-binding field. **Workspace migration (lead-verified, no schema + bump needed):** restore at `:766` already uses `getattr(window, "latex_engine_combo", + None)` (null-safe); capture at `:1128` stops writing `"engine"`. The workspace schema is + `datalab.workspace.v2` (`datalab_core/workspace_v2.py:10`); dropping an OPTIONAL `engine` + field is backward-compatible (old `.datalab` files with `"engine":"pdflatex"` load fine — + the engine value is simply ignored, tectonic is always used). So NO schema_version bump. + Add a test: an old workspace with `latex.engine=pdflatex` restores without error and + compiles via tectonic. +- **TeX/PDF result tabs:** in `_RESULT_VIEW_ORDER` (`panels.py:128`), result indices + (`:1624`), reachability (`test_desktop_option_reachability.py:373,:384`), and scanner + scenarios (`tools/scan_desktop_gui_schema.py:71`). → Remove from `_RESULT_VIEW_ORDER`, + re-index result tabs, update reachability (the latex_edit/pdf controls move to the dialog; + their schema keys move with them or are re-scoped to the dialog), update scanner scenarios. +- **`output_setup_section`:** empty widget — safe delete, but check `_config_card_sections` + (`panels.py:693`) which lists it; remove from that tuple. +- **`workbench_options_panel.py`:** delete if the dialogs replace both inline panels; + update `test_desktop_toolbar_options_panel.py` (asserts the inline panels) to the dialog + behavior. + +## MANDATORY temp-path resolution (Codex #2/#3 — hard requirement, not optional) + +Because all 5 modes are file-first and SKIP tex when `output_path` is empty, the run MUST +write tex to a per-run TEMP `.tex` when `generate_latex` is on and the user set no path. +Implement by making the call sites pass a temp path (a `tempfile`-managed `.tex`) as +`output_path` instead of `output_file_edit.text()` — for EVERY mode +(`window_extrapolation_mixin.py:197,249,...`, root `_write_root_latex_if_requested:714`, +fitting `residuals_mixin:524`). Then `result.latex_path` exists, `_load_latex_into_editor` +populates the (dialog's) tex source, and PDF preview compiles that temp file. The 保存 +button copies the current tex source to a user-chosen path. This is REQUIRED for the design +to function — "materialize only on Save" is explicitly rejected (it breaks tex generation). + +## Load-bearing risks (test FIRST) +1. **PDF renders via tectonic with NO local TeX.** Test: force local pdflatex/xelatex + absent (PATH scrub), compile → tectonic path produces a PDF (or the ensure-install + worker is invoked). No fallback to local engines. **Codex #5:** the existing + `test_desktop_latex_compile_ui.py:114` ASSERTS the old fallback — it must be rewritten to + assert the tectonic-only path (fallback removed). Update, don't just delete. +2. **Options dialog drives the run.** Test: change generate_latex in the dialog → the + run trigger sees it (the hidden real checkbox reflects it); a silent-set regression + fails (mirror downstream-signal assertion, like the menu-editor test). +3. **No control stranded / single-parent kept.** The options dialogs hold the REAL option + widgets (reparented once into the dialog, a stable single parent — like today's inline + panels). Reachability: options reachable by opening the dialog (add the dialog-open gate + to the sweep). NO hidden state-holders, NO mirror widgets. (The LaTeX-PREVIEW window is + different: its TeX/PDF views are NEW display widgets reusing the render/gen LOGIC, since + `latex_edit`/`pdf_scroll` are result-display widgets, not schema-keyed inputs.) +4. **result_tabs no longer has TeX/PDF; the 2 buttons open the dialog on the right tab.** +5. **Bottom run + empty area gone; toolbar 运行 still triggers a run for all 5 modes.** + +## Non-goals (YAGNI) +- No change to the compute math or the 5 modes' logic. +- **Web frontend is UNAFFECTED (verified):** `app_web` has its OWN compile path + (`app_web/latex_security.py:compile_latex_safe` + `app_web/security.py:validate_latex_engine`, + its own pdflatex/xelatex whitelist) and does NOT import `shared/latex_engine.py`'s desktop + fallback. So removing the DESKTOP fallback does not touch the web route. Keep + `shared/latex_engine.py`'s public API compatible regardless (it exports + `tectonic_compile_argv`/`ensure_tectonic_installed` which the desktop uses). +- No new PDF features (annotations, print) beyond the current render + zoom. +- History content/behavior unchanged beyond the collapse. + +## Gate (project CLAUDE.md) +spec → **Codex + Gemini serial adversarial** → TDD (RED tectonic-only + options-dialog +drives-run + result-buttons-open-dialog first) → ruff → full desktop suite → CodeRabbit → +user test on real macOS → user-confirmed merge → `graphify update .`. `main` untouched; +work in the `feat/toolbar-options-popup` branch (or a new `feat/latex-pdf-window`). diff --git a/docs/superpowers/specs/2026-07-05-two-pane-layout-design.md b/docs/superpowers/specs/2026-07-05-two-pane-layout-design.md new file mode 100644 index 00000000..7e57576e --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-two-pane-layout-design.md @@ -0,0 +1,222 @@ +# DataLab Desktop — Two-Pane Layout + Mode Selector on Toolbar + +**Date:** 2026-07-05 **Status:** draft (pending dual-model + user review) +**Builds on:** `2026-07-04-toolbar-options-popup-design.md` (options already on toolbar) + +## Goal (user, 2026-07-05) + +Collapse the 3-pane workbench into **2 panes** and move the compute-mode selector onto +the toolbar: + +1. **计算模式 (`mode_combo`) → toolbar LEFT dropdown**, right after the DataLab identity + label, always visible (parallels the 计算/LaTeX option buttons already on the toolbar). +2. **输入栏 merges into pane 1.** The left config rail's `input_section` (使用数据文件 + + 输入数据表格) joins the workspace pane. +3. **3-pane → 2-pane:** `[输入(top) + 配置(bottom), vertically stacked]` | `[结果]`. The + result pane becomes 1-of-2 and gains width. +4. Run button (开始执行) + formula/param config stay with pane 1. + +## Confirmed decisions +1. Mode → toolbar left dropdown (NOT a top-of-pane row). +2. Pane 1 internal order: **输入 on top, 配置 on bottom, vertical stack** (NOT an inner + left/right split — that would re-narrow what we just widened). +3. Approach: spec → dual-model serial adversarial → TDD. + +## Current structure (verified against code) + +- `build_workbench_main_splitter` (`workbench_layout.py:111`): three panes — + `config_scroll` (0), `workspace_scroll` (1), `result_frame` (2); + `setSizes([CONFIG_RAIL_WIDTH, workspace_width, RESULT_RAIL_WIDTH])` (:148); + `setStretchFactor(2,0)` (:137). +- `self.left_layout` (pane 0) stacks **4 sections** (`panels.py:738-741`): `mode_section`, + `input_section`, `output_setup_section` (near-empty since options moved to the toolbar), + `run_section`. +- `mode_combo` created in `mode_box` QGroupBox "计算模式" (`panels.py:744-766`); + `currentIndexChanged → _on_mode_change` drives per-mode config switching (`mode_stack`). +- Splitter state persisted at `KEY_MAIN_SPLITTER_STATE`; restore already guards a pane-count + change via `extract_splitter_pane_count` (`panels.py:412-415`) — a stale 3-pane blob is + discarded, not applied. + +## Blast radius — hard-coded 3-pane assumptions (audited; expanded after Codex review) + +- `workbench_layout.py:130-148` — 3× `addWidget`, `setStretchFactor(2,0)`, + `setSizes([...3 values...])`. +- `panels.py:596-629` — `_refresh_main_splitter_left_min_width`: **computes the left + min-width entirely from `workbench_config_content`/`workbench_config_rail`** (597-615), + then `splitter.count() < 3` early return (620) + `setSizes([left, center, right])` (3 + values, 627). **CRITICAL (Codex finding #2):** if the config rail detaches, this sizes + the WRONG (detached) widget and the merged pane's min-width is never enforced. The whole + function must be **re-anchored to the merged pane** (`workbench_workspace_content` / + `workbench_workspace_canvas`), not just have `count()` tweaked to 2. +- `panels.py:348` — `self.left_layout`/`self.left_container`/`self._left_scroll` are ALIASES + to the config rail (`workbench_config_*`). **These aliases must be re-pointed at the merged + pane** so every consumer (sizing, scrollbar checks) targets the real left pane. +- `app_desktop/workbench_visual_contract.py:49-97` — **the visual contract is 3-pane** + (Codex finding #1): `workbench_region_metrics` enumerates CONFIG_RAIL/WORKSPACE/RESULT + (52-58); `visual_contract_issues` flags a "missing_workbench_region" if the config rail is + not visible (66-67), enforces `CONFIG_RAIL_MIN_WIDTH` (72-75), and asserts + `config.x < workspace.x < result.x` (88-96). **Must be rewritten for 2 panes** (drop the + CONFIG region + the 3-way order assert; keep merged-pane + result checks) or it reports + false issues once the config rail is no longer a visible pane. +- `theme.py:35-36` — `CONFIG_RAIL_WIDTH = 320`, `RESULT_RAIL_WIDTH = 380`. RESULT stays; + the merged input+config pane gets a min width (reuse `CONFIG_RAIL_WIDTH` for the merged + pane, or add `WORKSPACE_PANE_MIN_WIDTH`). Note `workbench_visual_contract.py:16-20` also + hard-codes `CONFIG_RAIL_MIN_WIDTH`/`WORKSPACE_CANVAS_MIN_WIDTH`/`RESULT_RAIL_MIN_WIDTH`. +- **Tooling** (Codex finding #2, non-blocking for the app but update for consistency): + `tools/scan_desktop_gui_schema.py:513` and `tools/capture_desktop_gui_screens.py:95` + reference the config rail; audit and repoint. + +Codex confirmed as SOUND (no change needed): splitter stale-3-pane persistence (the +pane-count guard at `panels.py:412-413` drops a real 3-pane blob — independently verified), +`mode_combo` reparent (`_on_mode_change` uses `self.mode_combo`/`self.mode_stack` not +parentage — `window.py:2179`), and `input_section` reparent (bindings on the widgets +themselves — `panels.py:447`, `window.py:1319`, `workspace_controller.py:1816/1932`). + +## Decision (resolves Codex FAIL): merged pane = new left-pane source of truth +The merged left pane IS `workbench_workspace_*` (already the layout path for +formula/variable/mode-stack, `panels.py:353`). We: +1. Re-anchor `left_layout`/`left_container`/`_left_scroll` (panels.py:348) to the merged + `workbench_workspace_*` pane. +2. Move `input_section` (and the config sections) into `workbench_workspace_layout` above + the existing formula/mode-stack content. +3. Rewrite `_refresh_main_splitter_left_min_width` to size the merged pane. +4. Rewrite `workbench_visual_contract.py` to a 2-pane contract. +5. `workbench_config_rail`/`workbench_config_content` become compatibility-only (kept for + attribute references, NOT a splitter pane, NOT sized/validated as a visible region). + +## Architecture + +### 1. `workbench_layout.py` (MODIFIED) — 2-pane splitter +- `build_workbench_main_splitter` adds **two** widgets: the merged left pane + (`workspace_scroll`, now holding input + config) and `result_frame`. Drop + `config_scroll` as a splitter child. +- `setStretchFactor(0,1)` (left grows) or keep result fixed-ish — decide by feel; default: + left stretch 1, result stretch 0 with a sensible starting width (result WIDER than the + old 380 since it is now 1-of-2). `setSizes([left_width, RESULT_RAIL_WIDTH])`. +- `config_scroll` / `workbench_config_content` attributes: **keep them created** (some code + + tests reference `workbench_config_content`), but they are no longer a splitter pane. + Decision: the merged pane's top holds `input_section`, bottom holds the config sections — + we reuse the EXISTING `workspace_scroll`/`workspace_layout` as the merged pane and move + `input_section` (+ the config sections that were in pane 0) into it. `config_scroll` may + become an unused-but-present container, OR we repurpose `workspace_scroll` as the single + left pane. Pick the minimal-reference-breakage option during impl (audit + `workbench_config_content` / `workbench_config_rail` consumers first). + +### 2. `panels.py` (MODIFIED) — section placement +- `mode_section` no longer added to `left_layout`; instead `mode_combo` is placed on the + toolbar (unit 3). `mode_box` QGroupBox may be dropped (mode label lives on the toolbar + button/dropdown) — keep `mode_combo` as `self.mode_combo` (30+ references). +- The remaining sections (`input_section`, config sections, `run_section`) are laid into the + **single merged pane** top-to-bottom: 输入 (input_section) on top, then config, then run. +- `_refresh_main_splitter_left_min_width` (:620): change `count() < 3` → `count() < 2` and + `setSizes([left, right])` (2 values). Compute left min from the merged pane's content. + +### 3. `workbench_toolbar.py` (MODIFIED) — mode dropdown on the LEFT +- Immediately after the identity label / before 新建, add a mode control. Two options: + - **(a) reparent `mode_combo` onto the toolbar** (a plain combo in the toolbar row), or + - **(b) a `QToolButton` menu** listing the 5 modes, synced to `mode_combo`. + - **Prefer (a):** the real `mode_combo` on the toolbar is one widget, no sync, and + `_on_mode_change` keeps firing. But `mode_combo` is created in `panels.py` (build_ui) + AFTER the toolbar. So: toolbar reserves a slot (a container/placeholder); `panels.py` + inserts `mode_combo` into it once created (lazy/after-build, like the option panels). + Label it 模式/Mode via `_register_text`. +- Ensure `mode_combo` stays reachable + `_on_mode_change` wiring intact; per-mode config + still switches `mode_stack` in the merged pane. + +### 4. Splitter persistence (VERIFY, likely no change) +- The `extract_splitter_pane_count` guard already discards a stale 3-pane blob. Add/confirm + a test: a saved 3-pane state does NOT crash or missize the new 2-pane splitter (guard + returns None-count → blob dropped → default 2-pane sizes applied). + +### 5. Tests (TDD, RED first) +- **New/updated `test_desktop_shell_layout.py`**: splitter `count() == 2`; result pane is + index 1; the merged pane contains both `input_section` and the config `mode_stack`. +- **New `test_desktop_mode_selector_on_toolbar.py`**: `mode_combo` is a descendant of + `workbench_bar`; changing it fires `_on_mode_change`; each of the 5 modes still switches + the visible per-mode config. +- **Updated reachability/layout tests**: input + config controls reachable in the single + merged pane; no control stranded. +- **Splitter-migration test**: stale 3-pane persisted blob → clean 2-pane fallback. +- Keep all 5-mode behaviour tests green. +- **Existing 3-pane test assertions to UPDATE (audited — these break and are part of this + change):** + - `tests/test_desktop_workbench_layout.py:44` (`count()==3` → 2), `:46` + (`widget(0)==CONFIG_RAIL_OBJECT` → merged pane object), `:51` + (`visual_contract_issues==[]` — must pass against the rewritten 2-pane contract), + `:72/:75` (`count()==3` → 2), `:76/:129` (left-size ≥ config-rail min → merged-pane min). + - `tests/test_desktop_mode_stack.py:136-137` (`count()==3`, `len(sizes())==3` → 2). + - `tests/test_desktop_gui_redesign_scan.py:126-129` (expects `workbench_config_rail`/ + `_left_scroll` findable — repoint to the merged pane's object). + - `tests/test_desktop_workbench_visual_screenshots.py:45` (`config_rail width ≥ + CONFIG_RAIL_MIN_WIDTH` → merged-pane width check). + - `tests/test_desktop_root_solving_ui.py:187`, `test_desktop_gui_schema_scan.py:194` + (`sizes()[0] ≥ _main_splitter_left_min_width`) — KEEP working by ensuring the merged + pane still populates `_main_splitter_left_min_width`. + - **(Gemini serial-review additions — also break, also in scope):** + - `tests/test_desktop_workbench_layout.py:47-50` (`widget(1)==WORKSPACE_CANVAS_OBJECT`, + `widget(2)==RESULT_RAIL_OBJECT`) → 2-pane indices; `:65-66/:128-130/:153-154` + (`sizes[1]`, `sizes[2]` min-width) → 2-value size asserts; `:185` defensive extra-pane + reset → re-baseline for 2 panes. + - `tests/test_desktop_shell_layout.py:71-87` asserts the left-pane section order + `["mode_section","input_section","output_setup_section","run_section"]` and `:101` + references `window.mode_section` — **`mode_section` is DROPPED** (mode → toolbar), so + this expected list becomes `["input_section", …, "run_section"]` in the merged pane, + and mode-section assertions are removed/repointed to the toolbar mode control. + - `tests/test_desktop_workbench_data_area.py:215` (expects `mode_section`) and `:329` + (`window.mode_section.parentWidget() is window.workbench_config_content`) — update: + no `mode_section`; the input/config sections now parent under the merged pane. + - `tests/test_desktop_theme_spacing.py:46` + (`test_all_mode_section_cards_share_uniform_spacing`) — audit: if it iterates + `mode_section` cards, repoint to the merged pane's cards or the toolbar mode control. + - **Decision on `mode_section`:** keep `self.mode_section` as a (possibly empty/unused) + attribute ONLY if cheaper than updating all consumers; but since tests assert its + *parent* and *ordering*, cleaner to DROP it and update the ~4 test sites. `mode_combo` + (the real widget, 30+ refs) is preserved and moved to the toolbar. + - **(Lead independent sweep — TWO high-value files both models missed):** + - `tests/test_splitter_persistence.py:121-124` asserts `count()==3`, `len(sizes())==3`, + `sizes()[2] ≥ result_rail.minimumWidth()` → **update to 2 panes**. CRITICAL: `:149-167` + `test_valid_looking_stale_blob_with_wrong_pane_count_reverts` builds a **2-pane** fake + blob and expects a **3-pane** window to reject it — after the refactor the window is + **2-pane**, so this test must **invert**: build a stale **3-pane** blob and assert the + new 2-pane window rejects it (this becomes the primary migration test named in §4). + - `tests/test_desktop_workbench_visual_contract.py` is the DEDICATED contract test: + `test_workbench_exposes_three_column_visual_regions` (`:63`, expects 3 regions + + `visual_contract_issues==[]`), `test_visual_contract_reports_minimum_width_violations` + (`:98`), `test_visual_contract_reports_missing_regions_and_invalid_order` (`:132`) all + encode the 3-pane contract. **Rewrite this file** alongside the contract rewrite in + unit-4: two regions (merged + result), drop the `config.x supports_digit_group_size: bool`. +2. **sisetup emitter** takes `emit_digit_group_size` from the probe (drop the date guard). +3. **Grouping strategy** in each mode's writer non-dcolumn path: probe-capable → S column + + native grouping; not-capable → app-side `group_digits_both_sides` + `\text{}` + `r` + column. Statistics is prototyped; extend to root / extrapolation / error / fitting. +4. **Tests (TDD):** unit tests for `group_digits_both_sides` (widths 3/4/6/0, sign, frac, + uncertainty tail); sisetup emitter with/without `emit_digit_group_size`; a probe-stub + test for each strategy branch; golden regression; real-tectonic e2e that width renders + (app-side path) and a local-engine e2e (skipped if no PATH engine) that S-column width + renders. Full desktop + latex suites. +5. **Gate:** desktop + latex suites green + ruff → dual-model (Codex + Gemini serial) → + CodeRabbit → user test → user-confirmed merge → graphify update. + +## Risks / notes + +- App-side grouping changes all modes' non-dcolumn cell text + column spec — wide golden + blast radius; do per-mode with tests. +- The probe adds a one-time compile on first PDF per engine (~100s of ms). Cache it. +- Local-engine compiles are NOT tectonic — network-free, but depend on the user's TeX. Keep + bundled tectonic as the guaranteed fallback so a broken local TeX never blocks a PDF. +- Keep `.tex` export intact (both strategies still produce compilable, reusable .tex). diff --git a/docs/superpowers/specs/2026-07-08-left-config-card-unify-design.md b/docs/superpowers/specs/2026-07-08-left-config-card-unify-design.md new file mode 100644 index 00000000..2dcb7b7c --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-left-config-card-unify-design.md @@ -0,0 +1,136 @@ +# Left workspace column → two blocks (data + one config card) + +> **STATUS: IMPLEMENTED** (commit ea1885d). The column is now [input_section] + +> [workbench_config_card], with the card ordered mode_stack → formula → variable. The review +> findings below (S1/P-A/S3/S4/S5 + P-B/C/D) were all fixed first; S2 was withdrawn as a +> misjudgment. + +## Problem + +The left workspace column stacks **four** blocks top-to-bottom: + +1. `input_section` — data input (输入数据 / 常数 tabs) +2. `workbench_formula_panel` — shared formula input (per-mode QStackedWidget) +3. `workbench_variable_panel` — shared variable mapping (per-mode QStackedWidget) +4. `mode_stack` — per-mode config card (`CurrentPageStack`) + +Two user complaints follow from this: + +- **Fitting order feels backwards**: the model selector lives in the mode card (block 4, last), + so formula/variable info appears *above* the model selector. The user expects + "choose the model first, then see its fields". +- **Not one config box**: each mode should present exactly two blocks — `[输入数据]` and + `[one config box for that mode]` — not four separate stacked widgets. + +## Key facts established from the code + +- All three config widgets (`mode_stack`, `workbench_formula_panel`, + `workbench_variable_panel`) are ALREADY per-mode: each is a stacked widget with a page per + mode, switched together on mode change. The "which mode uses what" logic already exists. +- The formula/variable panels ALREADY self-hide when the current mode has no formula/variables + (`panel.setVisible(page_has_visible_variables)` in `refresh_variable_workspace_panel`, and + the analogous formula refresh). So grouping them into a card leaves no empty gap — unused + sub-blocks disappear on their own. + +## Design + +Wrap the three config widgets in a single container `QGroupBox` — `workbench_config_card` — +laid out vertically in this order: + +1. `mode_stack` (mode selector + mode-specific config) — **top** +2. `workbench_formula_panel` (formula input) +3. `workbench_variable_panel` (variable mapping) + +Add this ONE card to the workspace column as the second block, replacing the three separate +`addWidget` calls. The per-mode switching and self-hide logic inside each stack is untouched. + +Result: the left column has exactly two blocks — `[输入数据 tabs]` + `[config card]`. In fitting +the card reads model-selector → (formula when custom) → variables. Modes that don't use +formula/variables show only their mode config (the sub-panels self-hide). + +## Scope / blast radius + +- **Touched**: `panels.py` (the build-order section that adds the three widgets — reparent them + into a new `workbench_config_card` in the new order); `theme.py` (style for + `workbench_config_card`, reusing the existing config-card style). +- **NOT touched**: the 5 mode views, the schema/reveal system, serialization, per-mode + formula/variable population + self-hide logic. +- **Tests to update**: layout/screenshot tests that assert the three panels are direct children + of the workspace column; add assertions that the column now has two blocks and the card's + internal order is mode → formula → variable. + +## Testing + +- Workspace column has exactly two visible direct blocks: data tabs + `workbench_config_card`. +- Inside the card, child order is `mode_stack`, then formula panel, then variable panel. +- Switching each mode keeps the card content correct; formula/variable sub-blocks self-hide in + modes that don't use them (no empty gap). +- Screenshot manifest updated for the new grouping. + +--- + +## Known bugs from the three-model serial review (Claude → Codex → Gemini, code-grounded, reproduced) + +Run against diff `74109e7..HEAD` (this session's UI work). To be fixed alongside / before the +config-card restructure. Severity + attribution noted. + +### In-scope (introduced this session) — fix before merge + +- **S1 [HIGH] mpf precision loss, two-sided.** `app_desktop/latex_inputs_serialization.py`: + `_MPF_STR_DIGITS = 50` caps encoding at 50 significant digits, and `_decode` does + `mp.mpf(obj["v"])` which reparses at the ambient `mp.dps`. A high-precision workspace (UI allows + compute up to `MAX_MPMATH_DPS` + 200 LaTeX digits) loses precision on reopen → regenerated + on-demand TeX is numerically wrong. Reproduced by all three models. + **Fix**: encode with enough digits for the value's own precision (not a fixed 50 — e.g. derive + from `mp.mp.dps` at encode time or a large safe cap); decode inside `mp.workdps(N)` so the parse + is not truncated by the ambient session precision. + +- **~~S2~~ [WITHDRAWN — was a misjudgment].** Codex-2 flagged that restore clears + `use_constants_file_checkbox` → "file-backed constants silently become manual". Verifying + against the suite showed this is BY DESIGN: on save the file's CONTENTS are captured as an + attachment and inlined into the editor on restore, so the workspace is self-contained and does + not depend on the external file still existing (test + `test_workspace_restores_file_backed_data_for_statistics_time_series` deletes the file then + asserts checkbox=False + data inlined). The proposed "fix" broke that decoupling and was + reverted. No data is lost; only the file-source toggle is intentionally off. Kept a regression + test asserting the file CONTENT survives the round-trip. (Lesson: a review finding that + contradicts an existing intentional test must be verified against the suite before "fixing".) + +- **S3 [MEDIUM] `mode_stack` Maximum policy clips dynamic-growth modes.** The hollow-gap fix set + `mode_stack` to `QSizePolicy.Maximum` + stretch=0. A mode whose config grows after layout + (fitting → comparison reveals a candidate list) is clipped ~19px (page.height 586 < sizeHint + 603, even in a tall window). **Fix**: use `Preferred` vertical policy (grows to content) with the + column's existing `AlignTop` preventing short-page inflation — verified un-clips (626, gap=0 on + short modes preserved). + +- **S4 [MEDIUM] stale tests** assert `manual_box` is a direct child of `input_section`, but it now + lives under `_data_tab`: `tests/test_desktop_workbench_data_area.py:46`, + `tests/test_desktop_workbench_editor_canvas.py:34`. **Fix**: update the parent assertions. + +- **S5 [cosmetic] status chip duplication.** `_refresh_toolbar_status_chip` builds + `f"{label} · {summary}"`; for failed/running states `_value_summary` returns the same word → + "Failed · Failed" / "Running · Running". **Fix**: omit the summary when it equals the status word. + +### Pre-existing (some newly reachable via the new constants-file UI) — separate fix + +- **P-A [MEDIUM crash] unguarded file read** in `workspace_controller._capture_data_section:264` + (`Path(path_text).read_bytes()`): saving a workspace crashes (`FileNotFoundError`) if the data OR + constants file was moved/deleted. Exists on main for the data path; the new constants-file UI + adds a second trigger. **Fix**: guard the read (skip/attach-empty + keep the path) for both. +- **P-B [low]** `line.split()` in the file-text canonicaliser drops empty cells → column shift. +- **P-C [low]** `use_file` True + empty path tags `source_kind="file"` but captures the manual + table (inconsistent state, no attachment). Newly reachable via constants file UI. +- **P-D [low]** unsafe `row["name"]/row["value"]` in constants capture (KeyError on malformed row; + compare the safe `.get()` used elsewhere). Newly reachable via constants file UI. +- **P-E [low]** implicit-config migration double-convert `AttributeError` for legacy `schema != 2` + workspaces. Unrelated to this session. + +### Refuted / theoretical (note only) + +- **`__t__` tag collision**: a stash dict colliding with a real serializer tag (`{"__t__":"mpf",...}`) + would misdecode, but the stash never holds user-controlled arbitrary dicts — not reachable. + Optional defense-in-depth: wrap plain dicts under a `"dict"` tag so no bare `__t__` is trusted. + +Adversarial note: Codex escalated S1 to the encode side; Codex refuted S3 but the refutation was +OVERTURNED by a tall-window reproduction; Gemini surfaced the pre-existing serialization cluster +(P-A…P-E). The Gemini CLI channel timed out on the full prompt and succeeded on a shorter retry. diff --git a/docs/web/deploy.en.md b/docs/web/deploy.en.md index 8832445e..0b56eb48 100644 --- a/docs/web/deploy.en.md +++ b/docs/web/deploy.en.md @@ -55,8 +55,8 @@ export DATALAB_DEBUG=1 # 1. Install gunicorn pip install gunicorn -# 2. Start gunicorn (4 workers) -gunicorn -w 4 -b 127.0.0.1:8000 app_web.server:app +# 2. Start gunicorn (4 workers, app-factory form) +gunicorn -w 4 -b 127.0.0.1:8000 'app_web.server:create_app()' # 3. Configure nginx reverse proxy # /etc/nginx/sites-available/datalab @@ -77,6 +77,21 @@ server { sudo systemctl restart nginx ``` +> **Multi-worker trade-offs (read before sizing `-w`).** Use multiple worker +> *processes*, not threads: mpmath's precision is process-global and serialized by +> a per-process lock (`app_web/blueprints/sse.py` `_MP_SERIAL_LOCK`), so each +> worker runs one fit at a time and concurrency across users comes only from +> having several workers. But two pieces of state are held per worker, in memory: +> - **SSE rate-limiter** (`_RATE_HISTORY` in `sse.py`): the DoS limit is enforced +> *per worker*, so the effective budget is roughly `RATE_MAX_REQUESTS × workers`. +> Size it accordingly, or enforce a strict global cap at the reverse proxy +> (nginx `limit_req`). +> - **Collaboration session registry** (`app_web/blueprints/collaborate.py`): a +> join-token minted on one worker is invisible to the others, so multi-worker +> collaboration needs **sticky sessions**, and true horizontal scale needs a +> **shared store (Redis)** — the `collab` extra in `pyproject.toml` already notes +> Redis for this. + ### Option 2: systemd Service Create `/etc/systemd/system/datalab-web.service`: @@ -93,7 +108,7 @@ WorkingDirectory=/path/to/data_extrapolation_source Environment="DATALAB_WEB_SECRET=your-secret-key-here" Environment="DATALAB_HOST=127.0.0.1" Environment="DATALAB_PORT=8000" -ExecStart=/usr/bin/gunicorn -w 4 -b 127.0.0.1:8000 app_web.server:app +ExecStart=/usr/bin/gunicorn -w 4 -b 127.0.0.1:8000 'app_web.server:create_app()' Restart=always [Install] @@ -112,7 +127,7 @@ Gunicorn does not support Windows. Use Waitress instead: ```powershell pip install waitress -waitress-serve --listen=192.168.85.1:8000 --threads=8 app_web.server:app +waitress-serve --listen=192.168.85.1:8000 --threads=8 --call app_web.server:create_app ``` If you need multi-process workers on Windows, consider running the service in **WSL2/Docker** and using Gunicorn there. diff --git a/docs/web/deploy.zh.md b/docs/web/deploy.zh.md index effc8f36..e10225b3 100644 --- a/docs/web/deploy.zh.md +++ b/docs/web/deploy.zh.md @@ -54,8 +54,8 @@ export DATALAB_DEBUG=1 # 1. 安装 Gunicorn pip install gunicorn -# 2. 启动 Gunicorn(4 个 worker) -gunicorn -w 4 -b 127.0.0.1:8000 app_web.server:app +# 2. 启动 Gunicorn(4 个 worker,应用工厂形式) +gunicorn -w 4 -b 127.0.0.1:8000 'app_web.server:create_app()' # 3. 配置 Nginx 反向代理 # /etc/nginx/sites-available/datalab @@ -76,6 +76,17 @@ server { sudo systemctl restart nginx ``` +> **多 worker 的权衡(设置 `-w` 前必读)。** 请用多个 worker **进程**而非线程:mpmath 的 +> 精度是进程全局的,并由每进程锁(`app_web/blueprints/sse.py` 的 `_MP_SERIAL_LOCK`)串行化, +> 因此每个 worker 同一时刻只跑一个拟合,跨用户并发只能靠多个 worker 进程实现。但有两处状态 +> 按 worker 各自保存在内存中: +> - **SSE 限流器**(`sse.py` 的 `_RATE_HISTORY`):DoS 限流是**按 worker** 各自计数的,因此 +> 实际额度约为 `RATE_MAX_REQUESTS × worker 数`。请据此调小该值,或在反向代理层做严格的 +> 全局限流(nginx `limit_req`)。 +> - **协作会话注册表**(`app_web/blueprints/collaborate.py`):某个 worker 签发的 join-token +> 对其他 worker 不可见,因此多 worker 协作需要**粘性会话(sticky sessions)**,真正的横向 +> 扩展还需要**共享存储(Redis)**——`pyproject.toml` 中的 `collab` extra 已注明需要 Redis。 + ### 推荐方式 2:systemd 服务 创建 `/etc/systemd/system/datalab-web.service`: @@ -92,7 +103,7 @@ WorkingDirectory=/path/to/data_extrapolation_source Environment="DATALAB_WEB_SECRET=your-secret-key-here" Environment="DATALAB_HOST=127.0.0.1" Environment="DATALAB_PORT=8000" -ExecStart=/usr/bin/gunicorn -w 4 -b 127.0.0.1:8000 app_web.server:app +ExecStart=/usr/bin/gunicorn -w 4 -b 127.0.0.1:8000 'app_web.server:create_app()' Restart=always [Install] @@ -111,7 +122,7 @@ sudo systemctl start datalab-web ```powershell pip install waitress -waitress-serve --listen=192.168.85.1:8000 --threads=8 app_web.server:app +waitress-serve --listen=192.168.85.1:8000 --threads=8 --call app_web.server:create_app ``` 如需多进程 worker(CPU 密集型更合适),建议使用 **WSL2/Docker** 在 Linux 环境内运行 Gunicorn。 @@ -218,10 +229,17 @@ Gunicorn worker 数量建议(CPU 密集型): - 公式:`2 × CPU核心数 + 1` - 示例:4 核 CPU → 9 workers +用多个 worker **进程**而非线程:mpmath 精度是进程全局的,由每进程锁 +(`_MP_SERIAL_LOCK`)串行化,每个 worker 同一时刻只跑一个拟合,跨用户并发只能靠多进程。 + ```bash -gunicorn -w 9 -b 127.0.0.1:8000 app_web.server:app +gunicorn -w 9 -b 127.0.0.1:8000 'app_web.server:create_app()' ``` +> **注意**:SSE 限流器与协作会话注册表按 worker 各自保存在内存中。因此 DoS 限流额度约为 +> `RATE_MAX_REQUESTS × worker 数`(严格全局限流请在 nginx `limit_req` 层做);且多 worker +> 协作需要粘性会话加共享存储(Redis)——`pyproject.toml` 的 `collab` extra 已注明需要 Redis。 + ### 资源限制 使用 systemd 限制资源占用: ```ini diff --git a/extrapolation_methods/accelerators.py b/extrapolation_methods/accelerators.py index 63901dae..2bd45b4c 100644 --- a/extrapolation_methods/accelerators.py +++ b/extrapolation_methods/accelerators.py @@ -126,12 +126,16 @@ def _run_shanks( "epsilon_depth": mp.mpf(len(table)), "last_row_length": mp.mpf(len(last_row)), } + # In mpmath's Wynn-epsilon table the ODD columns are auxiliary (non-convergent) entries that + # diverge to huge junk magnitudes — only the even columns are convergents. So last_row[-1] is + # the best convergent and last_row[-3] is the previous convergent, while last_row[-2] is junk. + # Derive both diagnostics from the proper convergent difference |[-1] - [-3]| (audit A6); a + # 2-element last row (a 3-input sequence) has no previous convergent, so emit neither rather + # than a garbage value taken from the auxiliary entry (consumers fall back sanely on absence). if len(last_row) >= 3: - metadata["error_estimate"] = mp.fabs(last_row[-1] - last_row[-3]) - elif len(last_row) == 2: - metadata["error_estimate"] = mp.fabs(last_row[-1] - last_row[-2]) - if len(last_row) >= 2: - metadata["cancellation_indicator"] = mp.fabs(last_row[-2]) + convergent_gap = mp.fabs(last_row[-1] - last_row[-3]) + metadata["error_estimate"] = convergent_gap + metadata["cancellation_indicator"] = convergent_gap metadata["wynn_variant"] = variant metadata["note"] = "mp.shanks uses Wynn epsilon algorithm" return SequenceAcceleratorResult(value=limit, metadata=metadata) diff --git a/fitting/hp_fitter.py b/fitting/hp_fitter.py index d7ea3256..f2da47b7 100644 --- a/fitting/hp_fitter.py +++ b/fitting/hp_fitter.py @@ -678,7 +678,12 @@ def _process_solution(solution: tuple[mp.mpf, ...]) -> None: current_targets, parameter_state.free_params, chi2, - dof if dof > 0 else 1, + # Pass the TRUE dof — _compute_covariance's own `noise = chi2/dof if dof > 0 + # else mp.nan` guard then fires for dof<=0, yielding NaN uncertainties like + # the linear auto_models path. Clamping to 1 here defeated that guard and + # produced spuriously precise (~0) errors for an exactly-determined fit + # (audit A5). + dof, applied_weights, ) dependent_errors = _propagate_dependent_errors(parameter_state, solved_params, covariance) diff --git a/fitting/report.py b/fitting/report.py index c794a159..5b50418e 100644 --- a/fitting/report.py +++ b/fitting/report.py @@ -22,9 +22,15 @@ def summarize_fit_result(result: FitResult) -> str: stat = stat if stat is not None else result.param_errors.get(name, mp.mpf("0")) sys = sys if sys is not None else mp.mpf("0") total = total if total is not None else result.param_errors.get(name, stat) - entry = f"{name} = {_format_value(value)} ± {_format_value(total)}" - if sys and not mp.almosteq(sys, mp.mpf("0")): - entry += f" (stat {_format_value(stat)}, sys {_format_value(sys)})" + # An undefined (non-finite) total uncertainty renders as "N/A" so the text + # summary matches the frontends' tables instead of printing "± nan". The + # stat/sys split is likewise suppressed when sys is undefined — a NaN sys + # passes the truthiness/almosteq checks and would print "(stat nan, sys nan)". + total_text = _format_value(total) if mp.isfinite(total) else "N/A" + entry = f"{name} = {_format_value(value)} ± {total_text}" + if sys and mp.isfinite(sys) and not mp.almosteq(sys, mp.mpf("0")): + stat_text = _format_value(stat) if mp.isfinite(stat) else "N/A" + entry += f" (stat {stat_text}, sys {_format_value(sys)})" lines.append(entry) lines.extend( [ diff --git a/gunicorn.conf.py b/gunicorn.conf.py index a23078ca..6a01fe41 100644 --- a/gunicorn.conf.py +++ b/gunicorn.conf.py @@ -1,6 +1,6 @@ """Gunicorn configuration for the DataLab web app (production). -Run with: gunicorn -c gunicorn.conf.py app_web.server:app +Run with: gunicorn -c gunicorn.conf.py 'app_web.server:create_app()' Why this file exists — the concurrency root-fix (P1-2) ------------------------------------------------------ diff --git a/shared/latex_engine.py b/shared/latex_engine.py index 02699c19..aa189c30 100644 --- a/shared/latex_engine.py +++ b/shared/latex_engine.py @@ -33,6 +33,7 @@ import os import platform import shutil +import subprocess import sys import tarfile import tempfile @@ -533,3 +534,166 @@ def tectonic_compile_argv(binary: str, tex_path: Path | str) -> list[str]: "--", str(tex_path), ] + + +# --------------------------------------------------------------------------- +# siunitx capability probe (does the engine's siunitx honour digit-group-size?) +# --------------------------------------------------------------------------- + +# A minimal document that FAILS to compile iff siunitx rejects ``digit-group-size`` +# (LaTeX3 key-unknown). Newer siunitx (>= ~3.1, local TeX Live) compiles it; the +# Tectonic-bundled 3.0.49 errors out. Kept tiny so the probe is fast. +_DIGIT_GROUP_SIZE_PROBE_TEX = ( + "\\documentclass{article}\n" + "\\usepackage{siunitx}\n" + "\\sisetup{group-digits = all, digit-group-size = 4}\n" + "\\begin{document}\\num{12345678}\\end{document}\n" +) + +# Cache: engine binary path -> supports digit-group-size (bool). Populated on first probe. +_capability_cache: dict[str, bool] = {} + + +def _reset_capability_cache() -> None: + """Clear the probe cache (tests + when the engine selection changes).""" + _capability_cache.clear() + + +def engine_probe_argv(binary: str, tex_path: Path | str) -> list[str]: + """Argv to compile ``tex_path`` with ``binary`` for a NON-interactive one-shot probe. + + Tectonic and the LaTeX engines take different flags; the stem decides which (matching + the compile worker's own dispatch).""" + if Path(binary).stem.lower().endswith("tectonic"): + return tectonic_compile_argv(binary, tex_path) + return [ + binary, + "-no-shell-escape", + "-interaction=nonstopmode", + "-halt-on-error", + str(tex_path), + ] + + +def siunitx_supports_digit_group_size(engine_path: str) -> bool: + """Return True iff ``engine_path``'s siunitx honours ``digit-group-size``. + + Compiles a tiny probe doc once per engine path (cached). Any launch failure, timeout, + or non-zero exit → False (treated as "not supported"), so a broken/missing engine never + crashes the caller — the app falls back to app-side text grouping. + """ + if not engine_path: + return False + if engine_path in _capability_cache: + return _capability_cache[engine_path] + + supported = False + try: + with tempfile.TemporaryDirectory(prefix="datalab_siprobe_") as tmp: + tex = Path(tmp) / "siprobe.tex" + tex.write_text(_DIGIT_GROUP_SIZE_PROBE_TEX, encoding="utf-8") + argv = engine_probe_argv(engine_path, tex) + proc = subprocess.run( + argv, + cwd=tmp, + capture_output=True, + text=True, + timeout=120, + ) + supported = proc.returncode == 0 + except (OSError, subprocess.SubprocessError): + supported = False + + _capability_cache[engine_path] = supported + return supported + + +# Ordered preference of PATH LaTeX engines to try in local/auto modes. +_LOCAL_ENGINE_PREFERENCE = ("xelatex", "pdflatex", "lualatex") + +# All engine names the discovery UI enumerates (local engines first, tectonic last). +_ALL_ENGINE_NAMES = ("xelatex", "pdflatex", "lualatex", "tectonic") + + +def discover_all_engines( + *, bundle_root: Path | str | None = None +) -> list[tuple[str, EngineChoice]]: + """Enumerate the LaTeX engines actually available on this machine. + + Returns ``(engine_name, EngineChoice)`` pairs — one per engine that resolves (system + PATH, bundled TinyTeX, or an already-installed Tectonic). Engines not found are omitted; + two names resolving to the same binary path are listed once. The order follows + ``_ALL_ENGINE_NAMES`` (local engines first, tectonic last). Callers build the engine + selector from this so the dropdown shows the real detected compilers. + """ + found: list[tuple[str, EngineChoice]] = [] + seen_paths: set[str] = set() + for name in _ALL_ENGINE_NAMES: + choice = resolve_engine(name, bundle_root=bundle_root) + if choice is None or not choice.path: + continue + if choice.path in seen_paths: + continue + seen_paths.add(choice.path) + found.append((name, choice)) + return found + + +def resolve_engine_for_mode( + mode: str, *, bundle_root: Path | str | None = None +) -> EngineChoice | None: + """Resolve a compile engine per the user's engine MODE. + + - ``"bundled"`` → the internal Tectonic only (guaranteed, network-installable). Group + WIDTH is fixed at 3 (its siunitx lacks digit-group-size); the writers fall back to + app-side text grouping. + - ``"local"`` → a PATH LaTeX engine (xelatex/pdflatex/lualatex) only; no Tectonic + fallback. Returns None if the user has no local TeX. + - ``"auto"`` (default) → prefer a PATH engine whose siunitx honours digit-group-size + (so S-column native variable-width grouping works); otherwise fall back to Tectonic. + + Returns an :class:`EngineChoice` (with a resolved ``path``) or None when nothing usable + is found for the mode. + """ + if mode == "bundled": + # "内置" must force the bundled/auto-installed Tectonic — NOT a system-PATH tectonic + # (resolve_engine checks PATH first, which would let a system binary shadow the + # bundled one; dual-model review F5). Prefer bundled TinyTeX, then ~/.datalab/bin. + if bundle_root is None: + bundle_root = find_app_root() + bun_path = discover_bundled_engine(bundle_root, "tectonic") + if bun_path: + return EngineChoice(path=bun_path, source="bundled") + candidate = tectonic_install_dir() / tectonic_executable_name() + if candidate.is_file(): + return EngineChoice(path=str(candidate), source="auto-tectonic") + # Nothing bundled/installed yet — fall back to whatever resolve_engine finds so the + # caller can trigger the Tectonic auto-install path. + return resolve_engine("tectonic", bundle_root=bundle_root) + + def _first_local() -> EngineChoice | None: + for name in _LOCAL_ENGINE_PREFERENCE: + choice = resolve_engine(name, bundle_root=bundle_root) + if choice is not None: + return choice + return None + + if mode == "local": + return _first_local() + + # auto: a CAPABLE local engine wins (best grouping); else fall back to Tectonic (always + # available once installed); else an incapable local engine (still produces correct PDFs, + # just fixed-width grouping). Must scan ALL local engines for a capable one before + # settling — returning the first incapable one early would skip a later capable engine + # (dual-model review F4). + tectonic = resolve_engine("tectonic", bundle_root=bundle_root) + first_incapable: EngineChoice | None = None + for name in _LOCAL_ENGINE_PREFERENCE: + choice = resolve_engine(name, bundle_root=bundle_root) + if choice is None: + continue + if siunitx_supports_digit_group_size(choice.path): + return choice + if first_incapable is None: + first_incapable = choice + return tectonic or first_incapable diff --git a/shared/ui_specs.py b/shared/ui_specs.py index 27ddf34b..7ee434d1 100644 --- a/shared/ui_specs.py +++ b/shared/ui_specs.py @@ -357,6 +357,17 @@ def form_section( visible_when=VisibilityRule.equals("method", "wynn_epsilon"), ) +# quadratic ("默认三点公式") derives its three values from the data columns themselves, so it takes +# no tunable parameters (empty group, like shanks/wynn_epsilon). Added so the desktop offers the +# backend's default three-point method that the web already exposes (audit B3). +QUADRATIC_PARAMS = form_section( + key="quadratic_params", + title_zh="默认三点公式", + title_en="Default three-point formula", + fields=[], + visible_when=VisibilityRule.equals("method", "quadratic"), +) + # ============================================================ # Complete Method Specifications @@ -390,6 +401,14 @@ def get_description(self, lang: str = "zh") -> str: description_en=get_method_description("power_law", "en"), parameter_groups=[POWER_LAW_PARAMS], ), + "quadratic": MethodSpec( + key="quadratic", + name_zh="默认三点公式", + name_en="Default three-point formula", + description_zh=get_method_description("quadratic", "zh"), + description_en=get_method_description("quadratic", "en"), + parameter_groups=[QUADRATIC_PARAMS], + ), "richardson": MethodSpec( key="richardson", name_zh="Richardson 序列加速", @@ -440,6 +459,7 @@ def get_description(self, lang: str = "zh") -> str: # Order of methods in the dropdown (desktop GUI order) METHOD_DISPLAY_ORDER = [ "power_law", + "quadratic", "richardson", "shanks", "levin_u", @@ -517,7 +537,11 @@ def get_method_options(lang: str = "zh") -> list[tuple[str, str]]: choices=[ _choice("polynomial", "多项式", "Polynomial"), _choice("inverse_power", "反幂级数", "Inverse-power series"), + _choice("pade", "Padé 拟合", "Padé"), + _choice("power_limit", "幂律极限拟合", "Power-law limit"), _choice("custom", "自定义模型", "Custom model"), + _choice("self_consistent", "自洽隐式模型", "Self-consistent / implicit"), + _choice("comparison", "选定拟合比较", "Selected-fit comparison"), ], tooltip_zh="选择曲线拟合模型。", tooltip_en="Choose the curve fitting model.", @@ -700,14 +724,15 @@ def get_method_options(lang: str = "zh") -> list[tuple[str, str]]: key="latex.engine", label_zh="LaTeX 引擎:", label_en="LaTeX engine:", - default_value="tectonic", + default_value="auto", choices=( - _choice("pdflatex", "pdflatex", "pdflatex"), - _choice("xelatex", "xelatex", "xelatex"), - _choice("tectonic", "tectonic", "tectonic"), + # Engine MODE, not a specific binary — the app resolves the actual engine per mode. + _choice("auto", "自动", "Auto"), + _choice("bundled", "内置 Tectonic", "Bundled Tectonic"), + _choice("local", "本地 TeX", "Local TeX"), ), - tooltip_zh="选择用于编译 PDF 的 LaTeX 引擎。", - tooltip_en="Choose the LaTeX engine used to compile PDF output.", + tooltip_zh="自动优先本地 TeX(支持任意分组宽度),否则使用内置 Tectonic。", + tooltip_en="Auto prefers a local TeX (supports any group width), else the bundled Tectonic.", required=False, ) RESULT_LATEX_ENGINE_PATH_FIELD = button_field( diff --git a/statistics_utils.py b/statistics_utils.py index 400e5649..9754395f 100644 --- a/statistics_utils.py +++ b/statistics_utils.py @@ -88,7 +88,9 @@ def _statistics_input_units_for_labels( return unit_annotations_for_labels(units, "inputs", labels, fallback_prefix="column") -def _statistics_latex_preamble(*, use_dcolumn: bool, group_size: int) -> list[str]: +def _statistics_latex_preamble( + *, use_dcolumn: bool, group_size: int, native_group_width: bool = True +) -> list[str]: from datalab_latex.sisetup_block import build_sisetup_block lines = [ @@ -116,10 +118,15 @@ def _statistics_latex_preamble(*, use_dcolumn: bool, group_size: int) -> list[st lines.append("\\usepackage{dcolumn}") lines.append("\\newcolumntype{d}[1]{D{.}{.}{#1}}") lines.append("\\usepackage{siunitx}") + # native_group_width True → the engine honours digit-group-size → emit it (S-column + # native variable-width grouping). False → don't emit (bundled Tectonic rejects it); the + # cells are pre-grouped app-side instead. group_size 0 / dcolumn → no override anyway. + emit_dgs = True if (native_group_width and not use_dcolumn and group_size > 0) else False lines.append( build_sisetup_block( group_size=group_size, include_dcolumn=use_dcolumn, + emit_digit_group_size=emit_dgs, ).rstrip("\n") ) return lines @@ -174,10 +181,25 @@ def generate_statistics_latex( caption: str | None = None, latex_group_size: int = 3, units: Mapping[str, Any] | None = None, + native_group_width: bool = True, ): from data_extrapolation_latex_latest import calculate_dcolumn_format_for_column, siunitx_column_spec + from datalab_latex.latex_formatting import group_digits_both_sides group_size = max(0, int(latex_group_size)) + # App-side grouping: when the compile engine's siunitx CANNOT vary the digit-group width + # (native_group_width False → bundled Tectonic siunitx 3.0.49) AND grouping is on in + # siunitx (non-dcolumn) mode, pre-group each cell here (any width) and print it as a + # plain \text{} cell in an r column, instead of a raw number in an S column that siunitx + # would re-group at a fixed 3. When native_group_width is True (capable local TeX) the + # S column + \sisetup{digit-group-size} does the grouping natively. + app_group = (not native_group_width) and (not use_dcolumn) and group_size > 0 + + def _maybe_group(cell: str) -> str: + if app_group and "\\multicolumn" not in cell and "\\text" not in cell: + return "\\text{" + group_digits_both_sides(cell, group_size) + "}" + return cell + num_cols = len(data_rows[0]) if data_rows else 0 formatted_columns: list[list[str]] = [[] for _ in range(num_cols)] for row_idx, row in enumerate(data_rows): @@ -195,13 +217,16 @@ def generate_statistics_latex( is_input=True, group_size=group_size, ) - formatted_columns[col_idx].append(cell) + formatted_columns[col_idx].append(_maybe_group(cell)) if use_dcolumn: num_specs = [ calculate_dcolumn_format_for_column(formatted_columns[i], f"stats_data_col_{i}") for i in range(num_cols) ] + elif app_group: + # Plain right-aligned column: cell text is already grouped + wrapped in \text{}. + num_specs = ["r"] * num_cols else: num_specs = [siunitx_column_spec(formatted_columns[i]) for i in range(num_cols)] data_col_spec = "l" + ("" if not num_specs else " " + " ".join(num_specs)) @@ -209,7 +234,7 @@ def generate_statistics_latex( input_units = _statistics_input_units_for_labels(units, data_column_labels) def _format_summary_value(value, sigma, is_input: bool) -> str: - return _format_table_value( + cell = _format_table_value( value, sigma, digits, @@ -218,6 +243,7 @@ def _format_summary_value(value, sigma, is_input: bool) -> str: is_input=is_input, group_size=group_size, )[0] + return _maybe_group(cell) summary_rows = build_statistics_latex_summary_rows( result, @@ -226,7 +252,9 @@ def _format_summary_value(value, sigma, is_input: bool) -> str: ) summary_units = statistics_latex_summary_units_for_rows(units, summary_rows) - lines = _statistics_latex_preamble(use_dcolumn=use_dcolumn, group_size=group_size) + lines = _statistics_latex_preamble( + use_dcolumn=use_dcolumn, group_size=group_size, native_group_width=native_group_width + ) title = f"Statistical Summary ({result.get('method_label', '')})" table_caption = caption if caption else f"Statistical summary for {value_col}" @@ -303,10 +331,20 @@ def generate_statistics_latex_batches( uncertainty_digits: int | None = None, latex_group_size: int = 3, units: Mapping[str, Any] | None = None, + native_group_width: bool = True, ): from data_extrapolation_latex_latest import calculate_dcolumn_format_for_column, siunitx_column_spec + from datalab_latex.latex_formatting import group_digits_both_sides group_size = max(0, int(latex_group_size)) + # See generate_statistics_latex: app-side pre-grouping when the engine can't vary width. + app_group = (not native_group_width) and (not use_dcolumn) and group_size > 0 + + def _maybe_group(cell: str) -> str: + if app_group and "\\multicolumn" not in cell and "\\text" not in cell: + return "\\text{" + group_digits_both_sides(cell, group_size) + "}" + return cell + def _build_block( batch_idx: int, rows, @@ -333,13 +371,15 @@ def _build_block( is_input=True, group_size=group_size, ) - formatted_columns[col_idx].append(cell) + formatted_columns[col_idx].append(_maybe_group(cell)) if use_dcolumn: num_specs = [ calculate_dcolumn_format_for_column(formatted_columns[i], f"stats_batch_{batch_idx}_col_{i}") for i in range(num_cols) ] + elif app_group: + num_specs = ["r"] * num_cols else: num_specs = [siunitx_column_spec(formatted_columns[i]) for i in range(num_cols)] data_col_spec = "l" + ("" if not num_specs else " " + " ".join(num_specs)) @@ -347,7 +387,7 @@ def _build_block( input_units = _statistics_input_units_for_labels(block_units, data_column_labels) def _format_summary_value(value, sigma, is_input: bool) -> str: - return _format_table_value( + cell = _format_table_value( value, sigma, digits, @@ -356,6 +396,7 @@ def _format_summary_value(value, sigma, is_input: bool) -> str: is_input=is_input, group_size=group_size, )[0] + return _maybe_group(cell) summary_rows = build_statistics_latex_summary_rows( result, @@ -419,7 +460,9 @@ def _format_summary_value(value, sigma, is_input: bool) -> str: lines_block.extend(["\\bottomrule", "\\end{tabular}", "\\end{table}", ""]) return lines_block - lines = _statistics_latex_preamble(use_dcolumn=use_dcolumn, group_size=group_size) + lines = _statistics_latex_preamble( + use_dcolumn=use_dcolumn, group_size=group_size, native_group_width=native_group_width + ) title = f"Statistical Summary ({value_col})" base_caption = caption if caption else f"Statistical summary for {value_col}" @@ -458,6 +501,7 @@ def generate_statistics_bootstrap_latex( caption: str | None = None, uncertainty_digits: int | None = None, latex_group_size: int = 3, + native_group_width: bool = True, ): """Generate a standalone LaTeX report from a bootstrap statistics snapshot.""" @@ -470,7 +514,9 @@ def generate_statistics_bootstrap_latex( if not batches: raise ValueError("statistics bootstrap snapshot has no batches.") - lines = _statistics_latex_preamble(use_dcolumn=use_dcolumn, group_size=group_size) + lines = _statistics_latex_preamble( + use_dcolumn=use_dcolumn, group_size=group_size, native_group_width=native_group_width + ) base_caption = latex_escape(caption or "Bootstrap confidence intervals") lines.extend( [ @@ -574,6 +620,7 @@ def generate_statistics_time_series_latex( caption: str | None = None, uncertainty_digits: int | None = None, latex_group_size: int = 3, + native_group_width: bool = True, ): """Generate a standalone LaTeX report from a time-series statistics snapshot.""" @@ -673,7 +720,9 @@ def _numeric_spec(values: list[str], key: str) -> str: r"Status & Window rows \\" ) ) - lines = _statistics_latex_preamble(use_dcolumn=use_dcolumn, group_size=group_size) + lines = _statistics_latex_preamble( + use_dcolumn=use_dcolumn, group_size=group_size, native_group_width=native_group_width + ) escaped_caption = latex_escape(base_caption) lines.extend( [ @@ -716,6 +765,7 @@ def generate_statistics_hypothesis_latex( caption: str | None = None, uncertainty_digits: int | None = None, latex_group_size: int = 3, + native_group_width: bool = True, ): """Generate a standalone LaTeX report from a hypothesis-test snapshot.""" @@ -764,7 +814,9 @@ def generate_statistics_hypothesis_latex( else "Metric & \\multicolumn{1}{c}{Value} & Note \\\\" ) - lines = _statistics_latex_preamble(use_dcolumn=use_dcolumn, group_size=group_size) + lines = _statistics_latex_preamble( + use_dcolumn=use_dcolumn, group_size=group_size, native_group_width=native_group_width + ) lines.extend( [ "\\geometry{margin=1in}", diff --git a/test_probe_widget_tree.py b/test_probe_widget_tree.py new file mode 100644 index 00000000..836fe47e --- /dev/null +++ b/test_probe_widget_tree.py @@ -0,0 +1,35 @@ +import os +import sys +from PySide6.QtWidgets import QApplication +from PySide6.QtCore import QObject + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +sys.path.insert(0, os.path.abspath(".")) + +from app_desktop.window import ExtrapolationWindow + +QApplication.instance() or QApplication([]) +win = ExtrapolationWindow() +win._apply_language("zh") + +schema_keyed_widgets = [] +for obj in [win, *win.findChildren(QObject)]: + key = obj.property("datalab_schema_key") + if key: + schema_keyed_widgets.append((key, obj)) + +print(f"Total schema keyed widgets: {len(schema_keyed_widgets)}") +types_found = {} +for key, w in schema_keyed_widgets: + t = type(w).__name__ + if t not in types_found: + types_found[t] = [] + types_found[t].append(key) + +for t, keys in sorted(types_found.items()): + print(f"{t}: {len(keys)}") + for k in sorted(keys)[:5]: + print(f" {k}") + if len(keys) > 5: + print(" ...") + diff --git a/tests/test_app_web_compute_rate_limit.py b/tests/test_app_web_compute_rate_limit.py new file mode 100644 index 00000000..4c916667 --- /dev/null +++ b/tests/test_app_web_compute_rate_limit.py @@ -0,0 +1,57 @@ +"""The heavy compute POST routes must be per-IP rate limited (audit A2). + +Each compute route runs an mpmath computation while holding a process-global serial lock, so an +attacker hammering them can starve legitimate users. A blueprint before_request throttles POST +(reusing the SSE sliding-window limiter); GET (cheap form render) is never throttled. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("flask") + + +def _app_with_active_limiter(monkeypatch): + # The limiter is a no-op under TESTING / the disable env var — turn both off so the throttle is + # actually exercised, and reset the shared window so the test is order-independent. + monkeypatch.delenv("DATALAB_SSE_DISABLE_RATE_LIMIT", raising=False) + monkeypatch.setenv("DATALAB_DEBUG", "1") + from app_web.server import create_app + import app_web.blueprints.sse as sse + + sse._RATE_HISTORY.clear() + app = create_app() + app.config["TESTING"] = False + return app, sse + + +def test_compute_post_is_rate_limited(monkeypatch): + app, sse = _app_with_active_limiter(monkeypatch) + client = app.test_client() + codes = [client.post("/", data={}).status_code for _ in range(sse.RATE_MAX_REQUESTS + 5)] + assert 429 in codes, "compute POST route was never rate-limited" + # The first RATE_MAX_REQUESTS are admitted (whatever their handler status), then 429 kicks in. + assert codes[sse.RATE_MAX_REQUESTS] == 429 + + +def test_get_form_render_is_not_rate_limited(monkeypatch): + app, sse = _app_with_active_limiter(monkeypatch) + client = app.test_client() + codes = [client.get("/").status_code for _ in range(sse.RATE_MAX_REQUESTS + 5)] + assert 429 not in codes, "GET form render must not be throttled" + + +def test_limiter_is_bypassed_under_testing(monkeypatch): + # Regression guard: the normal test suite (TESTING=True) must not be throttled. + monkeypatch.delenv("DATALAB_SSE_DISABLE_RATE_LIMIT", raising=False) + monkeypatch.setenv("DATALAB_DEBUG", "1") + from app_web.server import create_app + import app_web.blueprints.sse as sse + + sse._RATE_HISTORY.clear() + app = create_app() + app.config["TESTING"] = True + client = app.test_client() + codes = [client.post("/", data={}).status_code for _ in range(sse.RATE_MAX_REQUESTS + 5)] + assert 429 not in codes diff --git a/tests/test_app_web_docs_baseline.py b/tests/test_app_web_docs_baseline.py index 52f77a92..fbfbace5 100644 --- a/tests/test_app_web_docs_baseline.py +++ b/tests/test_app_web_docs_baseline.py @@ -85,6 +85,25 @@ def test_docs_page_renders_named_markdown_and_navigation(client: Any) -> None: assert "datalab_lang=en" in response.headers.get("Set-Cookie", "") +def test_docs_headings_get_ids_so_toc_anchors_resolve(client: Any) -> None: + """The heading-id injection must actually run so the TOC in-page links resolve; the old + `` literal-backslash regex never matched and emitted no ids (audit A7).""" + import re + + response = client.get("/docs/guide?lang=en") + assert response.status_code == 200 + html = response.get_data(as_text=True) + + heading_ids = set(re.findall(r' None: response = client.get("/docs/not-a-page?lang=en") diff --git a/tests/test_app_web_fitting_custom_params.py b/tests/test_app_web_fitting_custom_params.py new file mode 100644 index 00000000..1f7354dd --- /dev/null +++ b/tests/test_app_web_fitting_custom_params.py @@ -0,0 +1,45 @@ +"""Custom-mode params-JSON error-path tests (CR-2, twin of the self_consistent CR-1). + +The custom branch's non-dict params check used to sit inside the JSON-parse +try/except, so its bilingual ValueError was caught and re-wrapped into a doubled +'汉语 / English / 汉语 / English' message that breaks the locale layer's single +' / ' split. These tests pin the corrected behavior: exactly one ' / ' per error. +""" + +from __future__ import annotations + +import pytest + +from app_web.logic.fitting import _run_fit + +_DATA_TEXT = "x y\n1 3\n2 5\n3 7\n" + + +def _run_custom(params_text: str) -> None: + _run_fit( + _DATA_TEXT, + { + "fit_mode": "custom", + "fit_custom_expr": "a*x + b", + "fit_custom_params": params_text, + "fit_x_column": "x", + "fit_target_column": "y", + "fit_mp_precision": "50", + }, + ) + + +def test_custom_non_dict_params_gives_single_bilingual_message() -> None: + with pytest.raises(ValueError) as exc_info: + _run_custom("[1, 2]") # valid JSON, but not an object + assert str(exc_info.value).count(" / ") == 1 + + +def test_custom_params_json_syntax_error_is_wrapped_once() -> None: + with pytest.raises(ValueError) as exc_info: + _run_custom("{bad json") + message = str(exc_info.value) + assert message.count(" / ") == 1 + # The wrap identifies the failing stage in both languages. + assert "自定义模型解析失败" in message + assert "Failed to parse custom model" in message diff --git a/tests/test_app_web_fitting_self_consistent.py b/tests/test_app_web_fitting_self_consistent.py new file mode 100644 index 00000000..d1b58db8 --- /dev/null +++ b/tests/test_app_web_fitting_self_consistent.py @@ -0,0 +1,168 @@ +"""Web-fitting `self_consistent` (implicit) mode wiring tests (task B4). + +Reuses the known-recovery model from +``tests/test_implicit_model.py::test_runner_uses_singleton_output_inversion_seed_for_parameter_initials``: +implicit equation ``a*x`` (independent of the implicit variable ``u``), output +expression ``u + 1``. With ``a=2`` this yields ``y = 2*x + 1``, so the dataset +``x=1,2,3 -> y=3,5,7`` recovers ``a=2`` exactly. +""" + +from __future__ import annotations + +import mpmath as mp +import pytest + +from app_web.logic.fitting import _run_fit + +_DATA_TEXT = "x y\n1 3\n2 5\n3 7\n" + + +def test_run_fit_self_consistent_recovers_known_parameter() -> None: + result = _run_fit( + _DATA_TEXT, + { + "fit_mode": "self_consistent", + "fit_implicit_equation": "a*x", + "fit_implicit_variable": "u", + "fit_implicit_output": "u + 1", + "fit_implicit_params": '{"a": {"initial": "1"}}', + "fit_mp_precision": "50", + "fit_result_digits": "6", + }, + ) + + assert result.params + assert result.metrics + param_by_name = {p["name"]: p for p in result.params} + assert "a" in param_by_name + assert mp.almosteq(mp.mpf(str(param_by_name["a"]["value_raw"])), mp.mpf("2"), rel_eps=mp.mpf("1e-10")) + assert result.best_label == "自洽隐式模型 / Self-consistent" + + +def test_run_fit_self_consistent_requires_equation() -> None: + with pytest.raises(ValueError) as exc_info: + _run_fit( + _DATA_TEXT, + { + "fit_mode": "self_consistent", + "fit_implicit_equation": "", + "fit_implicit_variable": "u", + "fit_implicit_output": "u + 1", + "fit_mp_precision": "50", + }, + ) + assert " / " in str(exc_info.value) + + +def test_run_fit_self_consistent_requires_output_expression() -> None: + with pytest.raises(ValueError) as exc_info: + _run_fit( + _DATA_TEXT, + { + "fit_mode": "self_consistent", + "fit_implicit_equation": "a*x", + "fit_implicit_variable": "u", + "fit_implicit_output": "", + "fit_mp_precision": "50", + }, + ) + assert " / " in str(exc_info.value) + + +def test_run_fit_self_consistent_requires_valid_identifier_for_implicit_variable() -> None: + with pytest.raises(ValueError) as exc_info: + _run_fit( + _DATA_TEXT, + { + "fit_mode": "self_consistent", + "fit_implicit_equation": "a*x", + "fit_implicit_variable": "1bad", + "fit_implicit_output": "u + 1", + "fit_mp_precision": "50", + }, + ) + assert " / " in str(exc_info.value) + + +def test_run_fit_self_consistent_non_dict_params_gives_single_bilingual_message() -> None: + """CR-1 regression: a non-dict params JSON must raise ONE clean bilingual message. + + Previously the non-dict check was raised inside the JSON-parse try/except, so it + was caught and re-wrapped, producing a doubled '汉语 / English / 汉语 / English' + string that breaks the locale layer's single ' / ' split. + """ + with pytest.raises(ValueError) as exc_info: + _run_fit( + _DATA_TEXT, + { + "fit_mode": "self_consistent", + "fit_implicit_equation": "a*x", + "fit_implicit_variable": "u", + "fit_implicit_output": "u + 1", + "fit_implicit_params": "[1, 2]", # valid JSON, but not an object + "fit_mp_precision": "50", + }, + ) + # Exactly one ' / ' separator — not a nested/doubled message. + assert str(exc_info.value).count(" / ") == 1 + + +def test_run_fit_self_consistent_unused_param_reports_na_instead_of_crashing() -> None: + """CX-1 regression: an unused parameter yields a non-finite (undefined) uncertainty. + + The implicit solver still converges on the real parameter, but the covariance is + rank-deficient, so uncertainties come back NaN. _collect_params must render those + as 'N/A' rather than letting the siunitx formatter raise a raw, non-bilingual + 'cannot convert inf or nan to int' and 500-crash the whole fit response. + """ + result = _run_fit( + _DATA_TEXT, + { + "fit_mode": "self_consistent", + "fit_implicit_equation": "a*x", + "fit_implicit_variable": "u", + "fit_implicit_output": "u + 1", + "fit_implicit_params": '{"z": {"initial": "1"}}', # z never appears in the equation + "fit_mp_precision": "50", + "fit_result_digits": "6", + }, + ) + assert result.params # did not crash + by_name = {p["name"]: p for p in result.params} + assert "z" in by_name and "a" in by_name + # The unused parameter's uncertainty is undefined → reported as N/A, not a crash. + assert by_name["z"]["uncertainty"] == "N/A" + # The real parameter value is still recovered correctly (a = 2 for y = 2x + 1). + assert mp.almosteq(mp.mpf(str(by_name["a"]["value_raw"])), mp.mpf("2"), rel_eps=mp.mpf("1e-10")) + # Round-2 R2-2: the CSV export and text summary must agree with the table — + # an undefined sigma is "N/A" on every surface, never a bare "nan". + csv_param_lines = [ln for ln in (result.csv_data or "").splitlines() if ",parameter," in ln] + assert csv_param_lines and all(",N/A," in ln and ",nan," not in ln for ln in csv_param_lines) + assert "± N/A" in result.summary_text + assert "± nan" not in result.summary_text + + +def test_run_fit_self_consistent_zero_dof_survives_nan_metrics() -> None: + """Round-2 R2-1 regression: dof=0 (2 points, 2 params) makes reduced χ²/AIC/BIC NaN. + + The LaTeX metrics branch used to re-parse 'nan' and crash in + format_value_for_latex_file, discarding the whole successful fit behind a + generic compute-failed flash. Non-finite metrics must now render as + parse-safe literal cells while params/CSV/plot survive. + """ + result = _run_fit( + "x y\n1 3\n2 5\n", # 2 points, 2 inferred params (z unused + a) → dof = 0 + { + "fit_mode": "self_consistent", + "fit_implicit_equation": "a*x", + "fit_implicit_variable": "u", + "fit_implicit_output": "u + 1", + "fit_implicit_params": '{"z": {"initial": "1"}}', + "fit_mp_precision": "50", + "fit_result_digits": "6", + }, + ) + by_name = {p["name"]: p for p in result.params} + assert mp.almosteq(mp.mpf(str(by_name["a"]["value_raw"])), mp.mpf("2"), rel_eps=mp.mpf("1e-10")) + # NaN metrics render as parse-safe literal cells in the LaTeX metrics table. + assert "\\multicolumn{1}{c}{nan}" in result.latex_text diff --git a/tests/test_app_web_precision_clamp.py b/tests/test_app_web_precision_clamp.py new file mode 100644 index 00000000..ec1f909d --- /dev/null +++ b/tests/test_app_web_precision_clamp.py @@ -0,0 +1,59 @@ +"""The web compute routes must CLAMP the user-supplied mpmath precision (dps). + +mp.dps is process-global and each compute route holds a serial lock while it runs, so an unbounded +precision value (e.g. 100_000_000) would set an absurd precision and stall the worker — a trivial +DoS (audit A1). `_parse_precision` clamps at parse time to [MIN_MPMATH_DPS, MAX_MPMATH_DPS], and +every compute route parses its precision field through it. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("flask") + +from app_web.logic.common import _parse_precision +from shared.precision import MAX_MPMATH_DPS, MIN_MPMATH_DPS + + +def test_parse_precision_clamps_pathological_high_value() -> None: + # The DoS vector: an absurd precision must be bounded to the app's ceiling. + assert _parse_precision("100000000") == MAX_MPMATH_DPS + + +def test_parse_precision_clamps_below_minimum() -> None: + assert _parse_precision("5") == MIN_MPMATH_DPS + + +def test_parse_precision_passes_in_range_value() -> None: + assert _parse_precision("80") == 80 + + +def test_parse_precision_returns_default_when_absent() -> None: + assert _parse_precision(None) is None + assert _parse_precision("") is None + assert _parse_precision(None, 80) == 80 + + +def test_every_compute_route_parses_precision_through_the_clamp() -> None: + """Guardrail: the compute routes must use the clamping `_parse_precision`, not the raw + `_parse_int`, for their *_mp_precision fields — otherwise the clamp is bypassed.""" + import pathlib + + root = pathlib.Path(__file__).resolve().parents[1] / "app_web" / "logic" + fields = { + "extrapolation.py": "mp_precision", + "error_propagation.py": "error_mp_precision", + "statistics.py": "stats_mp_precision", + "root_solving.py": "root_mp_precision", + "fitting.py": "fit_mp_precision", + } + for filename, field in fields.items(): + source = (root / filename).read_text(encoding="utf-8") + assert f'_parse_precision(form.get("{field}"))' in source, ( + f"{filename}: compute-precision field '{field}' must be parsed via _parse_precision " + f"(clamped), not _parse_int" + ) + assert f'_parse_int(form.get("{field}"))' not in source, ( + f"{filename}: '{field}' still parsed via unclamped _parse_int" + ) diff --git a/tests/test_auto_fit_removed.py b/tests/test_auto_fit_removed.py index b57fb090..6121cee8 100644 --- a/tests/test_auto_fit_removed.py +++ b/tests/test_auto_fit_removed.py @@ -311,16 +311,18 @@ def test_web_fitting_template_exposes_only_explicit_supported_choices(): 'value="pade"', 'value="power_limit"', 'value="custom"', + 'value="self_consistent"', 'value="comparison"', ): assert allowed in text assert 'name="fit_comparison_candidates"' in text - # The exact six-model Task 1 set applies to desktop. The current web - # flow has no self-consistent/implicit input fields, so it exposes only - # the supported explicit subset and does not pretend to route it. - assert 'value="self_consistent"' not in text + # Task B4: the web flow now offers self-consistent/implicit fitting (mirroring + # desktop), wired via fitting.FitRunner() with a dedicated implicit-model field + # block (fit_implicit_equation / _variable / _output / _params). It routes + # through the same explicit-choice `