diff --git a/docs/KEYBOARD_SHORTCUTS.md b/docs/KEYBOARD_SHORTCUTS.md index e749f06..2881b38 100644 --- a/docs/KEYBOARD_SHORTCUTS.md +++ b/docs/KEYBOARD_SHORTCUTS.md @@ -31,6 +31,13 @@ Nudging with the arrow keys (one grid step; Shift: 1 px) also moves selected routing points, and the routing points of a link whose two ends both move go with them. One undo step. +## Tables +| Shortcut | Action | +|---|---| +| Ctrl+F | Show or hide the filter row of the table that has the focus (tables of more than 10 rows) | +| Ctrl+D | Data Tables: fill down, the top selected row's value goes to the other selected rows in the current column | +| Ctrl+V | Data Tables, on the grid itself: paste one value into the current column of every selected row | + ## Annotation z-order (Model view) Needs one or more annotations selected. Each command is greyed out when it would not move anything, so a note already at the front has *Bring to diff --git a/docs/RBDS_FORMAT.md b/docs/RBDS_FORMAT.md index ecaf347..a066862 100644 --- a/docs/RBDS_FORMAT.md +++ b/docs/RBDS_FORMAT.md @@ -545,6 +545,15 @@ element positions and annotation geometry stay put between sessions. A hand-authored file without it has one derived on open, deterministically, from the element positions. +**`view`.** `.rbds`-only view options, written once for the whole file +(never per level) so reopening restores the look exactly: `link_routing` +(`straight`, `angled` or `orthogonal`) and `block_labels` (what a block +shows under its name: `mean`, `spread`, `rate` or `off`). A value is written +only when it was set; a file without `view` leaves the viewer's current +settings alone, and an unknown value is ignored. The page flow used by Auto +Layout is per level and lives in `flow_directions` (key: the hierarchy id, +`0` for the top level). + **Anchors.** An annotation placed next to an element or another annotation (`adjacent_to`, or an arrow's `from_`/`to`) carries an `anchor` — diff --git a/docs/manual-reference.adoc b/docs/manual-reference.adoc index 15bffec..a26bb7b 100644 --- a/docs/manual-reference.adoc +++ b/docs/manual-reference.adoc @@ -372,6 +372,6 @@ documentation. [cols="2,8",options="header"] |=== |Tab |Columns -|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 +|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, Level |=== diff --git a/docs/manual.adoc b/docs/manual.adoc index 2fb7f2b..6ae7fee 100644 --- a/docs/manual.adoc +++ b/docs/manual.adoc @@ -61,11 +61,13 @@ the Inspector shows read-only echoes for those fields. Model-wide fields Open from *View → Panels → Data Tables*. It edits the model as spreadsheets, one tab per element type or per section of the `.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. Model Explorer -highlights the level on screen (Home, or the hierarchy you are inside) and -the window title ends with its name and id, for example +Blocks, Nodes, Events, Sparing and Preventive Maintenance show the +hierarchy level currently on the canvas; the header says “current level: +8 of 17 blocks…” when the model has nested levels. Tick **Whole model** +to see every level at once (see below), or use Model Explorer to reach +the others. +Model Explorer highlights the level on screen (Home, or the hierarchy you +are inside) and the window title ends with its name and id, for example “— Pump_Hierarchy (id 20)”. [cols="2,6",options="header"] @@ -109,6 +111,115 @@ above (unavailable), Failure resets — lists only blocks with PM enabled |=== +[[data-tables-whole-model]] +=== Whole model + +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. + +[[table-filters]] +=== Filtering a table + +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. + +[cols="2,5",options="header"] +|=== +|Type |Matches + +|`pump` +|cells containing “pump”, ignoring case + +|`"main pump"` +|a phrase, spaces included + +|`Pump*`, `*_B`, `P?mp` +|wildcards: `*` is any run of characters, `?` is one character. With a +wildcard the whole cell must match, so `Pump*` means “starts with Pump” +and `*pump*` means “contains” + +|`=Pump_A` +|the whole cell equals the text (`=5` compares numerically) + +|`!pump` +|does not match: cells that do not contain “pump” + +|`>0.9`, `>=5`, `<100`, `<=2`, `=3`, `!=0` +|number comparisons + +|`5..10` +|a number from 5 to 10, inclusive + +|`blank`, `nonblank` +|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. + +[[data-tables-multi-edit]] +=== Changing several rows at once + +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 to + 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 + <>): 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. + [[block-defaults]] == Block Defaults @@ -148,6 +259,23 @@ stays on after each placement, so one click on the button places as many elements as you need. Press *Q* or *Esc*, or right-click, to go back to *Select*. +=== Copy, cut and paste + +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. + === Insert an element on a link With an Add tool on, point at a link instead of empty canvas: @@ -249,6 +377,16 @@ done: 3 selected items on Home" or "Auto Layout done: 12 items in hierarchy Pumps". [[input-rules]] +[[stacked-elements]] +== Elements on top of each other + +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. + == Values and links Raptor 7 refuses RAPTOR Studio applies Raptor 7's own input checks, with Raptor 7's own diff --git a/docs/manual.html b/docs/manual.html index 941e3b9..2099b86 100644 --- a/docs/manual.html +++ b/docs/manual.html @@ -15,17 +15,25 @@

RAPTOR Studio Reference Manual

Table of Contents
  • Model tab → Project Settings
  • -
  • Data Tables
  • +
  • Data Tables + +
  • Block Defaults
  • Mass Edit
  • Building on the canvas
  • -
  • Values and links Raptor 7 refuses
  • +
  • Elements on top of each other
  • +
  • Values and links Raptor 7 refuses
  • Results dock
    • Analytical analysis
    • @@ -236,11 +244,13 @@

      Data Tables

      Open from View → Panels → Data Tables. It edits the model as spreadsheets, one tab per element type or per section of the .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. Model Explorer -highlights the level on screen (Home, or the hierarchy you are inside) and -the window title ends with its name and id, for example +Blocks, Nodes, Events, Sparing and Preventive Maintenance show the +hierarchy level currently on the canvas; the header says “current level: +8 of 17 blocks…” when the model has nested levels. Tick Whole model +to see every level at once (see below), or use Model Explorer to reach +the others. +Model Explorer highlights the level on screen (Home, or the hierarchy you +are inside) and the window title ends with its name and id, for example “— Pump_Hierarchy (id 20)”.

      @@ -302,6 +312,151 @@

      Data Tables

      +
      +

      Whole model

      +
      +

      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.

      +
      +
      +
      +

      Filtering a table

      +
      +

      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.

      +
      + ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      TypeMatches

      pump

      cells containing “pump”, ignoring case

      "main pump"

      a phrase, spaces included

      Pump*, *_B, P?mp

      wildcards: is any run of characters, ? is one character. With a +wildcard the whole cell must match, so Pump means “starts with Pump” +and pump means “contains”

      =Pump_A

      the whole cell equals the text (=5 compares numerically)

      !pump

      does not match: cells that do not contain “pump”

      >0.9, >=5, <100, ⇐2, =3, !=0

      number comparisons

      5..10

      a number from 5 to 10, inclusive

      blank, nonblank

      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.

      +
      +
      +
      +

      Changing several rows at once

      +
      +

      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.

        +
      • +
      +
      +
      @@ -362,6 +517,25 @@

      Building on the canvas

      Select.

      +

      Copy, cut and paste

      +
      +

      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:

      @@ -522,7 +696,20 @@

      Auto Layout a selection

      -

      Values and links Raptor 7 refuses

      +

      Elements on top of each other

      +
      +
      +

      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 @@ -3049,7 +3236,7 @@

      Data Tables columns

      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, Level

      diff --git a/raptor/gui/ui/analytic_dialog.py b/raptor/gui/ui/analytic_dialog.py index 092f129..20b5a42 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 7b737f2..4144300 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 7b30d6b..17c3626 100644 --- a/raptor/gui/ui/data_tables.py +++ b/raptor/gui/ui/data_tables.py @@ -13,17 +13,20 @@ """ from __future__ import annotations +from bisect import bisect_left +from collections.abc import Callable +from typing import Any, cast + from raptor.gui.ui.qtcompat import req from raptor.gui.ui import msgbox -from typing import Any from PySide6.QtCore import Qt, Signal, QTimer, QEvent from PySide6.QtGui import QDoubleValidator 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 @@ -35,6 +38,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 @@ -57,6 +61,8 @@ def _fmt_stat(val) -> str: "Name": "The element's name", "ID": "The element's id, as the Explorer, the Inspector and exported " "tables show it in brackets (read-only)", + "Level": "The hierarchy level this element is on (Whole model only; " + "read-only)", } @@ -68,6 +74,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: Callable[[str], bool] | None = 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 = Qt.SortOrder.AscendingOrder) -> None: if any(self.cellWidget(row, column) is not None @@ -104,6 +123,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() @@ -111,6 +201,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. @@ -125,7 +220,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 @@ -183,7 +278,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", @@ -201,7 +296,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", @@ -278,6 +373,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 @@ -293,12 +393,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( @@ -324,6 +424,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(): + cast(_DataTable, _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") @@ -335,7 +444,7 @@ def __init__(self, parent=None): self._tabs.addTab(self._pm_table, "Preventive Maintenance") # Keyed-row tables: column -> kind for commit validation. - self._keyed = { + self._keyed: dict[QTableWidget, tuple[str, str, dict[str, type]]] = { self._spares_table: ("SPARE_POOL_DATA", "PoolName", {"PoolName": str, "RSO": int, "Stock": int, "NewSp": int, @@ -370,7 +479,7 @@ def __init__(self, parent=None): ] # Grids whose cell widgets are built on demand: table -> builder, # and the row keys (block ids) still waiting for theirs. - self._lazy_builders = { + self._lazy_builders: dict[QTableWidget, Callable[[Any, int, int], None]] = { self._blocks_table: self._build_block_widgets, self._sparing_table: self._build_sparing_widgets, } @@ -415,6 +524,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") @@ -428,6 +538,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) @@ -463,7 +590,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) @@ -535,10 +662,10 @@ def _apply_time_headers(self) -> None: item = table.horizontalHeaderItem(col) if item is not None and item.text().split(" (")[0] == name: item.setText(f"{name} ({unit})" if unit else name) - for table, (section, _key, _kinds) in self._keyed.items(): + for ktable, (section, _key, _kinds) in self._keyed.items(): fields = self._KEYED_TIME_FIELDS.get(section, set()) - for col in range(table.columnCount()): - item = table.horizontalHeaderItem(col) + for col in range(ktable.columnCount()): + item = ktable.horizontalHeaderItem(col) if item is None: continue base = item.text().split(" (")[0] @@ -772,9 +899,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 @@ -793,6 +921,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.""" @@ -840,6 +971,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 @@ -858,6 +1233,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 {}) @@ -1027,7 +1409,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 @@ -1063,7 +1445,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: @@ -1099,15 +1481,24 @@ def _realize_visible(self, t: QTableWidget) -> None: # Deferred calls (sort, resize) can land after the grid is gone. if not isValid(t) or not self._lazy_pending.get(t): return - n = t.rowCount() - top = max(t.rowAt(0), 0) + # Only rows a filter leaves showing count. `rowAt` answers -1 when + # the shown rows do not fill the window (or none match), which used + # to be read as "the table runs on", so a selective filter built the + # widgets of every hidden row: ~55 s on a 3,800-block model. + shown = [r for r in range(t.rowCount()) if not t.isRowHidden(r)] + if not shown: + return + top = t.rowAt(0) bottom = t.rowAt(max(t.viewport().height() - 1, 0)) - if bottom < 0: - bottom = n - 1 - # Not yet laid out (hidden tab, first show): take a screenful. - bottom = max(bottom, top + 30) - self._realize_rows(t, range(max(top - _LAZY_MARGIN, 0), - min(bottom + _LAZY_MARGIN + 1, n))) + first = bisect_left(shown, top) if top >= 0 else 0 + # A screenful, at least 30 (not yet laid out: hidden tab, first show). + fits = max(t.viewport().height() // max( + t.verticalHeader().defaultSectionSize(), 1) + 1, 30) + last = (bisect_left(shown, bottom) if bottom >= 0 + else first + fits) + last = min(last, first + fits, len(shown) - 1) + self._realize_rows(t, shown[max(first - _LAZY_MARGIN, 0): + last + _LAZY_MARGIN + 1]) def _realize_all(self, t: QTableWidget) -> None: """Every row: before anything that reads all the widgets (export).""" @@ -1140,6 +1531,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) @@ -1162,10 +1555,13 @@ 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] + 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) @@ -1196,7 +1592,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, @@ -1242,10 +1638,13 @@ 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] + 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) @@ -1263,22 +1662,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: @@ -1418,17 +1835,22 @@ 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] + 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() @@ -1448,7 +1870,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() @@ -1472,7 +1897,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() @@ -1522,10 +1950,13 @@ 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] + 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) @@ -1538,7 +1969,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 @@ -1563,8 +1994,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: @@ -1573,12 +2006,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) @@ -1624,11 +2061,14 @@ 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] + 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) @@ -1661,7 +2101,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() # ------------------------------------------------------------------ @@ -2056,6 +2500,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(): @@ -2087,7 +2533,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() @@ -2101,6 +2550,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 @@ -2113,6 +2564,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) @@ -2138,8 +2591,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: @@ -2176,6 +2645,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 @@ -2195,12 +2672,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 ce63ccc..5303412 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: @@ -147,6 +166,7 @@ def import_block_library(win): warn.setWordWrap(True) lay.addWidget(warn) buttons = QHBoxLayout() + buttons.setSpacing(8) all_btn = QPushButton("Select All") all_btn.setToolTip("Select every template, to update all the blocks " "that share a name with one.") @@ -245,6 +265,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 967ccd3..07a5dfa 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 4e8abb1..b93e0fe 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 4125db2..7f61ae2 100644 --- a/raptor/gui/ui/main_window.py +++ b/raptor/gui/ui/main_window.py @@ -1562,8 +1562,13 @@ def _edit_paste(self) -> None: editor = text_editing.active_editor() if editor is not None: text_editing.paste(editor) - else: - self._diagram_dock.view.paste() + return + # A Data Tables grid takes one pasted value for its selected rows. + grid_paste = getattr(QApplication.focusWidget(), "paste_text", None) + if callable(grid_paste) and grid_paste( + QApplication.clipboard().text()): + return + self._diagram_dock.view.paste() def _on_focus_changed(self, _old, new) -> None: """Cut/Copy/Paste follow the text editor that has the keyboard, @@ -1716,6 +1721,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 64021bc..39d4088 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 3530357..58702d9 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") @@ -2408,6 +2409,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)", @@ -2697,6 +2699,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 4f39efd..286dfd6 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/qt_cleanup.py b/tests/unit/qt_cleanup.py new file mode 100644 index 0000000..1d76f2c --- /dev/null +++ b/tests/unit/qt_cleanup.py @@ -0,0 +1,23 @@ +"""Delete a test's canvases with Qt, not with the cyclic collector. + +A shown `DiagramView` left to `gc.collect()` has its scene, items and custom +cursors torn down in whatever order the collector reaches them, which +segfaulted CI in the test hygiene. Closing it and running its queued +`DeferredDelete` frees it in Qt's own (safe) order. +""" +from __future__ import annotations + +from collections.abc import Iterable + +from PySide6.QtCore import QEvent +from PySide6.QtWidgets import QApplication, QWidget + + +def dispose(widgets: Iterable[QWidget]) -> None: + for w in widgets: + try: + w.close() + w.deleteLater() + except RuntimeError: # already deleted on the C++ side + pass + QApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) diff --git a/tests/unit/test_gui_data_table_lazy_widgets.py b/tests/unit/test_gui_data_table_lazy_widgets.py index 1d1f1fe..6321c40 100644 --- a/tests/unit/test_gui_data_table_lazy_widgets.py +++ b/tests/unit/test_gui_data_table_lazy_widgets.py @@ -150,3 +150,31 @@ def test_a_small_model_still_builds_everything_at_once(): finally: w._dirty = False w.close() + + +def test_a_selective_filter_builds_only_the_widgets_of_the_rows_it_shows(): + """A filter that leaves few or no rows showing must not realize the + hidden ones (rowAt answers -1 then, which once meant "everything").""" + import pytest as _pytest + _pytest.importorskip("PySide6") + from PySide6.QtWidgets import QApplication, QTableWidget + from raptor.gui.ui import data_tables as dt + QApplication.instance() or QApplication([]) + dock = dt.DataTablesDock() + t = QTableWidget(500, 3) + for r in range(500): + for c in range(3): + t.setItem(r, c, dt.QTableWidgetItem(str(r))) + dock._lazy_pending[t] = set(range(500)) + built = [] + dock._lazy_builders[t] = lambda tb, r, key: built.append(r) + dock._row_key = staticmethod(lambda tb, r: r) + for r in range(500): + t.setRowHidden(r, r not in (7, 300)) + dock._realize_visible(t) + assert sorted(built) == [7, 300] + built.clear() + for r in range(500): + t.setRowHidden(r, True) + dock._realize_visible(t) + assert built == [] 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 0000000..0fd7000 --- /dev/null +++ b/tests/unit/test_gui_data_tables_multi_edit.py @@ -0,0 +1,257 @@ +"""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 + + +def test_the_window_paste_offers_the_focused_grid_its_value_first(monkeypatch): + """Ctrl+V reaches the window's Paste command, not the grid's key handler: + a focused Data Tables grid must get the clipboard text before the canvas.""" + from PySide6.QtWidgets import QApplication + 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) + win = MainWindow() + got = [] + grid = win._data_tables._blocks_table + grid.paste_text = lambda text: got.append(text) or True + canvas_pastes = [] + monkeypatch.setattr(win._diagram_dock.view, "paste", + lambda *a, **k: canvas_pastes.append(1)) + monkeypatch.setattr(QApplication, "focusWidget", staticmethod(lambda: grid)) + QApplication.clipboard().setText("42") + win._edit_paste() + assert got == ["42"] and canvas_pastes == [] + grid.paste_text = lambda text: False # the grid declines + win._edit_paste() + assert canvas_pastes == [1] + win.close() 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 0000000..7e9f822 --- /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 diff --git a/tests/unit/test_routing_nodes.py b/tests/unit/test_routing_nodes.py index a58a61d..5d233b2 100644 --- a/tests/unit/test_routing_nodes.py +++ b/tests/unit/test_routing_nodes.py @@ -16,6 +16,7 @@ from raptor.core.fileio.rbds import load_rbds, save_rbds from raptor.core.fileio.model_file import load_model_file from raptor.gui.ui.diagram import DiagramView +from tests.unit.qt_cleanup import dispose REPO = pathlib.Path(__file__).resolve().parents[2] FIXTURE = REPO / "tests/golden/TwoBlockRedundancy/input.rbd" @@ -27,8 +28,20 @@ def qapp(): return QApplication.instance() or QApplication([]) +_made: list[DiagramView] = [] + + +@pytest.fixture(autouse=True) +def _dispose_views(): + """Every canvas a test builds is deleted with Qt afterwards.""" + yield + dispose(_made) + _made.clear() + + def _view(path=FIXTURE): v = DiagramView() + _made.append(v) v.load_rbd(load_model_file(str(path))) return v