From ccabcba4391a55f2574a884f47eea9db7f621dc9 Mon Sep 17 00:00:00 2001
From: Daniel Mulholland Open from View → Panels → Data Tables. It edits the model as
spreadsheets, one tab per element type or per section of the RAPTOR Studio Reference Manual
Data Tables
.rbds
file. Only some cells are editable; invalid values revert on entry.
-Blocks, Nodes and Events show the hierarchy level currently on the
-canvas; the header says “current level: 8 of 17 blocks…” when the model
-has nested levels. Use Model Explorer to reach the others.
The Whole model box in the bar above the tabs shows the blocks, nodes
+and events of every level in the same grids, with an extra Level
+column (Home, Pump Bank, Pump Bank / Stage 2). The header reads
+“Whole model (14 blocks across 4 levels)”. Nothing is drawn on the canvas
+for the other levels, so it stays quick on large models. Rows on the level
+that is open on the canvas stay editable; rows of other levels are
+read-only (greyed, with a tooltip) because their edits would act on
+elements that are not on screen. Right-click such a row and choose Go to
+level to open its level and select it on the canvas. The setting lasts
+for the session and is not saved in the model. Sorting, filtering and CSV
+export work in both modes, and the Level column is exported only while it
+is shown.
Tables with more than 10 rows have a filter row under the column headings. +It is hidden until you ask for it: press Ctrl+F while the table has the +focus, or right-click the column headings and choose Filter. The +Results tables (Capacity, Weak link, Cost, Importance, the per-element +table of the analytical results) and the Data Tables (Blocks, Nodes, +Events, Sparing, Preventive Maintenance) have it. Short tables and the +event log (which has its own filter box) do not.
+Type in the box above a column to keep only the rows that match that +column; boxes in several columns must all match. Filtering hides rows +rather than copying data, so it is fast on large tables, and it waits a +moment (about 0.15 s) after you stop typing before it runs. A badge shows +“Showing N of M”. A box that cannot be understood turns red and the table +is left unfiltered. Clear empties every box, and closing the filter row +clears the filter.
+| Type | +Matches | +
|---|---|
|
+cells containing “pump”, ignoring case |
+
|
+a phrase, spaces included |
+
|
+wildcards: |
+
|
+the whole cell equals the text ( |
+
|
+does not match: cells that do not contain “pump” |
+
|
+number comparisons |
+
|
+a number from 5 to 10, inclusive |
+
|
+empty, or non-empty, cells |
+
Separate terms with a space for AND (all must match) and with | or ,
+for OR, which binds loosest: >5 <10 | blank means “(more than 5 and less
+than 10) or empty”. A number term compares the cell’s raw value; where a
+cell only has text, the number is read from it, ignoring thousands commas,
+a %, a leading > (a censored result) and a trailing unit such as
+“hours”. A cell with no number never matches a number term.
In the Data Tables the filter reads the model’s own values (names, +distribution names, parameters, means), not the cell editors, so rows that +are filtered out never build their editors. In the Sparing table only +Name, ID and Sparing Type can be filtered. Export CSV writes only the rows +the filter shows (the dialog says “only N of M rows”). Editing a cell so +that its row stops matching does not hide the row from under your cursor; +it goes at the next filter pass.
+Select several rows (click row headings with Ctrl or Shift, or select +a filtered set), then edit one cell in a selected row: the change is +applied to that column on every selected row, in one undo step. While +more than one row is selected the bar says “N rows selected. Editing a +cell will change all N.” and the status bar reports “Set <column> to +<value> on N rows” afterwards. Editing a cell in a row that is not selected +changes that row only. The first time in a session you are asked “Apply to +N rows?” (with a Don’t ask again box, which lasts until you quit).
+Only rows that are shown are changed: rows hidden by a filter are never +touched, and in Whole model only rows of the open level are (others are +skipped and counted).
+Changing a distribution gives each row that distribution, re-seeding the +parameters where the old ones no longer fit; a Param k edit skips rows +whose distribution has no k-th parameter and says so.
+Read-only and computed columns (means, standard deviations, # Params) +and names are never multi-edited.
+All the rows are checked together by the input rules (see +[input-rules]): if one value is refused the whole change is refused +and every row reverts.
+Ctrl+D (Fill Down) copies the top selected row’s value in the current +column to the other selected rows. Pasting a single value with Ctrl+V +while the grid (not a cell editor) has the focus fills that column of +all selected rows. In a cell editor Ctrl+V pastes text as usual.
+Copy and Cut are greyed out until something is selected, and Paste is +greyed out unless there is something Raptor can paste. Copying puts the +selection (elements, the links between them, annotations, hierarchies with +their contents) on the system clipboard as well as in the window, so you +can paste into another model open in the same window or into another +copy of RAPTOR Studio. Pasted elements get new ids and keep the +dependencies between the elements copied together (remapped to the +copies); a dependency on an element that was not copied is dropped. Into a +different model, a block keeps a spare pool, resource or PM trigger only +if the target model also defines one of that name; otherwise the status +bar names what was dropped. Pasting other text into the diagram does +nothing. While you are editing text (an annotation, a rename box, a table +cell) Cut, Copy and Paste work on that text, and pasting a RAPTOR clip +there inserts its plain-text summary.
+With an Add tool on, point at a link instead of empty canvas:
@@ -497,7 +671,20 @@Validation warns (it never blocks) when elements on one level are drawn on +top of each other so that the topology cannot be read: blocks, nodes, +events and hierarchies all count, including a node on a block. The warning +names the element drawn last, which is the one on top, and what it +covers, for example “Node 'N3' is drawn on top of Block 'Pump_A'”. Move +one of them apart, or run Auto Layout.
+RAPTOR Studio applies Raptor 7’s own input checks, with Raptor 7’s own
diff --git a/raptor/gui/ui/analytic_dialog.py b/raptor/gui/ui/analytic_dialog.py
index 092f129e..20b5a420 100644
--- a/raptor/gui/ui/analytic_dialog.py
+++ b/raptor/gui/ui/analytic_dialog.py
@@ -628,6 +628,7 @@ def _build_settings(self) -> QGroupBox: # noqa: PLR0915
form.addRow("Hierarchy:", self._hierarchy)
limits = QHBoxLayout()
+ limits.setSpacing(SPACING["sm"])
limits.setContentsMargins(0, 0, 0, 0)
self._max_variables = QSpinBox()
self._max_variables.setRange(1, 1_000_000)
diff --git a/raptor/gui/ui/batch_dialogs.py b/raptor/gui/ui/batch_dialogs.py
index 356cdfa4..6aae0e02 100644
--- a/raptor/gui/ui/batch_dialogs.py
+++ b/raptor/gui/ui/batch_dialogs.py
@@ -341,6 +341,7 @@ def __init__(self, win):
close = QPushButton("Close")
close.clicked.connect(self.close)
self._buttons = QHBoxLayout()
+ self._buttons.setSpacing(8)
self._buttons.addWidget(self._export_btn)
self._buttons.addStretch(1)
self._buttons.addWidget(self._run_btn)
diff --git a/raptor/gui/ui/data_tables.py b/raptor/gui/ui/data_tables.py
index 4c77d592..f54469b9 100644
--- a/raptor/gui/ui/data_tables.py
+++ b/raptor/gui/ui/data_tables.py
@@ -13,6 +13,8 @@
"""
from __future__ import annotations
+from typing import Any
+
from raptor.gui.ui.qtcompat import req
from raptor.gui.ui import msgbox
@@ -22,7 +24,7 @@
from PySide6.QtWidgets import (
QApplication,
QWidget, QVBoxLayout, QTabWidget, QTableWidget, QTableWidgetItem,
- QComboBox, QLabel, QHeaderView, QHBoxLayout, QPushButton,
+ QCheckBox, QComboBox, QLabel, QHeaderView, QHBoxLayout, QPushButton,
)
from raptor.gui.ui import filedialogs as fdg
@@ -34,6 +36,7 @@
DISTRO_PARAM_LABELS, validate_distro_params,
)
from raptor.core.distro_rules import DISTRO_NAMES
+from raptor.core.fileio.rbdlib import DIST_NPARAMS as DIST_NPARAMS_COUNT
from raptor.core.engine.diststats import compute_stats
@@ -67,6 +70,19 @@ class _DataTable(QTableWidget):
those headers as non-sortable.
"""
+ #: Set by the dock: called with the clipboard's text when Ctrl+V is
+ #: pressed on the grid itself (not in a cell's editor). True = handled.
+ paste_text = None
+
+ def keyPressEvent(self, event) -> None:
+ from PySide6.QtGui import QKeySequence
+ if (self.paste_text is not None
+ and event.matches(QKeySequence.StandardKey.Paste)):
+ if self.paste_text(QApplication.clipboard().text()):
+ event.accept()
+ return
+ super().keyPressEvent(event)
+
def sortItems(self, column: int, order: Qt.SortOrder) -> None:
if any(self.cellWidget(row, column) is not None
for row in range(self.rowCount())):
@@ -102,6 +118,77 @@ def _is_time_param(label: str) -> bool:
"Shape", "Shape2", "N Trials", "Probability")
+class _WholeModel:
+ """Every level's blocks, nodes and events, read from the file's rows.
+
+ The view's own `blocks`/`nodes`/`events` hold the level on screen only.
+ Here the level on screen keeps its live objects (they are the ones the
+ edit paths change) and every other level is built from its rows. Nothing
+ is built on the canvas for another level. `level` names where an element
+ lives ("Home", "Pump Bank", "Pump Bank / Stage 2")."""
+
+ def __init__(self, view) -> None:
+ from raptor.core.fileio.typed import (
+ block_from_row, event_from_row, hier_from_row, node_from_row)
+ rbd = view._rbd_ref
+ self.live_blocks = set(view.blocks)
+ self.live_nodes = set(view.nodes)
+ self.live_events = set(view.events)
+ self.blocks: dict = {}
+ self.nodes: dict = {}
+ self.events: dict = {}
+ self._parent: dict[tuple[str, int], int] = {}
+ hiers: dict[int, tuple[str, int]] = {}
+ sec = rbd.sections.get("HIERARCHY_DATA")
+ for row in (sec.rows if sec is not None else []):
+ try:
+ h = hier_from_row(row)
+ except (ValueError, KeyError):
+ continue
+ if h.id:
+ hiers[h.id] = (h.name, h.parent_id)
+ self.level_count = 1 + len(hiers)
+ names: dict[int, str] = {0: "Home"}
+
+ def level_name(pid: int) -> str:
+ if pid in names:
+ return names[pid]
+ chain, seen, cur = [], set(), pid
+ while cur and cur in hiers and cur not in seen:
+ seen.add(cur)
+ chain.append(hiers[cur][0])
+ cur = hiers[cur][1]
+ names[pid] = " / ".join(reversed(chain)) or "Home"
+ return names[pid]
+ self._level_name = level_name
+ for kind, section, build, live, store in (
+ ("block", "BLOCK_DATA", block_from_row, view.blocks,
+ self.blocks),
+ ("node", "NODE_DATA", node_from_row, view.nodes,
+ self.nodes),
+ ("event", "EVENT_DATA", event_from_row, view.events,
+ self.events)):
+ sec = rbd.sections.get(section)
+ for row in (sec.rows if sec is not None else []):
+ try:
+ obj = build(row)
+ except (ValueError, KeyError):
+ continue
+ if obj.id == 0 and kind != "node":
+ continue # the column-template row
+ if kind == "node" and obj.id == 0:
+ continue
+ store[obj.id] = live.get(obj.id, obj)
+ self._parent[(kind, obj.id)] = obj.parent_id
+
+ def level(self, kind: str, oid: int) -> str:
+ return self._level_name(self._parent.get((kind, oid), 0))
+
+ def is_live(self, kind: str, oid: int) -> bool:
+ return oid in {"block": self.live_blocks, "node": self.live_nodes,
+ "event": self.live_events}[kind]
+
+
class DataTablesDock(QWidget):
#: Emitted after ANY committed edit so MainWindow can mark dirty.
model_edited = Signal()
@@ -109,6 +196,11 @@ class DataTablesDock(QWidget):
#: Same shape as results.py's element_clicked; MainWindow wires both
#: to the one DiagramView.locate_element-backed handler.
element_locate_requested = Signal(str, str)
+ #: The same, by id and kind: used in Whole model, where two levels can
+ #: hold elements of one name.
+ element_locate_id_requested = Signal(str, int)
+ #: A line for the window's status bar ("Set ... on 12 rows").
+ status_message = Signal(str)
#: A committed cell failed distribution-parameter validation and was
#: reverted. Shares its name with `InspectorDock.validation_failed`,
#: whose handler MainWindow also connects this one to.
@@ -123,7 +215,7 @@ class DataTablesDock(QWidget):
"Repair Param 2", "Repair Param 3",
"# Repair Params", "PM Frequency",
"Failure Mean", "Failure Std Dev",
- "Repair Mean", "Repair Std Dev"]
+ "Repair Mean", "Repair Std Dev", "Level"]
#: Column header tooltips -- plain-English meaning, since "Failure
#: Param 1" vs "Param1" vs "Param2" says nothing on its own. Per-cell
@@ -181,7 +273,7 @@ class DataTablesDock(QWidget):
#: Raptor 7's "Block Input Tables" -- Sparing and Preventive
#: Maintenance tabs.
_SPARING_COLS = ["Name", "ID", "Sparing Type", "Pool Name",
- "Resource", "New Spares", "Order Interval"]
+ "Resource", "New Spares", "Order Interval", "Level"]
_SPARING_COL_TIPS = {
"Sparing Type": "How this block gets replacement units: "
"Infinite/Custom/Pooled/ColdPool/Resource",
@@ -199,7 +291,7 @@ class DataTablesDock(QWidget):
#: `block.uses_pm`.
_PM_COLS = ["Name", "ID", "Frequency", "Needs spare", "Refresh",
"Defer (non-mission)", "Defer (unavailable)",
- "Failure resets"]
+ "Failure resets", "Level"]
_PM_COL_TIPS = {
"Frequency": "How often (model time units) this block "
"undergoes Preventive Maintenance",
@@ -276,6 +368,11 @@ class DataTablesDock(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._view = None
+ #: "Whole model": every level's rows, not just the one on screen.
+ #: Kept for the session (it is a view of the model, not part of it).
+ self._whole = False
+ self._multi_confirmed = False # "Don't ask again": session only
+ self._whole_src: _WholeModel | None = None
#: A refresh was requested while this dock was hidden. The grids are
#: expensive for large models (one widget per editable cell), so a
#: hidden Data Tables tab is filled when it is first shown instead
@@ -291,12 +388,12 @@ def __init__(self, parent=None):
self._blocks_table = self._make_table(
self._BLOCK_COLS, self._BLOCK_COL_TIPS)
self._nodes_table = self._make_table(
- ["Name", "ID", "Type", "Required good (k)", "Out"],
+ ["Name", "ID", "Type", "Required good (k)", "Out", "Level"],
self._NODE_COL_TIPS,
# wider than default: "Required good (k)" was clipping
wide_cols={"Required good (k)": 150})
self._events_table = self._make_table(
- ["Name", "ID", "Distro", "Param", "Stream"],
+ ["Name", "ID", "Distro", "Param", "Stream", "Level"],
self._EVENT_COL_TIPS)
# Name-keyed sections (no ID column): pools and triggers.
self._spares_table = self._make_table(
@@ -322,6 +419,15 @@ def __init__(self, parent=None):
self._nodes_table.itemChanged.connect(self._on_node_name_changed)
self._events_table.itemChanged.connect(self._on_event_name_changed)
self._pm_table.itemChanged.connect(self._on_pm_cell_changed)
+ self._apply_level_column()
+ from PySide6.QtGui import QKeySequence, QShortcut
+ fill = QShortcut(QKeySequence("Ctrl+D"), self)
+ fill.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
+ fill.activated.connect(self.fill_down)
+ for _t, _k in self._level_tables():
+ _t.paste_text = self.paste_into_selection
+ _t.selectionModel().selectionChanged.connect(self._update_banner)
+ self._tabs.currentChanged.connect(self._update_banner)
self._tabs.addTab(self._blocks_table, "Blocks")
self._tabs.addTab(self._nodes_table, "Nodes")
self._tabs.addTab(self._events_table, "Events")
@@ -413,6 +519,7 @@ def __init__(self, parent=None):
bar = QHBoxLayout()
bar.setContentsMargins(SPACING["sm"], SPACING["xs"],
SPACING["sm"], SPACING["xs"])
+ bar.setSpacing(SPACING["sm"]) # the buttons used to touch
hint = QLabel("Edits apply to the model immediately "
"(Save to persist). Invalid values revert.")
hint.setObjectName("ResultsPlaceholder")
@@ -426,6 +533,23 @@ def __init__(self, parent=None):
bar.addWidget(self._scope_label)
refresh = QPushButton("Refresh")
refresh.clicked.connect(self.refresh)
+ self._whole_box = QCheckBox("Whole model")
+ self._whole_box.setToolTip(
+ "Show the blocks, nodes and events of every hierarchy level, "
+ "with a Level column. Rows on the open level stay editable; "
+ "rows of other levels are read-only (right-click \u25b8 Go to "
+ "level).")
+ self._whole_box.toggled.connect(self._on_whole_toggled)
+ bar.addSpacing(SPACING["md"])
+ bar.addWidget(self._whole_box)
+ self._banner = QLabel("")
+ self._banner.setObjectName("MultiEditBanner")
+ self._banner.setStyleSheet(
+ "QLabel#MultiEditBanner { background: #fff3c4; color: #5c4400;"
+ " border: 1px solid #e0c060; border-radius: 3px;"
+ " padding: 1px 8px; }")
+ self._banner.hide()
+ bar.addWidget(self._banner)
bar.addStretch(1)
self._add_row_btn = QPushButton("Add Row")
self._add_row_btn.setVisible(False)
@@ -461,7 +585,7 @@ def __init__(self, parent=None):
@staticmethod
def _make_table(headers: list[str], tips: dict[str, str] | None = None,
- wide_cols: dict[str, int] | None = None) -> QTableWidget:
+ wide_cols: dict[str, int] | None = None) -> _DataTable:
t = _DataTable(0, len(headers))
t.setHorizontalHeaderLabels(headers)
t.verticalHeader().setVisible(False)
@@ -770,9 +894,10 @@ def _large_model(self) -> bool:
if view is None:
return False
try:
- return (len(getattr(view, "blocks", {}) or {})
- + len(getattr(view, "nodes", {}) or {})
- + len(getattr(view, "events", {}) or {})) > 400
+ src = self._src()
+ return (len(getattr(src, "blocks", {}) or {})
+ + len(getattr(src, "nodes", {}) or {})
+ + len(getattr(src, "events", {}) or {})) > 400
except TypeError:
return False
@@ -791,6 +916,9 @@ def _populate_table(self, table: QTableWidget) -> None:
self._populate_pm()
elif table in self._keyed:
self._populate_keyed(table)
+ for t, kind in self._level_tables():
+ if t is table:
+ self._lock_foreign_rows(t, kind)
def _update_empty_state(self, table: QTableWidget) -> None:
"""Explain an empty grid instead of showing a blank pane."""
@@ -839,6 +967,250 @@ def eventFilter(self, watched, event):
break
return super().eventFilter(watched, event)
+ def _src(self) -> Any:
+ """Where the Blocks/Nodes/Events/Sparing/PM grids read their
+ elements from: the view's level, or (Whole model) every level."""
+ view = self._view
+ if view is None or not self._whole:
+ return view
+ src = self._whole_src
+ if src is None:
+ src = self._whole_src = _WholeModel(view)
+ return src
+
+ def _level_tables(self) -> list[tuple[QTableWidget, str]]:
+ return [(self._blocks_table, "block"), (self._nodes_table, "node"),
+ (self._events_table, "event"),
+ (self._sparing_table, "block"), (self._pm_table, "block")]
+
+ def _apply_level_column(self) -> None:
+ """The Level column exists only in Whole model."""
+ for t, _kind in self._level_tables():
+ t.setColumnHidden(t.columnCount() - 1, not self._whole)
+
+ def _level_item(self, kind: str, oid: int) -> QTableWidgetItem:
+ src = self._src()
+ text = src.level(kind, oid) if isinstance(src, _WholeModel) else ""
+ item = _SortItem(text)
+ item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
+ return item
+
+ def _is_foreign(self, kind: str, oid: int | None) -> bool:
+ """A row of another level (Whole model): shown, not editable."""
+ src = self._src()
+ return (isinstance(src, _WholeModel) and oid is not None
+ and not src.is_live(kind, oid))
+
+ _FOREIGN_TIP = ("On another level: read-only here. Right-click and "
+ "choose Go to level to edit it on the canvas.")
+
+ def _lock_row(self, t: QTableWidget, r: int) -> None:
+ """Make one row read-only (it belongs to a level that is not open)."""
+ for c in range(t.columnCount()):
+ item = t.item(r, c)
+ if item is not None:
+ item.setFlags(item.flags() & ~(
+ Qt.ItemFlag.ItemIsEditable
+ | Qt.ItemFlag.ItemIsUserCheckable))
+ w = t.cellWidget(r, c)
+ if w is not None:
+ w.setEnabled(False)
+ name = t.item(r, 0)
+ if name is not None:
+ name.setToolTip(self._FOREIGN_TIP)
+
+ def _lock_foreign_rows(self, t: QTableWidget, kind: str) -> None:
+ if not self._whole:
+ return
+ for r in range(t.rowCount()):
+ if self._is_foreign(kind, self._row_key(t, r)):
+ self._lock_row(t, r)
+
+ # ------------------------------------------------------------------
+ # Multi-row column edit: select rows, edit one cell, change them all
+ # ------------------------------------------------------------------
+
+ _GRID_KIND = {"_blocks_table": "block", "_nodes_table": "node",
+ "_events_table": "event", "_sparing_table": "block",
+ "_pm_table": "block"}
+
+ def _edit_table(self) -> QTableWidget:
+ """The grid an edit came from: the tab on screen."""
+ cur = self._tabs.currentWidget()
+ if isinstance(cur, QTableWidget) and cur in dict(self._level_tables()):
+ return cur
+ return self._blocks_table
+
+ def _kind_of(self, table: QTableWidget) -> str:
+ return dict(self._level_tables()).get(table, "block")
+
+ def _selected_row_keys(self, table: QTableWidget) -> list[int]:
+ """The ids of the selected rows that are shown (a filtered-out row is
+ never touched), in row order."""
+ sm = table.selectionModel()
+ if sm is None:
+ return []
+ keys: list[int] = []
+ for r in sorted({i.row() for i in sm.selectedIndexes()}):
+ if table.isRowHidden(r):
+ continue
+ k = self._row_key(table, r)
+ if k is not None and k not in keys:
+ keys.append(k)
+ return keys
+
+ def _targets(self, table: QTableWidget, oid: int) -> list[int]:
+ """The rows an edit of row `oid` applies to: only itself, unless it is
+ one of several selected rows."""
+ keys = self._selected_row_keys(table)
+ if len(keys) < 2 or oid not in keys:
+ return [oid]
+ return keys
+
+ def _update_banner(self, *_a: object) -> None:
+ if getattr(self, "_banner", None) is None:
+ return # the bar is built after the grids
+ table = self._tabs.currentWidget()
+ n = (len(self._selected_row_keys(table))
+ if isinstance(table, QTableWidget)
+ and table in dict(self._level_tables()) else 0)
+ self._banner.setVisible(n > 1)
+ if n > 1:
+ self._banner.setText(
+ f"{n} rows selected. Editing a cell will change all {n}.")
+
+ def _confirm_multi(self, n: int) -> bool:
+ if self._multi_confirmed:
+ return True
+ from raptor.gui.ui import dialogs
+ ok, again = dialogs.ask_apply_to_rows(self, n)
+ if ok and again:
+ self._multi_confirmed = True # this session only
+ return ok
+
+ def _apply_rows(self, oid: int, what: str, value: str, fn,
+ applicable=None) -> bool:
+ """Run the single-row commit `fn(id)` for row `oid`, or for every
+ selected row when `oid` is one of several: one undo step and one
+ rule-gate pass for the lot (refused as a whole, every row reverts).
+ `applicable(id)` says whether a row has the column at all."""
+ table = self._edit_table()
+ kind = self._kind_of(table)
+ keys = self._targets(table, oid)
+ if len(keys) == 1:
+ return bool(fn(oid))
+ todo, read_only, na = [], 0, 0
+ for k in keys:
+ if self._is_foreign(kind, k):
+ read_only += 1
+ elif applicable is not None and not applicable(k):
+ na += 1
+ else:
+ todo.append(k)
+ if not todo:
+ self.status_message.emit(
+ f"Nothing to set: {len(keys)} rows selected, none can take "
+ f"a {what}.")
+ return False
+ if not self._confirm_multi(len(todo)):
+ self._refresh_soon() # put the typed value back
+ return False
+ stack = req(self._view)._stack
+ before = stack.count()
+ stack.beginMacro(f"Set {what} on {len(todo)} rows")
+ results: list[bool] = []
+ try:
+ for k in todo:
+ results.append(bool(fn(k)))
+ finally:
+ stack.endMacro()
+ changed = any(results)
+ if changed and stack.count() == before:
+ self._refresh_soon() # refused: rows revert
+ return False
+ if changed:
+ note = f"Set {what} to {value} on {sum(results)} rows"
+ if read_only:
+ note += f"; {read_only} read-only row(s) on other levels skipped"
+ if na:
+ note += f"; {na} without that parameter skipped"
+ self.status_message.emit(note)
+ return results[todo.index(oid)] if oid in todo else changed
+
+ def _refresh_soon(self) -> None:
+ from shiboken6 import isValid
+
+ def go() -> None:
+ if isValid(self):
+ self.refresh()
+ QTimer.singleShot(0, go)
+
+ def fill_down(self) -> bool:
+ """Ctrl+D: the top selected row's value in the current column goes to
+ the other selected rows."""
+ table = self._edit_table()
+ col = table.currentColumn()
+ rows = sorted({i.row() for i in table.selectionModel().selectedIndexes()
+ if not table.isRowHidden(i.row())})
+ if col < 0 or len(rows) < 2:
+ return False
+ w = table.cellWidget(rows[0], col)
+ from PySide6.QtWidgets import QLineEdit
+ edit = w.findChild(QLineEdit) if w is not None else None
+ if edit is not None:
+ text = edit.text()
+ elif isinstance(w, QComboBox):
+ return self._fill_combo(table, rows, col, w)
+ else:
+ item = table.item(rows[0], col)
+ text = item.text() if item is not None else ""
+ return self._fill_text(table, rows, col, text, start=1)
+
+ def _fill_text(self, table: QTableWidget, rows: list[int], col: int,
+ text: str, start: int = 0) -> bool:
+ """Type `text` into column `col` of one of `rows` (the first from
+ `start`): its commit fans out to every selected row."""
+ from PySide6.QtWidgets import QLineEdit
+ done = False
+ for r in rows[start:]:
+ w = table.cellWidget(r, col)
+ if w is None:
+ continue
+ e = w.findChild(QLineEdit)
+ if e is None or not e.isEnabled():
+ continue
+ e.setText(text)
+ e.editingFinished.emit() # the row's own commit path
+ done = True
+ break # the first commit fans out to every selected row
+ return done
+
+ def _fill_combo(self, table: QTableWidget, rows: list[int], col: int,
+ combo: QComboBox) -> bool:
+ target = table.cellWidget(rows[1], col)
+ if not isinstance(target, QComboBox) or not target.isEnabled():
+ return False
+ target.setCurrentIndex(combo.currentIndex())
+ return True
+
+ def paste_into_selection(self, text: str) -> bool:
+ """Paste of one plain value with several rows selected: it goes to
+ the current column of every selected row."""
+ table = self._edit_table()
+ sm = table.selectionModel()
+ rows = sorted({i.row() for i in sm.selectedIndexes()
+ if not table.isRowHidden(i.row())}) if sm else []
+ text = text.strip()
+ if len(rows) < 2 or "\n" in text or "\t" in text or not text:
+ return False
+ return self._fill_text(table, rows, table.currentColumn(), text)
+
+ def _on_whole_toggled(self, on: bool) -> None:
+ self._whole = bool(on)
+ self._whole_src = None
+ self._apply_level_column()
+ self.refresh(force=True)
+
def _update_scope_note(self) -> None:
"""Say when the Blocks/Nodes/Events grids show one level only."""
view = self._view
@@ -857,6 +1229,13 @@ def total(section: str, skip_default: bool = False) -> int:
if str(r.get("ID", "")).strip() not in
("0", "0.0", "0.000000"))
+ if self._whole:
+ src = self._src()
+ self._scope_label.setText(
+ f"· Whole model ({len(src.blocks)} blocks across "
+ f"{src.level_count} levels)")
+ self._scope_label.show()
+ return
cur_b = len(getattr(view, "blocks", {}) or {})
cur_n = len(getattr(view, "nodes", {}) or {})
cur_e = len(getattr(view, "events", {}) or {})
@@ -1026,7 +1405,7 @@ def _block_cell(self, t: QTableWidget, row: int, col: int,
bid: int | None) -> tuple[str, float | None]:
"""What the Blocks grid shows in a cell, from the model (the widget
columns) or the cell's item."""
- b = self._view.blocks.get(bid) if self._view is not None \
+ b = self._src().blocks.get(bid) if self._view is not None \
and bid is not None else None
if b is not None and col in (2, 7):
code = b.fail_distro if col == 2 else b.repair_distro
@@ -1062,7 +1441,7 @@ def _sparing_matcher(self, row: int, filters) -> bool:
from raptor.core.fileio.typed import SPARING_TYPE_TO_FILE_STRING
t = self._sparing_table
bid = self._row_key(t, row)
- b = self._view.blocks.get(bid) if self._view is not None \
+ b = self._src().blocks.get(bid) if self._view is not None \
and bid is not None else None
for col, f in filters:
if col == 2 and b is not None:
@@ -1139,6 +1518,8 @@ def _realize_rows(self, t: QTableWidget, rows) -> None:
for r, key in todo:
pending.discard(key)
build(t, r, key)
+ if self._whole and self._is_foreign("block", key):
+ self._lock_row(t, r)
finally:
t.setUpdatesEnabled(True)
@@ -1161,10 +1542,14 @@ def stat_text(val):
if display_u else val
return _fmt_stat(shown)
- bids = sorted(self._view.blocks)
+ src = self._src()
+ bids = sorted(src.blocks)
t.setRowCount(len(bids))
for r, bid in enumerate(bids):
- b = self._view.blocks[bid]
+ b = src.blocks[bid]
+ if self._whole:
+ t.setItem(r, t.columnCount() - 1,
+ self._level_item("block", bid))
# ID first (read-only anchor used by commits).
id_item = _SortItem(str(bid))
id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
@@ -1195,7 +1580,7 @@ def stat_text(val):
def _build_block_widgets(self, t, r: int, bid: int) -> None:
"""The cell widgets of one Blocks row (distribution combos and
parameter fields), their hover text and their accessible names."""
- b = req(self._view).blocks.get(bid)
+ b = req(self._src()).blocks.get(bid)
if b is None:
return
self._distro_cell(t, r, 2, b.fail_distro,
@@ -1241,10 +1626,14 @@ def _populate_nodes(self):
return
type_names = {1: "Start", 2: "Regular", 3: "End",
4: "Hierarchy In", 5: "Hierarchy Out"}
- node_ids = sorted(self._view.nodes)
+ src = self._src()
+ node_ids = sorted(src.nodes)
t.setRowCount(len(node_ids))
for r, nid in enumerate(node_ids):
- n = self._view.nodes[nid]
+ n = src.nodes[nid]
+ if self._whole:
+ t.setItem(r, t.columnCount() - 1,
+ self._level_item("node", nid))
id_item = _SortItem(str(nid))
id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
t.setItem(r, 1, id_item)
@@ -1262,22 +1651,40 @@ def _populate_nodes(self):
def _goto_clicked_row(self, item: QTableWidgetItem, kind: str) -> None:
table = item.tableWidget()
name_item = table.item(item.row(), 0)
+ if self._whole:
+ return # a click selects a cell; Go to level is explicit
if name_item is not None and name_item.text():
self.element_locate_requested.emit(kind, name_item.text())
def _show_row_context_menu(self, table: QTableWidget, kind: str, pos) -> None:
+ menu = self._row_menu(table, kind, pos)
+ if menu is not None:
+ menu.exec(table.viewport().mapToGlobal(pos))
+
+ def _row_menu(self, table: QTableWidget, kind: str, pos):
+ """The right-click menu of the row under `pos` (None off the rows)."""
from PySide6.QtWidgets import QMenu
row = table.rowAt(pos.y())
if row < 0:
- return
+ return None
name_item = table.item(row, 0)
if name_item is None:
- return
+ return None
menu = QMenu(table)
- act = menu.addAction(f"Go to {name_item.text()} on canvas")
- act.triggered.connect(
- lambda: self.element_locate_requested.emit(kind, name_item.text()))
- menu.exec(table.viewport().mapToGlobal(pos))
+ oid = self._row_key(table, row)
+ if self._whole and oid is not None:
+ level = table.item(row, table.columnCount() - 1)
+ where = level.text() if level is not None else "its level"
+ act = menu.addAction(
+ f"Go to level: {where} \u25b8 {name_item.text()}")
+ act.triggered.connect(
+ lambda: self.element_locate_id_requested.emit(kind, oid))
+ else:
+ act = menu.addAction(f"Go to {name_item.text()} on canvas")
+ act.triggered.connect(
+ lambda: self.element_locate_requested.emit(
+ kind, name_item.text()))
+ return menu
@staticmethod
def _set_cell_tip(t: QTableWidget, row: int, col: int, text: str) -> None:
@@ -1415,17 +1822,23 @@ def _populate_events(self):
return
from PySide6.QtWidgets import QHBoxLayout, QLineEdit
from PySide6.QtGui import QIntValidator
- event_ids = sorted(self._view.events)
+ src = self._src()
+ event_ids = sorted(src.events)
t.setRowCount(len(event_ids))
for r, eid in enumerate(event_ids):
- event = self._view.events[eid]
+ event = src.events[eid]
+ if self._whole:
+ t.setItem(r, t.columnCount() - 1,
+ self._level_item("event", eid))
id_item = _SortItem(str(eid))
id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
t.setItem(r, 1, id_item)
t.setItem(r, 0, _SortItem(event.name))
self._distro_cell(t, r, 2, event.fail_distro,
- lambda code, eid=eid:
- self._view.set_event_field(eid, "dist", code),
+ lambda code, eid=eid: self._apply_rows(
+ eid, "distribution", str(code),
+ lambda k: self._view.set_event_field(
+ k, "dist", code)),
kind="event")
# Single param cell (EVENT_DATA.failVal).
w = QWidget()
@@ -1445,7 +1858,10 @@ def commit_param(edit=edit, committed=committed, eid=eid):
edit.setText(f"{committed['v']:.6g}")
return
if new_v != committed["v"]:
- req(self._view).set_event_field(eid, "param", new_v)
+ self._apply_rows(
+ eid, "Param", f"{new_v:g}",
+ lambda k: req(self._view).set_event_field(
+ k, "param", new_v) or True)
committed["v"] = new_v
self.model_edited.emit()
@@ -1469,7 +1885,10 @@ def commit_stream(edits=edits, committed_s=committed_s, eid=eid):
edits.setText(str(committed_s["v"]))
return
if new_v != committed_s["v"]:
- req(self._view).set_event_field(eid, "stream", new_v)
+ self._apply_rows(
+ eid, "Stream", str(new_v),
+ lambda k: req(self._view).set_event_field(
+ k, "stream", new_v) or True)
committed_s["v"] = new_v
self.model_edited.emit()
@@ -1519,10 +1938,14 @@ def _populate_sparing(self):
if self._view is None:
t.setUpdatesEnabled(True)
return
- bids = sorted(self._view.blocks)
+ src = self._src()
+ bids = sorted(src.blocks)
t.setRowCount(len(bids))
for r, bid in enumerate(bids):
- b = self._view.blocks[bid]
+ b = src.blocks[bid]
+ if self._whole:
+ t.setItem(r, t.columnCount() - 1,
+ self._level_item("block", bid))
name_item = _SortItem(b.name)
name_item.setFlags(name_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
t.setItem(r, 0, name_item)
@@ -1535,7 +1958,7 @@ def _populate_sparing(self):
def _build_sparing_widgets(self, t, r: int, bid: int) -> None:
"""The cell widgets of one Sparing row, their hover text and names."""
- b = req(self._view).blocks.get(bid)
+ b = req(self._src()).blocks.get(bid)
if b is None:
return
from PySide6.QtWidgets import QLineEdit
@@ -1560,8 +1983,10 @@ def _build_sparing_widgets(self, t, r: int, bid: int) -> None:
combo.setCurrentIndex(max(idx, 0))
def commit_sparing(file_string, bid=bid, cmb=combo):
- req(self._view).set_block_sparing_type(
- bid, _SPARING_TYPE_BY_NAME[file_string].name)
+ self._apply_rows(
+ bid, "Sparing Type", file_string,
+ lambda k: req(self._view).set_block_sparing_type(
+ k, _SPARING_TYPE_BY_NAME[file_string].name) or True)
self.model_edited.emit()
combo.currentIndexChanged.connect(
lambda _i, cmb=combo, cb=commit_sparing:
@@ -1570,12 +1995,16 @@ def commit_sparing(file_string, bid=bid, cmb=combo):
self._text_cell(
t, r, 3, b.pool_name,
- lambda text, bid=bid: (
- req(self._view).set_block_pool_name(bid, text) or True))
+ lambda text, bid=bid: self._apply_rows(
+ bid, "Pool Name", text,
+ lambda k: req(self._view).set_block_pool_name(k, text)
+ or True))
self._text_cell(
t, r, 4, b.resource_pool_name,
- lambda text, bid=bid: (
- req(self._view).set_block_resource_pool(bid, text) or True))
+ lambda text, bid=bid: self._apply_rows(
+ bid, "Resource", text,
+ lambda k: req(self._view).set_block_resource_pool(k, text)
+ or True))
def commit_new_spares(bid=bid, row=r):
cw_c = t.cellWidget(row, 5)
@@ -1621,11 +2050,15 @@ def _populate_pm(self):
t.setUpdatesEnabled(True)
t.blockSignals(False)
return
- bids = [bid for bid in sorted(self._view.blocks)
- if self._view.blocks[bid].uses_pm]
+ src = self._src()
+ bids = [bid for bid in sorted(src.blocks)
+ if src.blocks[bid].uses_pm]
t.setRowCount(len(bids))
for r, bid in enumerate(bids):
- b = self._view.blocks[bid]
+ b = src.blocks[bid]
+ if self._whole:
+ t.setItem(r, t.columnCount() - 1,
+ self._level_item("block", bid))
name_item = _SortItem(b.name)
name_item.setFlags(name_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
t.setItem(r, 0, name_item)
@@ -1658,7 +2091,11 @@ def _on_pm_cell_changed(self, item: QTableWidgetItem):
return
bid, key = payload
on = item.checkState() == Qt.CheckState.Checked
- self._view.set_block_pm_option(bid, key, on)
+ self._apply_rows(
+ bid, req(req(item.tableWidget()).horizontalHeaderItem(
+ item.column())).text(),
+ "on" if on else "off",
+ lambda k: self._view.set_block_pm_option(k, key, on) or True)
self.model_edited.emit()
# ------------------------------------------------------------------
@@ -2053,6 +2490,8 @@ def _on_event_name_changed(self, item: QTableWidgetItem):
if item.column() != 0 or self._view is None:
return
eid = int(req(self._events_table.item(item.row(), 1)).text())
+ if eid not in self._view.events:
+ return
new_name = "_".join(item.text().split()) or \
self._view.events[eid].name
if new_name != item.text():
@@ -2084,7 +2523,10 @@ def commit():
return
if new_v == committed["v"]:
return
- req(self._view).set_node_good_paths(nid, new_v)
+ self._apply_rows(
+ nid, "Required good (k)", str(new_v),
+ lambda k: req(self._view).set_node_good_paths(k, new_v)
+ or True)
committed["v"] = new_v
self.model_edited.emit()
@@ -2098,6 +2540,8 @@ def _on_block_name_changed(self, item: QTableWidgetItem):
if item.column() != 0 or self._view is None:
return
bid = int(req(self._blocks_table.item(item.row(), 1)).text())
+ if bid not in self._view.blocks:
+ return # another level's row (Whole model), read-only
new_name = "_".join(item.text().split()) or self._view.blocks[bid].name
if new_name != item.text():
item.setText(new_name) # re-enters this handler with the fixed text
@@ -2110,6 +2554,8 @@ def _on_node_name_changed(self, item: QTableWidgetItem):
if item.column() != 0 or self._view is None:
return
nid = int(req(self._nodes_table.item(item.row(), 1)).text())
+ if nid not in self._view.nodes:
+ return
new_name = "_".join(item.text().split()) or self._view.nodes[nid].node_name
if new_name != item.text():
item.setText(new_name)
@@ -2135,8 +2581,24 @@ def _entry_values(self, distro: int, values: list[float]) -> list[float]:
for j, v in enumerate(values)]
return param_entry.to_entry(distro, shown)
+ def _has_param(self, bid: int, which: str, index: int) -> bool:
+ """Whether block `bid`'s distribution has a `index`-th parameter."""
+ b = req(self._src()).blocks.get(bid)
+ if b is None:
+ return False
+ code = b.fail_distro if which == "F" else b.repair_distro
+ return index < DIST_NPARAMS_COUNT.get(code, 3)
+
def _set_param_entry(self, bid: int, which: str, index: int,
raw: float) -> bool:
+ use = "Failure" if which == "F" else "Repair"
+ return self._apply_rows(
+ bid, f"{use} Param {index + 1}", f"{raw:g}",
+ lambda k: self._set_param_entry_one(k, which, index, raw),
+ applicable=lambda k: self._has_param(k, which, index))
+
+ def _set_param_entry_one(self, bid: int, which: str, index: int,
+ raw: float) -> bool:
"""Commit one cell typed in Raptor's entry mode."""
block = req(self._view).blocks.get(bid)
if block is None:
@@ -2173,6 +2635,14 @@ def _set_param_entry(self, bid: int, which: str, index: int,
return True
def _set_param(self, bid: int, which: str, index: int, value: float) -> bool:
+ use = "Failure" if which == "F" else "Repair"
+ return self._apply_rows(
+ bid, f"{use} Param {index + 1}", f"{value:g}",
+ lambda k: self._set_param_one(k, which, index, value),
+ applicable=lambda k: self._has_param(k, which, index))
+
+ def _set_param_one(self, bid: int, which: str, index: int,
+ value: float) -> bool:
block = req(self._view).blocks.get(bid)
if block is None:
return False
@@ -2192,12 +2662,17 @@ def _set_param(self, bid: int, which: str, index: int, value: float) -> bool:
def _set_dist(self, bid: int, which: str, code: int) -> bool:
# Routed through the view so the change lands on the undo stack.
- ok = req(self._view).change_block_dist(bid, which, code)
+ use = "Failure Dist" if which == "F" else "Repair Dist"
+ ok = self._apply_rows(
+ bid, use, distro_display_name(code, is_failure=which == "F"),
+ lambda k: req(self._view).change_block_dist(k, which, code))
if ok:
self.model_edited.emit()
return ok
def _set_pm(self, bid: int, value: float) -> bool:
- req(self._view).set_block_pm_freq(bid, value)
+ self._apply_rows(
+ bid, "PM Frequency", f"{value:g}",
+ lambda k: req(self._view).set_block_pm_freq(k, value) or True)
self.model_edited.emit()
return True
diff --git a/raptor/gui/ui/dialogs.py b/raptor/gui/ui/dialogs.py
index 30d25937..00f026ad 100644
--- a/raptor/gui/ui/dialogs.py
+++ b/raptor/gui/ui/dialogs.py
@@ -25,6 +25,25 @@
from raptor.gui.ui.main_window import _TERMINATION_CHOICES
+def ask_apply_to_rows(parent, n: int) -> tuple[bool, bool]:
+ """The first multi-row edit of a session: 'Apply to N rows?' with a
+ 'Don't ask again' box (this session only). Returns (apply, dont_ask)."""
+ from PySide6.QtWidgets import QCheckBox, QStyle
+ box = QMessageBox(QMessageBox.Icon.Question, "Edit several rows",
+ f"Apply to {n} rows?", QMessageBox.StandardButton.NoButton,
+ parent)
+ style = box.style()
+ yes = box.addButton(f"Change {n} rows", QMessageBox.ButtonRole.YesRole)
+ yes.setIcon(style.standardIcon(QStyle.StandardPixmap.SP_DialogYesButton))
+ no = box.addButton("Cancel", QMessageBox.ButtonRole.NoRole)
+ no.setIcon(style.standardIcon(QStyle.StandardPixmap.SP_DialogNoButton))
+ again = QCheckBox("Don't ask again")
+ box.setCheckBox(again)
+ box.setDefaultButton(yes)
+ box.exec()
+ return box.clickedButton() is yes, again.isChecked()
+
+
def question_box(parent, title: str, text: str, *,
yes_text: str = "Yes", no_text: str = "No",
default_yes: bool = False) -> bool:
@@ -126,6 +145,7 @@ def import_block_library(win):
items.setCurrentRow(0)
lay.addWidget(items, 1)
buttons = QHBoxLayout()
+ buttons.setSpacing(8)
add_btn = QPushButton("Add Block")
upd_btn = QPushButton("Update Matching")
close_btn = QPushButton("Close")
@@ -198,6 +218,7 @@ def show_spof_dialog(win):
items.addItem(f"{where}{d['name']} ({d['kind']} #{d['id']})")
lay.addWidget(items, 1)
buttons = QHBoxLayout()
+ buttons.setSpacing(8)
export_btn = QPushButton("Export…")
close_btn = QPushButton("Close")
close_btn.clicked.connect(dlg.reject)
diff --git a/raptor/gui/ui/empirical_dialog.py b/raptor/gui/ui/empirical_dialog.py
index 967ccd3b..07a5dfa4 100644
--- a/raptor/gui/ui/empirical_dialog.py
+++ b/raptor/gui/ui/empirical_dialog.py
@@ -52,6 +52,7 @@ def open_empirical_dialog(parent, values: list[float],
v.addLayout(entry_row)
file_row = QHBoxLayout()
+ file_row.setSpacing(8)
file_row.addWidget(load_b)
file_row.addWidget(del_b)
file_row.addStretch(1)
@@ -162,6 +163,7 @@ def load_file():
load_b.clicked.connect(load_file)
buttons = QHBoxLayout()
+ buttons.setSpacing(8)
ok_b = QPushButton("OK")
widgets["ok_b"] = ok_b
cancel_b = QPushButton("Cancel")
diff --git a/raptor/gui/ui/help_viewer.py b/raptor/gui/ui/help_viewer.py
index 4e8abb1f..b93e0fee 100644
--- a/raptor/gui/ui/help_viewer.py
+++ b/raptor/gui/ui/help_viewer.py
@@ -256,6 +256,7 @@ def act(text, tip, slot):
# ---- find bar -------------------------------------------
frow = QHBoxLayout()
+ frow.setSpacing(8)
frow.setContentsMargins(6, 4, 6, 4)
self._find_edit = QLineEdit()
self._find_edit.setPlaceholderText(
diff --git a/raptor/gui/ui/main_window.py b/raptor/gui/ui/main_window.py
index 26fc0bb3..d45bb9cc 100644
--- a/raptor/gui/ui/main_window.py
+++ b/raptor/gui/ui/main_window.py
@@ -1610,6 +1610,10 @@ def _setup_docks(self):
self._data_tables.export_tables_requested.connect(self._export_tables)
self._data_tables.element_locate_requested.connect(
self._on_result_element_clicked)
+ self._data_tables.element_locate_id_requested.connect(
+ self._on_fev_element_locate)
+ self._data_tables.status_message.connect(
+ lambda text: self._statusbar.showMessage(text, 8000))
self._data_tables.validation_failed.connect(
lambda msg: self._statusbar.showMessage(f"Invalid value — {msg}", 8000))
dock_tables = QDockWidget("Data Tables", self)
diff --git a/raptor/gui/ui/phasing_dialog.py b/raptor/gui/ui/phasing_dialog.py
index 64021bc5..39d4088c 100644
--- a/raptor/gui/ui/phasing_dialog.py
+++ b/raptor/gui/ui/phasing_dialog.py
@@ -220,6 +220,7 @@ def __init__(self, win):
# ---- Fill row/column toolbar -----------------------------
bar = QHBoxLayout()
+ bar.setSpacing(SPACING["sm"])
tip = ("Stamp the PALETTE's armed mode+value (or, if none is "
"armed, the selected cell's own mode+value) across its "
"whole row (this element, every phase)")
diff --git a/raptor/gui/ui/results.py b/raptor/gui/ui/results.py
index b5760aa9..dc4882ac 100644
--- a/raptor/gui/ui/results.py
+++ b/raptor/gui/ui/results.py
@@ -652,6 +652,7 @@ def _build_details_tab(self):
self._details_tabs.currentChanged.connect(self._remember_details_tab)
bar = QHBoxLayout()
+ bar.setSpacing(SPACING["sm"])
bar.setContentsMargins(SPACING["md"], SPACING["xs"], SPACING["md"], 0)
from PySide6.QtWidgets import QPushButton
copy_btn = QPushButton("Copy Table")
@@ -2407,6 +2408,7 @@ def _build_graphs_tab(self):
SPACING["md"], SPACING["md"])
v.setSpacing(SPACING["sm"])
top = QHBoxLayout()
+ top.setSpacing(SPACING["sm"])
self._graph_mode = QComboBox()
self._graph_mode.addItems(["Per Trial Ao", "Interval Ao",
"Multi-run Ao", "Capacity (Flow)",
@@ -2696,6 +2698,7 @@ def _build_raw_tab(self):
outer.setSpacing(0)
bar = QHBoxLayout()
+ bar.setSpacing(SPACING["sm"])
bar.setContentsMargins(SPACING["md"], SPACING["xs"],
SPACING["md"], 0)
from PySide6.QtWidgets import QPushButton
diff --git a/raptor/gui/ui/table_export.py b/raptor/gui/ui/table_export.py
index 4f39efdc..286dfd69 100644
--- a/raptor/gui/ui/table_export.py
+++ b/raptor/gui/ui/table_export.py
@@ -114,9 +114,13 @@ def table_to_grid(table: Any, *, raw: bool = True,
realize_all = getattr(table, "realize_all", None)
if realize_all is not None and cell_widgets:
realize_all()
- column_count = table.columnCount()
+ # A column the user has hidden (the Data Tables' Level column outside
+ # Whole model) is not exported either.
+ cols = [c for c in range(table.columnCount())
+ if not (hasattr(table, "isColumnHidden")
+ and table.isColumnHidden(c))]
headers = []
- for c in range(column_count):
+ for c in cols:
head = table.horizontalHeaderItem(c)
headers.append(head.text() if head is not None else "")
# Respect the active hierarchy/filter: exporting rows the user cannot
@@ -124,19 +128,20 @@ def table_to_grid(table: Any, *, raw: bool = True,
visible = [r for r in range(table.rowCount())
if not table.isRowHidden(r)]
rows = [[cell_text(table, r, c, raw=raw, cell_widgets=cell_widgets)
- for c in range(column_count)]
+ for c in cols]
for r in visible]
# A ">" display prefix is not a number. Keep the numeric value in its
# column and add a parallel flag column so a spreadsheet can both sum
# the value and know it is a lower bound.
censor_cols = [
- c for c in range(column_count)
+ c for c in cols
if any((table.item(r, c) is not None
and table.item(r, c).data(CENSOR_ROLE))
for r in visible)
]
if censor_cols:
- headers = headers + [f"{headers[c]} (censored)" for c in censor_cols]
+ headers = headers + [f"{headers[cols.index(c)]} (censored)"
+ for c in censor_cols]
for row, r in zip(rows, visible):
row.extend(
">" if (table.item(r, c) is not None
diff --git a/tests/unit/test_gui_data_tables_multi_edit.py b/tests/unit/test_gui_data_tables_multi_edit.py
new file mode 100644
index 00000000..895a846b
--- /dev/null
+++ b/tests/unit/test_gui_data_tables_multi_edit.py
@@ -0,0 +1,232 @@
+"""Data Tables: select several rows, edit one cell, change them all."""
+from __future__ import annotations
+
+import pytest
+from PySide6.QtCore import QItemSelectionModel
+from PySide6.QtWidgets import QApplication, QComboBox, QLineEdit
+
+pytestmark = pytest.mark.gui
+
+MODEL = "tests/golden/Phasing3/input.rbd"
+
+
+@pytest.fixture
+def win(monkeypatch):
+ from raptor.core.fileio.rbd import load_rbd
+ from raptor.gui.ui import dialogs, settings
+ from raptor.gui.ui.main_window import MainWindow
+ QApplication.instance() or QApplication([])
+ settings.set_value("startup/show_start_page", False)
+ w = MainWindow()
+ w._load_into_app(load_rbd(MODEL), MODEL)
+ w.resize(1400, 800)
+ w.show()
+ QApplication.processEvents()
+ w._asked = []
+ monkeypatch.setattr(dialogs, "ask_apply_to_rows",
+ lambda parent, n: (w._asked.append(n) or (True, False)))
+ yield w
+ w._dirty = False
+ w.close()
+
+
+def _select(table, rows):
+ sm = table.selectionModel()
+ sm.clearSelection()
+ for r in rows:
+ sm.select(table.model().index(r, 0),
+ QItemSelectionModel.SelectionFlag.Select
+ | QItemSelectionModel.SelectionFlag.Rows)
+
+
+def _cur(t, row, col):
+ """Make a cell current without touching the selection (clicking into a
+ cell's editor does the same; `setCurrentCell` would reselect)."""
+ t.selectionModel().setCurrentIndex(
+ t.model().index(row, col), QItemSelectionModel.SelectionFlag.NoUpdate)
+
+
+def _bid(t, r):
+ return int(t.item(r, 1).text())
+
+
+def _type(t, row, col, text):
+ _cur(t, row, col)
+ edit = t.cellWidget(row, col).findChild(QLineEdit)
+ edit.setText(text)
+ edit.editingFinished.emit()
+
+
+def _blocks(win):
+ dt = win._data_tables
+ t = dt._blocks_table
+ dt._realize_all(t)
+ return dt, t, win._diagram_dock.view
+
+
+def test_editing_a_selected_row_changes_every_selected_row_in_one_step(win):
+ _dt, t, view = _blocks(win)
+ _select(t, [0, 1, 2])
+ ids = [_bid(t, r) for r in range(3)]
+ before = {i: list(view.blocks[i].fail_values) for i in view.blocks}
+ n = view._stack.count()
+ _type(t, 0, 3, "4321") # Failure Param 1
+ for i in ids:
+ assert view.blocks[i].fail_values[0] == 4321.0
+ untouched = [i for i in view.blocks if i not in ids]
+ assert all(view.blocks[i].fail_values == before[i] for i in untouched)
+ assert view._stack.count() == n + 1 # ONE undo step
+ view._stack.undo()
+ assert all(list(view.blocks[i].fail_values) == before[i] for i in ids)
+ view._stack.redo()
+ assert all(view.blocks[i].fail_values[0] == 4321.0 for i in ids)
+ assert "on 3 rows" in win._statusbar.currentMessage()
+
+
+def test_an_unselected_row_edits_alone(win):
+ _dt, t, view = _blocks(win)
+ _select(t, [1, 2])
+ _type(t, 0, 3, "777") # row 0 is not in the selection
+ assert view.blocks[_bid(t, 0)].fail_values[0] == 777.0
+ assert view.blocks[_bid(t, 1)].fail_values[0] != 777.0
+
+
+def test_one_selected_row_is_a_single_edit(win):
+ _dt, t, view = _blocks(win)
+ _select(t, [1])
+ _type(t, 1, 3, "555")
+ assert view.blocks[_bid(t, 1)].fail_values[0] == 555.0
+ assert win._asked == []
+
+
+def test_a_refused_multi_edit_reverts_every_row(win, monkeypatch):
+ from raptor.gui.ui import msgbox
+ shown = []
+ monkeypatch.setattr(msgbox, "warning", lambda *a, **k: shown.append(a))
+ dt, t, view = _blocks(win)
+ _select(t, [0, 1, 2])
+ ids = [_bid(t, r) for r in range(3)]
+ before = {i: view.blocks[i].pm_freq for i in ids}
+ n = view._stack.count()
+ # a PM frequency the input rules refuse (they need > 0 when PM is off)
+ dt._apply_rows(ids[0], "PM Frequency", "-1",
+ lambda k: view.set_block_pm_freq(k, -1.0) or True)
+ QApplication.processEvents()
+ assert shown # the gate spoke once
+ assert view._stack.count() == n # nothing kept
+ assert {i: view.blocks[i].pm_freq for i in ids} == before
+
+
+def test_filter_limits_the_rows_changed(win):
+ _dt, t, view = _blocks(win)
+ _select(t, [0, 1, 2])
+ t.setRowHidden(1, True) # filtered out
+ hidden = _bid(t, 1)
+ before = view.blocks[hidden].fail_values[0]
+ _type(t, 0, 3, "999")
+ assert view.blocks[_bid(t, 0)].fail_values[0] == 999.0
+ assert view.blocks[_bid(t, 2)].fail_values[0] == 999.0
+ assert view.blocks[hidden].fail_values[0] == before
+
+
+def test_distribution_change_applies_to_every_selected_row(win):
+ _dt, t, view = _blocks(win)
+ _select(t, [0, 1])
+ ids = [_bid(t, 0), _bid(t, 1)]
+ combo = t.cellWidget(0, 2)
+ assert isinstance(combo, QComboBox)
+ now = {view.blocks[i].fail_distro for i in ids}
+ target = next(i for i in range(combo.count())
+ if combo.itemData(i) not in now
+ and combo.itemText(i).lower().startswith("gamma"))
+ combo.setCurrentIndex(target)
+ codes = {view.blocks[i].fail_distro for i in ids}
+ assert len(codes) == 1 and codes.isdisjoint(now)
+ assert "Failure Dist" in win._statusbar.currentMessage()
+
+
+def test_a_parameter_a_distribution_lacks_is_skipped_and_reported(win):
+ dt, t, view = _blocks(win)
+ _select(t, [0, 1])
+ a, b = _bid(t, 0), _bid(t, 1)
+ view.change_block_dist(a, "F", 4) # Exponential: 2 params
+ view.change_block_dist(b, "F", 15) # Weibull: 3 params
+ dt.refresh(force=True)
+ QApplication.processEvents()
+ t = dt._blocks_table
+ dt._realize_all(t)
+ rows = {_bid(t, r): r for r in range(t.rowCount())}
+ _select(t, [rows[a], rows[b]])
+ before = view.blocks[a].fail_values[2]
+ _type(t, rows[b], 5, "12") # Failure Param 3
+ assert view.blocks[b].fail_values[2] == 12.0
+ assert view.blocks[a].fail_values[2] == before
+ assert "without that parameter skipped" in win._statusbar.currentMessage()
+
+
+def test_the_confirmation_is_asked_once_when_told_not_to_ask_again(
+ win, monkeypatch):
+ from raptor.gui.ui import dialogs
+ asked = []
+ monkeypatch.setattr(dialogs, "ask_apply_to_rows",
+ lambda parent, n: (asked.append(n) or (True, True)))
+ dt, t, _view = _blocks(win)
+ _select(t, [0, 1, 2])
+ _type(t, 0, 3, "11")
+ _select(t, [0, 1, 2])
+ _type(t, 0, 3, "12")
+ assert asked == [3]
+ assert dt._multi_confirmed
+
+
+def test_declining_the_confirmation_changes_nothing(win, monkeypatch):
+ from raptor.gui.ui import dialogs
+ monkeypatch.setattr(dialogs, "ask_apply_to_rows", lambda p, n: (False, False))
+ _dt, t, view = _blocks(win)
+ _select(t, [0, 1])
+ before = view.blocks[_bid(t, 0)].fail_values[0]
+ n = view._stack.count()
+ _type(t, 0, 3, "31337")
+ assert view._stack.count() == n
+ assert view.blocks[_bid(t, 0)].fail_values[0] == before
+
+
+def test_ctrl_d_fills_down(win):
+ dt, t, view = _blocks(win)
+ ids = [_bid(t, r) for r in range(3)]
+ _type(t, 0, 3, "808") # one row, one edit
+ _select(t, [0, 1, 2])
+ _cur(t, 0, 3)
+ assert dt.fill_down()
+ assert [view.blocks[i].fail_values[0] for i in ids] == [808.0] * 3
+
+
+def test_pasting_one_value_fills_the_selected_rows(win):
+ dt, t, view = _blocks(win)
+ _select(t, [0, 1, 2])
+ _cur(t, 0, 3)
+ assert dt.paste_into_selection("606")
+ assert all(view.blocks[_bid(t, r)].fail_values[0] == 606.0
+ for r in range(3))
+ assert not dt.paste_into_selection("a\nb") # not a single value
+
+
+def test_banner_counts_the_selected_rows(win):
+ dt, t, _view = _blocks(win)
+ _select(t, [0])
+ assert not dt._banner.isVisible() and dt._banner.text() == ""
+ _select(t, [0, 1, 2, 3])
+ QApplication.processEvents()
+ assert dt._banner.text() == (
+ "4 rows selected. Editing a cell will change all 4.")
+ assert not dt._banner.isHidden()
+ dt.grab().save("/tmp/claude-1000/-home-mulhollandd-Documents-raptor-play/"
+ "6b447a50-9509-483d-9fb4-caf03d0e3767/scratchpad/banner.png")
+
+
+def test_read_only_columns_are_never_multi_edited(win):
+ _dt, t, view = _blocks(win)
+ _select(t, [0, 1])
+ name_before = view.blocks[_bid(t, 1)].name
+ t.item(0, 0).setText("Same") # a name is per row
+ assert view.blocks[_bid(t, 1)].name == name_before
diff --git a/tests/unit/test_gui_data_tables_whole_model.py b/tests/unit/test_gui_data_tables_whole_model.py
new file mode 100644
index 00000000..7e9f8223
--- /dev/null
+++ b/tests/unit/test_gui_data_tables_whole_model.py
@@ -0,0 +1,148 @@
+"""Data Tables: the Whole model switch (every level's rows, a Level column)."""
+from __future__ import annotations
+
+import pytest
+from PySide6.QtTest import QTest
+from PySide6.QtWidgets import QApplication
+
+pytestmark = pytest.mark.gui
+
+MODEL = "new_docs/models/DC2.rbds" # Home holds no blocks; three levels do
+
+
+@pytest.fixture
+def win():
+ from raptor.core.fileio.rbds import load_rbds
+ from raptor.gui.ui import settings
+ from raptor.gui.ui.main_window import MainWindow
+ QApplication.instance() or QApplication([])
+ settings.set_value("startup/show_start_page", False)
+ w = MainWindow()
+ w._load_into_app(load_rbds(MODEL), MODEL)
+ w.resize(1400, 800)
+ w.show()
+ QApplication.processEvents()
+ yield w
+ w._dirty = False
+ w.close()
+
+
+def _level_col(t):
+ return t.columnCount() - 1
+
+
+def test_level_column_exists_only_in_whole_model(win):
+ dt = win._data_tables
+ t = dt._blocks_table
+ assert t.isColumnHidden(_level_col(t))
+ assert t.rowCount() == 0 # Home has no blocks
+ dt._whole_box.setChecked(True)
+ assert not t.isColumnHidden(_level_col(t))
+ assert t.rowCount() == 14
+ levels = {t.item(r, _level_col(t)).text() for r in range(t.rowCount())}
+ assert levels == {"IT_sub", "Power_sub", "Cooling_sub"}
+ dt._whole_box.setChecked(False)
+ assert t.rowCount() == 0 and t.isColumnHidden(_level_col(t))
+
+
+def test_scope_label_names_the_whole_model(win):
+ dt = win._data_tables
+ dt._whole_box.setChecked(True)
+ assert dt._scope_label.text() == (
+ "· Whole model (14 blocks across 4 levels)")
+
+
+def test_every_tab_follows_the_switch(win):
+ dt = win._data_tables
+ dt._whole_box.setChecked(True)
+ for t in (dt._nodes_table, dt._events_table, dt._sparing_table):
+ assert not t.isColumnHidden(_level_col(t))
+ assert dt._nodes_table.rowCount() > 0
+ assert dt._sparing_table.rowCount() == 14
+ levels = {dt._nodes_table.item(r, _level_col(dt._nodes_table)).text()
+ for r in range(dt._nodes_table.rowCount())}
+ assert "Home" in levels and "IT_sub" in levels
+
+
+def test_rows_of_other_levels_are_read_only(win):
+ dt = win._data_tables
+ dt._whole_box.setChecked(True)
+ t = dt._blocks_table
+ dt._realize_all(t)
+ from PySide6.QtCore import Qt
+ for r in range(t.rowCount()): # none of these levels is open
+ assert not (t.item(r, 0).flags() & Qt.ItemFlag.ItemIsEditable)
+ assert not t.cellWidget(r, 2).isEnabled()
+ assert "another level" in t.item(r, 0).toolTip()
+
+
+def test_the_open_level_stays_editable(win):
+ view = win._diagram_dock.view
+ hid = next(iter(view._hier_items))
+ view.enter_level(hid)
+ QApplication.processEvents()
+ dt = win._data_tables
+ dt._whole_box.setChecked(True)
+ t = dt._blocks_table
+ dt._realize_all(t)
+ live = set(view.blocks)
+ assert live
+ editable = {dt._row_key(t, r) for r in range(t.rowCount())
+ if t.cellWidget(r, 2).isEnabled()}
+ assert editable == live
+
+
+def test_renaming_never_touches_a_row_of_another_level(win):
+ dt = win._data_tables
+ dt._whole_box.setChecked(True)
+ t = dt._blocks_table
+ before = {r: t.item(r, 0).text() for r in range(t.rowCount())}
+ t.item(0, 0).setText("Renamed") # must be ignored, not raise
+ assert set(win._diagram_dock.view.blocks) == set()
+ assert before[0] != "Renamed"
+
+
+def test_the_level_column_can_be_filtered(win):
+ dt = win._data_tables
+ dt._whole_box.setChecked(True)
+ t = dt._blocks_table
+ bar = dt._filter_bars[t]
+ assert bar.available()
+ bar.set_shown(True)
+ bar._editors[_level_col(t)].setText("Cool*")
+ QTest.qWait(bar._timer.interval() + 80)
+ shown, total = bar.rows_shown_of_total()
+ assert total == 14 and 0 < shown < 14
+ assert all(t.item(r, _level_col(t)).text() == "Cooling_sub"
+ for r in range(t.rowCount()) if not t.isRowHidden(r))
+
+
+def test_csv_export_has_the_level_column_only_in_whole_model(win):
+ from raptor.gui.ui.table_export import table_to_grid
+ dt = win._data_tables
+ t = dt._blocks_table
+ assert "Level" not in table_to_grid(t)[0]
+ dt._whole_box.setChecked(True)
+ headers, rows = table_to_grid(t)
+ assert headers[-1] == "Level" and len(rows) == 14
+ assert {r[-1] for r in rows} == {"IT_sub", "Power_sub", "Cooling_sub"}
+
+
+def test_go_to_level_goes_by_id(win, monkeypatch):
+ dt = win._data_tables
+ dt._whole_box.setChecked(True)
+ t = dt._blocks_table
+ seen = []
+ dt.element_locate_id_requested.connect(lambda k, i: seen.append((k, i)))
+ pos = t.visualRect(t.model().index(0, 0)).center()
+ menu = dt._row_menu(t, "block", pos) # (a real exec() would block)
+ assert menu.actions()[0].text().startswith("Go to level: ")
+ menu.actions()[0].trigger()
+ assert seen and seen[0][0] == "block"
+ assert win._diagram_dock.view.locate_id(*seen[0]) # switches level
+ assert win._diagram_dock.view.blocks
+
+
+def test_the_bar_buttons_have_spacing(win):
+ bar = win._data_tables.layout().itemAt(0).layout()
+ assert bar.spacing() > 0
From 56972cf3ee089433622e79bb33c59cbea76bbea1 Mon Sep 17 00:00:00 2001
From: Daniel Mulholland Blocks Name, ID, Failure Dist, Failure Param 1, Failure Param 2, Failure Param 3, # Failure Params, Repair Dist, Repair Param 1, Repair Param 2, Repair Param 3, # Repair Params, PM Frequency, Failure Mean, Failure Std Dev, Repair Mean, Repair Std Dev Name, ID, Failure Dist, Failure Param 1, Failure Param 2, Failure Param 3, # Failure Params, Repair Dist, Repair Param 1, Repair Param 2, Repair Param 3, # Repair Params, PM Frequency, Failure Mean, Failure Std Dev, Repair Mean, Repair Std Dev, LevelData Tables columns
From 71e3a9c5d0c2d74def7fc7ff6cfca11e6cbeb28c Mon Sep 17 00:00:00 2001
From: Daniel Mulholland
-
+