diff --git a/CHANGELOG.md b/CHANGELOG.md index f7a0cd6..e97d9b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,162 @@ and each [release on GitHub](https://github.com/s4mstruthers/openprocess/release uses it as its description. Write for the people using the app: what they will notice, not how it was done. Versions before 0.7.0 were released as CPNpy. +## 0.9.5 + +- **The log page no longer discovers models by itself.** Its *Discover* tab + used to run a miner on the spot, with no code to read and no record. Now + it hands the log to the canvas: pick a miner and a new analysis opens in + Mine with *Open log* and the miner connected and run, the miner's Result + open, its *How* and *Code* a click away. (In Learn, an exercise's log page + keeps its miners, since the exercise is the context there.) +- **Every computed figure links to its code.** What a page computes on its + own (the log page's process map and footprint, a net's soundness, + short-circuited net, structure, invariants, behavioural properties and + footprint, a coloured net's state space, a transition system's regions and + synthesised net) now has a small *{ } code* link on its card that opens the + file, scrolled to the function. Nothing in the app is a black box. +- **A model page's conformance is a handoff too.** *Check against a log ›* + on a discovered model builds the analysis in Mine (the model is kept in + the folder first). The Petri net page's *Conformance with a log…* is gone; + its Analysis tab has the same button. (In Learn, the exercise's model page + keeps its replay, as the exercise asks for it.) +- **Install a tool from Connections.** A tool that is not installed has + *Install…* on its card: after asking, pip installs it into the app's + Python in the background, and the boxes that need it come alive. The + downloaded app, whose Python cannot install packages, says so and gives the + command instead. + +## 0.9.4 + +Motion: a few short transitions, so the window reads as one flow. Nothing +moves for its own sake. + +- **A space switch settles.** Switching Mine, Model or Learn lifts a veil + over the window in about a fifth of a second, so the new space arrives + rather than snaps. The side panel on an analysis and Canvas ↔ Summary do + the same. +- **A box pulses once when it finishes**, green for done, red for failed, + so your eye finds what just changed on the canvas. +- **+ Add box fades in.** +- **Off switch:** set ``OPENPROCESS_NO_MOTION=1`` before starting the app + (for screenshots, slow machines, or if motion bothers you). +- **Fixed:** zoomed out on a net, the arrow for drawing an arc could stay + beside one node while the mouse was over its neighbour. Over a node the + arrow now always gives way, whatever the zoom. + +## 0.9.3 + +The patterns worth borrowing from the process-mining tools, without their +chrome. + +- **Summary: the key figure of every box, in one place.** An analysis has + *Canvas | Summary* in its header. The Summary is a tile per box, in the + order the boxes run: a score's first metric, a table's size, a log's + cases, a net's places and transitions. Nothing is computed there: a tile + is the box's result, as on the canvas, so it can never drift from the + workflow, and a box author gets a tile by returning a Scores. A tile + opens the box. +- **The process map in the Summary is a box's result too**: the + *Directly-follows graph* box's, drawn with a slider that hides the rarer + activities and paths of the drawing (the result is unchanged) and a + legend. An analysis without that box offers to add it, fed by the first + log, in one click. +- **The Model palette.** In Model, a net page's Select, Place, Transition + and Arc sit in the sidebar under the models, like a modeller's shape + palette. The page's own buttons come back when the sidebar is hidden. +- **Connections.** At the bottom of the sidebar (and View ▸ Connections): + the hub picture. What flows in (event logs, PNML, CPN Tools files, + transition systems, typed logs, datasets, simulations), OpenProcess in the + middle with its boxes and the folder's own, what flows out (PNML, XES, + CSV, figures, the experiment zip, Python), and the tools that plug in + (pandas, numpy, scipy, matplotlib, …), each saying whether it is + installed, how to install it, and which boxes it brings. All read from + the box library; nothing to declare. + +## 0.9.2 + +The handoffs between Mine and Model: four, each one button, each moving the +whole window and leaving a way back. + +- **Open a copy in Model ›** on a discovered net's Result. It was "Edit a + copy"; the word *copy* stays everywhere: the model is named "… (copy)", + its header says "‹ A copy, from Inductive Miner in Discover and check" (a + link back to the analysis), and the result in the workflow is never + changed. +- **A discovered net can be tidied in Mine, but not changed.** On a box's + Result, drag places and transitions to make the drawing easier to read; + the edges follow and the layout is saved with the workflow. Nothing can be + added, removed or renamed there: that is what the copy in Model is for. +- **Mine a simulated log ›** on a Petri net, a coloured net or a model page + (it was "Export as event log…" / "Generate event log…"). The log is saved + in the folder next to the model and opens in Mine as a new analysis with + an *Open log* box reading it. Without a folder it opens as a log page, as + before. +- **Check against a log ›** on a Petri net's Analysis tab. Pick a log and + Mine opens a new analysis with *Open net* (this net's file), *Open log* + and *Check fit*, connected and run. +- *Open net*'s Choose… and file list already offer the folder's models. + +## 0.9.1 + +Files flow into the canvas, so the analysis says what feeds it and the +sidebar is not the only way to a file. + +- **An input box names its file.** *Open log* reading boarding.xes shows + "INPUT · boarding.xes" on the canvas. +- **The folder's files are in + Add box.** Under *Input*, the folder's logs, + nets and transition systems are listed by name; one click adds the box + that reads the file, already set. Typing in the search finds them too. +- **Drop a file on the canvas** and it becomes its input box at that spot: a + log an *Open log*, a PNML file an *Open net*, a .cpn an *Open coloured + net*, a ts.txt an *Open transition system*. A file from outside the folder + is kept by its full path. +- **Mine's *Open log…* and *Log from notation…* land as boxes** on the + current analysis, or on a new one named after the file, instead of opening + a page of their own. (File ▸ Open… still opens pages.) +- **A page opened from a box has a way back.** *Open as log ›*, *Open as + model* and *Open as coloured net* now put "‹ Back to " above the + page's title; it returns to the analysis, switching the space if need be. + +## 0.9.0 + +Three spaces: **Mine**, **Model** and **Learn**, switched at the top of the +window. Process mining and net modelling no longer share a sidebar, so the +window always says which job you are doing. One folder underneath; nothing +moves on disk. + +- **Mine** is for event logs and analyses. Its sidebar lists the folder's + analyses (workflows) and, folded under them, its logs. Everything you open + or make here lands in this list: a log, a typed log, a comparison, a + transition system, an analysis. +- **Model** is for Petri nets and coloured nets. Its sidebar lists the + folder's models. New Petri net, New coloured net and Compare nets… live in + its footer. +- **Learn** is the exercise view, as before. From the switcher it opens the + folder's exercises, or the demo ones when the folder has none; Exit brings + you back to the space you left. +- **A document opens in its own space.** Open a net from Mine and the window + moves to Model; "Edit a copy" of a discovered net does the same. Each space + remembers what you had selected, and the folder remembers which space you + were in. +- **Flat lists, the same in both spaces.** The folder's own files come + first, then each subfolder's under a small caption naming it ("Week 5 / + Part 1"). Nothing is nested and nothing to unfold except LOGS, so the + sidebar reads the same way whichever space you are in. A folder with + nothing in it yet shows in both, so you can put things in it. Exercises + and their materials belong to Learn and are not listed in Mine or Model. +- **Gone:** the Folders / By kind toggle, the UNSAVED, EVENT LOGS, PETRI NETS, + MODELS, COLOURED NETS, TRANSITION SYSTEMS and WORKFLOWS headings in one + list, and "Untitled" drafts of nets sitting among logs. +- The welcome page has one card per space. View ▸ Mine, Model and Learn + (⌃⌥1, ⌃⌥2, ⌃⌥3) switch too. +- **A file box lists only the files it can read.** *Open log* offers the + folder's event logs, *Open net* its PNML files, *Open coloured net* its + .cpn files, *Open transition system* its ts.txt files, by name, with the + subfolder shown only when two names clash. *Choose…* filters the same way. + Picking from the list works on macOS now (it flickered shut before), and + the panel no longer runs off the edge in a folder with long paths. + ## 0.8.1 - **A file box has a *Choose…* button.** *Open log*, *Open net*, *Open diff --git a/README.md b/README.md index f370764..93b4e9d 100644 --- a/README.md +++ b/README.md @@ -205,11 +205,11 @@ handling (unsound — try Analysis)**. 2. Look through the tabs: *Overview*, *Variants*, *Cases*, *Dotted chart*, *Process map*, *Footprint*. **Filter…** keeps part of the log as a new log. -3. On the *Discover* tab, pick an algorithm: the model and how it was - derived update straight away. **Open as model →** adds the result to the sidebar (under **MODELS**, or - **UNSAVED** in a folder until you press **Keep** to save it there). -4. On the model's *Conformance* tab, choose a log and press **Check - conformance**. +3. On the *Discover* tab, pick an algorithm and press **Discover in Mine →**: + an analysis opens with the log and the miner connected and run. Click the + miner for its result, *How* it was derived, and the *Code* it ran. +4. **+ Add box ▸ Check fit** on that canvas, wired to the miner and the + log, scores fitness, precision and more, every step shown. **Simulate a coloured net** @@ -228,15 +228,22 @@ round. ![The folder Week 2 in the sidebar, with its subfolders, one net open and the others listed](docs/screenshots/workspace.png) +**Three spaces, one folder.** The switcher at the top of the window picks +**Mine** (event logs and analyses), **Model** (Petri nets and coloured nets) +or **Learn** (exercises). Each has its own sidebar, so mining and modelling +never share a list; a document opens in its space, and the folder remembers +which space you were in. + **In the sidebar** -- **Every event log and net in the folder** is listed, open or not. Files - that are not open yet are lighter: **click one to open it**. Closing a file - (✕ or ⌘W) puts it back in that state; it stays in the folder. -- **Folders** shows the folder as it is on disk, with collapsible subfolders - (folders first, then files, by name). **By kind** groups the files into - event logs, Petri nets and coloured nets instead. The choice, and which - subfolders are open, is remembered for each folder. +- **Mine lists the folder's analyses**, and its logs folded under them. + **Model lists its models.** Files that are not open yet are lighter: + **click one to open it**. Closing a file (✕ or ⌘W) puts it back in that + state; it stays in the folder. +- **Flat lists, the same in both spaces.** The folder's own files first, + then each subfolder's under a caption naming it ("Week 5 / Part 1"). + Nothing to unfold except LOGS. An empty folder shows in both spaces until + it has files. - **Organise from the app**: right-click for **New Folder…**, **Rename…**, **Show in Finder** and **Move to Bin** (recoverable from the Bin; never a hard delete). **Drag files onto a subfolder** to move them on disk. An open @@ -377,8 +384,10 @@ links to the definitions it builds on; **Help ▸ Definitions** lists them all. a log's footprint (α-algorithm, footprint conformance). - **Reachability graph…**, or a coverability graph with ω when the net is unbounded. -- **Conformance with a log…** opens the net as a model next to your logs, - for token replay and alignments. +- **Check against a log ›** (Analysis tab) builds an analysis in Mine with + this net, a log you pick and *Check fit*, connected and run. +- Every analysis card has a **{ } code** link that opens the function it is + computed by, so nothing here is a black box. | The counterexample, replayed | The footprint of the net | |---|---| @@ -389,8 +398,10 @@ links to the definitions it builds on; **Help ▸ Definitions** lists them all. - **Step through / Simulate:** the token game, fired by hand or at random. With **Trace** ticked, every fired transition shows its step numbers and the arcs the tokens used light up, the latest step strongest. -- **Generate event log…:** plays the net out many times and opens the - traces as a log. +- **Mine a simulated log ›:** plays the net out many times, saves the traces + as a log in the folder and opens Mine on a new analysis reading it. +- **Check against a log ›** (Analysis tab): picks a log and opens Mine on a + new analysis with this net, the log and *Check fit*, connected and run. - **Saving:** nets are saved as **PNML** (ProM, WoPeD and PM4Py read it), or as `.cpn`. *Save As* names the net after its file. With a folder open, a new net is `Untitled 1.pnml` in the folder from the start and is saved as @@ -399,8 +410,9 @@ links to the definitions it builds on; **Help ▸ Definitions** lists them all. - **Renaming:** double-click the net's name in the sidebar, or its title above the canvas. The file is renamed with it, in the same folder (an existing file is never overwritten). -- **Opening:** a `.pnml` file opens in this editor. A model you discovered - has **✎ Edit a copy** to bring it here. +- **Opening:** a `.pnml` file opens in this editor. A net discovered in Mine + has **Open a copy in Model ›** to bring a copy here; the original result is + never changed, and the copy's header links back to the analysis. ![Renaming a transition in place](docs/screenshots/petri-editing.png) @@ -436,21 +448,24 @@ links to the definitions it builds on; **Help ▸ Definitions** lists them all. - Time unit (Auto, or seconds up to years) and a grid step you can type in. - Colour and shape by any attribute. The default palette can be changed per value: right-click a value in the legend and pick its colour. -- **Discover:** +- **Discover** hands the log to an analysis in Mine (**Discover in Mine →**), + where the miner is a box with its result, its *How* and its *Code*: - α-algorithm, which shows its eight steps; - - Inductive Miner and IMf; - - Heuristics Miner, as a dependency graph or as a Petri net: which forks - are AND and which XOR is learned from the log (a causal net, whose - bindings are listed). Like in ProM, such a net fits its log but is not - always sound; - - **state-based regions**, two-phase: the log becomes a transition system - through a state function you choose (the prefix, postfix or both of each - event; as a set, multiset or sequence; over the last *k* events or all - of them), and its minimal regions become the places. The derivation - shows the transition system (pick a trace to light up the states it - passes through), the regions, GER and minimal pre- and post-regions of - every event, state separation and forward closure, and whether the - net's reachability graph is isomorphic to the transition system. + - Inductive Miner (with a noise setting for IMf); + - Heuristics Miner, as a Petri net: which forks are AND and which XOR is + learned from the log (a causal net, whose bindings are listed). Like in + ProM, such a net fits its log but is not always sound; + - **state-based regions**, two-phase: *Classical states* turns the log + into a transition system through a state function you choose (the + prefix, postfix or both of each event; as a set, multiset or sequence; + over the last *k* events or all of them), and *Regions to net* makes its + minimal regions the places. The derivation shows the transition system, + the regions, GER and minimal pre- and post-regions of every event, state + separation and forward closure, and whether the net's reachability graph + is isomorphic to the transition system. + + In Learn, an exercise's log page keeps the miners on the page, since the + exercise is the context there. - **Transition systems** (File ▸ New Transition System…, or a `ts.txt` file): type one as `s0 -a-> s1, s0 -b-> s2` and get the same region analysis and synthesis. *Is this a region?* answers yes or no for any set of states @@ -715,20 +730,40 @@ Miner, a fitness check), *Compare discovery* (three miners side by side), confidence* (bootstrap intervals, a test and a plot; needs the `science` extra) and *Predict the next activity*. Or start empty. -- **The page is the canvas.** Three buttons in its header: **+ Add box**, - **Run ▶**, and **⋯** for Re-run, Record, Export experiment and Save. +- **The page is the canvas.** Its header has **Canvas | Summary**, then + **+ Add box**, **Run ▶**, and **⋯** for Re-run, Record, Export experiment + and Save. The sidebar beside it lists the folder's analyses and, folded + under them, its logs. - **+ Add box** (or a double-click on the canvas) opens every box, by group: the six core groups side by side (Input, Filter, Discover, Check, Compare, Output) and one line for the rest (Science, Predict, Coloured nets, Sweep, - your own boxes) with *Show all*. Type to search them all: "alpha" finds the - α-algorithm; Enter adds the first match. + your own boxes) with *Show all*. Under *Input*, **the folder's own files**: + one click adds the box that reads the file, already set. Type to search + them all: "alpha" finds the α-algorithm, "boarding" the log; Enter adds the + first match. +- **Files flow into the canvas.** Drop a log, a PNML file, a .cpn or a + ts.txt on the canvas and it becomes its input box at that spot; an input + box names its file ("INPUT · boarding.xes"). *Open log…* and *Log from + notation…* in the sidebar land as boxes on the current analysis (or a new + one named after the file). In a box's **Settings**, *Choose…* picks the + file and the list beside it offers only the files the box can read. +- **Summary** is the key figure of every box, in one place: a tile per box, + in the order the boxes run (a score's first metric, a table's size, a + log's cases, a net's places and transitions). Nothing is computed there: + a tile is the box's result, as on the canvas, and a tile opens the box. + The process map under the tiles is the *Directly-follows graph* box's + result; an analysis without that box offers to add it in one click. - **Connect** by dragging from the dot on the right of a box: while you drag, only the inputs that take that kind of result light up, so a wrong connection cannot be made. Click a wire and press Delete to remove it. - **Click a box** and the side panel appears beside the canvas, on **Result** (the net, the log's figures, the table, the figure; a log's Result has *Open as log ›* for the dotted chart, process map, footprint, - variants and cases). Its other tabs: **How** (what the box reported: notes, + variants and cases, and a net's has *Open a copy in Model ›*). A page + opened this way shows "‹ Back to " above its title. A + discovered net can be tidied on the Result tab by dragging its places and + transitions (the layout is saved with the workflow); the net itself is not + changed there. Its other tabs: **How** (what the box reported: notes, intermediate values, the derivation), **Code** (the box's few lines and, under them, the actual algorithm it calls: the α-algorithm's eight steps, the Inductive Miner's cuts, with the work each follows and *Whole file* for @@ -754,6 +789,14 @@ extra) and *Predict the next activity*. Or start empty. material for a paper, which `openprocess run --check` can verify. - **The side panel** resizes by dragging the gap beside it; double-click the gap, or *View ▸ Reset Workflow Layout*, for its default width. +- **Connections** (at the bottom of the sidebar, or *View ▸ Connections*) + draws what flows in, OpenProcess and its boxes, what flows out, and the + optional tools (pandas, numpy, scipy, matplotlib, …) with whether each is + installed, how to install it and the boxes it brings. All of it is read + from the box library. +- **Motion** is kept short: a space switch, the side panel and Canvas ↔ + Summary settle in a fifth of a second, and a box pulses once when it + finishes. `OPENPROCESS_NO_MOTION=1` turns it off. ## Workflows from Python diff --git a/openprocess/__init__.py b/openprocess/__init__.py index c71b325..9842f92 100644 --- a/openprocess/__init__.py +++ b/openprocess/__init__.py @@ -48,7 +48,7 @@ #: The one place the version number is written (pyproject.toml, the update #: check and packaging/build.py read it from here). A release is tagged #: "v" + this, e.g. v0.2.0. -__version__ = "0.8.1" +__version__ = "0.9.5" __all__ = [ "Arc", "BindingElement", "CPNet", "Marking", "Multiset", "Page", "Place", diff --git a/openprocess/flow/workflow.py b/openprocess/flow/workflow.py index 5bea957..5f93ccc 100644 --- a/openprocess/flow/workflow.py +++ b/openprocess/flow/workflow.py @@ -54,6 +54,9 @@ class Node: position: tuple[float, float] = (0.0, 0.0) #: A name the user gave it (empty: the box's name). title: str = "" + #: Where the user dragged the places and transitions of this box's result + #: (a net), by their id, to tidy the drawing; the net itself is untouched. + layout: dict[str, tuple[float, float]] = field(default_factory=dict) def swept(self) -> list[tuple[str, Sweep]]: return [(name, value) for name, value in self.settings.items() if is_sweep(value)] @@ -314,7 +317,8 @@ def to_dict(self) -> dict: "name": self.name, "boxes": [{"id": n.id, "box": n.box, "settings": {k: (v.to_json() if is_sweep(v) else _json_value(v)) for k, v in n.settings.items()}, - "position": list(n.position), **({"title": n.title} if n.title else {})} + "position": list(n.position), **({"title": n.title} if n.title else {}), + **({"layout": {k: list(v) for k, v in n.layout.items()}} if n.layout else {})} for n in self.nodes.values()], "connections": [{"from": e.source, "output": e.output, "to": e.target, "input": e.input} for e in self.edges], @@ -329,10 +333,14 @@ def from_dict(cls, data: dict, library=None) -> "Workflow": settings = {k: Sweep(tuple(v["sweep"])) if isinstance(v, dict) and "sweep" in v else v for k, v in item.get("settings", {}).items()} try: - wf.add(item["box"], settings, tuple(item.get("position", (0, 0))), id=item["id"], - title=item.get("title", "")) + node = wf.add(item["box"], settings, tuple(item.get("position", (0, 0))), id=item["id"], + title=item.get("title", "")) except KeyError: raise WorkflowError(f"The workflow uses a box that is not available: {item['box']}") + layout = item.get("layout") or {} + if isinstance(layout, dict): + node.layout = {str(k): (float(v[0]), float(v[1])) for k, v in layout.items() + if isinstance(v, (list, tuple)) and len(v) == 2} for item in data.get("connections", []): wf.connect(item["from"], item["to"], item["input"], item.get("output", "out")) for item in data.get("groups", []): diff --git a/openprocess/gui/canvas.py b/openprocess/gui/canvas.py index cbbb51d..abc6cdf 100644 --- a/openprocess/gui/canvas.py +++ b/openprocess/gui/canvas.py @@ -999,13 +999,20 @@ def _update_connect_handle(self, position: QPointF) -> None: self.hide_connect_handle() return scale = self._view_scale() + nodes = [*self.place_items.values(), *self.transition_items.values()] + if any(item.contains(item.mapFromScene(position)) for item in nodes): + # Over a node the arrow is never shown, whatever the zoom: zoomed + # out, the arrow's hit zone (14 screen px) can reach a neighbour, + # and the node must win there. + self.hide_connect_handle() + return if self._handle_hit(position): self._handle.set_hot(True) return import math reach = self.HANDLE_REACH / scale # The nearest, not the first: zoomed out, the reach can span several nodes. - near = [item for item in [*self.place_items.values(), *self.transition_items.values()] + near = [item for item in nodes if item.sceneBoundingRect().adjusted(-reach, -reach, reach, reach) .contains(position)] node = min(near, key=lambda item: math.hypot(*(position - item.pos()).toTuple()), diff --git a/openprocess/gui/flow/canvas.py b/openprocess/gui/flow/canvas.py index 321b290..7f61392 100644 --- a/openprocess/gui/flow/canvas.py +++ b/openprocess/gui/flow/canvas.py @@ -24,8 +24,9 @@ from __future__ import annotations import math +from pathlib import Path -from PySide6.QtCore import QPointF, QRectF, Qt, Signal +from PySide6.QtCore import Property, QEasingCurve, QPointF, QPropertyAnimation, QRectF, Qt, Signal from PySide6.QtGui import QColor, QFont, QFontMetricsF, QPainter, QPainterPath, QPen from PySide6.QtWidgets import QGraphicsItem, QGraphicsObject, QGraphicsPathItem, QGraphicsScene @@ -34,7 +35,7 @@ from ...flow.types import type_info from ...flow.workflow import Edge, Group, Node, Workflow from .. import theme -from ..studio import style +from ..studio import motion, style from ..studio.graph_view import GraphView NODE_W = 178.0 @@ -73,6 +74,8 @@ def __init__(self, scene: "WorkflowScene", node: Node) -> None: self.highlight: set[str] = set() # inputs that fit the wire being dragged self.dimmed = False self.locked = False + self._glow = 0.0 # a ring around the dot that swells once when the box finishes + self.pulse_animation: QPropertyAnimation | None = None self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemSendsGeometryChanges) self.setAcceptHoverEvents(True) @@ -116,14 +119,48 @@ def port_at(self, point: QPointF) -> tuple[str, Port] | None: # -- state --------------------------------------------------------------------------- def set_status(self, status: str, subtitle: str, tooltip: str = "") -> None: + finished = status in (DONE, FAILED) and status != self.status self.status, self.subtitle = status, subtitle self.setToolTip(tooltip) self.update() + if finished: + self.pulse() + + def get_glow(self) -> float: + return self._glow + + def set_glow(self, value: float) -> None: + self._glow = float(value) + self.update() + + glow = Property(float, get_glow, set_glow) + + def pulse(self) -> None: + """The status dot swells once: the box has just finished.""" + if not motion.enabled: + return + animation = QPropertyAnimation(self, b"glow", self) + animation.setDuration(480) + animation.setStartValue(0.0) + animation.setKeyValueAt(0.35, 1.0) + animation.setEndValue(0.0) + animation.setEasingCurve(QEasingCurve.OutCubic) + self.pulse_animation = animation + animation.start() def set_highlight(self, inputs: set[str], dimmed: bool) -> None: self.highlight, self.dimmed = inputs, dimmed self.update() + def eyebrow(self) -> str: + """The small line over the title: the group, and the file a box reads + ("INPUT · boarding.xes"), so the canvas says what feeds it.""" + text = ("Yours · " if self.spec.custom else "") + self.spec.group.upper() + for setting in self.spec.settings: + if setting.kind == "path" and self.node.settings.get(setting.name) not in (None, ""): + return f"{text} · {Path(str(self.node.settings[setting.name])).name}" + return text + # -- Qt -------------------------------------------------------------------------------- def itemChange(self, change, value): # noqa: N802 if change == QGraphicsItem.ItemPositionChange and not self.locked: @@ -177,12 +214,18 @@ def paint(self, painter: QPainter, option, widget=None) -> None: # Eyebrow: the group (and "Yours" for a custom box). painter.setPen(QColor(t.text_muted)) painter.setFont(_font(8.5, True)) - eyebrow = ("Yours · " if self.spec.custom else "") + self.spec.group.upper() - painter.drawText(QPointF(12, 16), eyebrow) + painter.drawText(QPointF(12, 16), QFontMetricsF(painter.font()).elidedText( + self.eyebrow(), Qt.ElideMiddle, NODE_W - 36)) # Status dot. colour = STATUS_COLOUR.get(self.status, "muted") dot = QColor({"good": style.STATUS["good"], "accent": t.accent, "critical": style.STATUS["critical"], "warning": style.STATUS["warning"], "muted": t.text_muted}[colour]) + if self._glow > 0: + ring = QColor(dot) + ring.setAlphaF(0.45 * (1 - self._glow)) + painter.setPen(Qt.NoPen) + painter.setBrush(ring) + painter.drawEllipse(QPointF(NODE_W - 14, 13), 4.5 + 9 * self._glow, 4.5 + 9 * self._glow) painter.setPen(QPen(dot, 1.5)) painter.setBrush(dot if self.status not in (WAITING, IDLE) else Qt.NoBrush) painter.drawEllipse(QPointF(NODE_W - 14, 13), 4.5, 4.5) @@ -823,13 +866,38 @@ def contextMenuEvent(self, event) -> None: # noqa: N802 class WorkflowView(GraphView): - """The canvas view: panning, zooming and the floating zoom bar, from GraphView.""" + """The canvas view: panning, zooming and the floating zoom bar, from + GraphView; files dropped on it become input boxes (:attr:`files_dropped`).""" drawing_name = "The workflow" + #: Local files were dropped: their paths, and where (scene coordinates). + files_dropped = Signal(list, QPointF) def __init__(self, scene: WorkflowScene, parent=None) -> None: super().__init__(scene, parent) self.setDragMode(self.DragMode.RubberBandDrag) + self.setAcceptDrops(True) + self.viewport().setAcceptDrops(True) + + def dragEnterEvent(self, event) -> None: # noqa: N802 + if event.mimeData().hasUrls(): + event.acceptProposedAction() + else: + super().dragEnterEvent(event) + + def dragMoveEvent(self, event) -> None: # noqa: N802 + if event.mimeData().hasUrls(): + event.acceptProposedAction() + else: + super().dragMoveEvent(event) + + def dropEvent(self, event) -> None: # noqa: N802 + paths = [url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile()] + if not paths: + super().dropEvent(event) + return + event.acceptProposedAction() + self.files_dropped.emit(paths, self.mapToScene(event.position().toPoint())) def fit(self) -> None: self.auto_fit = True diff --git a/openprocess/gui/flow/page.py b/openprocess/gui/flow/page.py index 918d464..ad5c92f 100644 --- a/openprocess/gui/flow/page.py +++ b/openprocess/gui/flow/page.py @@ -29,6 +29,7 @@ from PySide6.QtCore import QEvent, QObject, QPoint, QPointF, Qt, QTimer, Signal from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtWidgets import ( + QStackedWidget, QDialog, QFileDialog, QLineEdit, QMenu, QMessageBox, QPlainTextEdit, QSplitter, QVBoxLayout, QWidget, ) @@ -38,11 +39,12 @@ from ...flow.types import EventLog, Figure, PetriNet, Scores, Table from ...flow.workflow import Edge, Workflow, group_to_python, to_python from .. import theme -from ..studio import style +from ..studio import motion, style from ..studio.widgets import Card, NoticeBar, PageHeader, SegmentedControl, button, hbox, label, scroll, vbox from ..studio.workers import run_in_background from .canvas import WorkflowScene, WorkflowView -from .picker import BoxPicker +from .picker import BoxPicker, input_box_for +from .summary import SummaryWidget from .viewers import SettingsWidget, code_widget, how_widget, pop_out, result_widget, status_text TABS = ["Result", "How", "Code", "Settings"] @@ -95,8 +97,8 @@ class WorkflowPage(QWidget): edited = Signal() #: A result should open as a page of its own (a LogDocument, ModelDocument, CpnDocument). open_document = Signal(object) - #: "Edit a copy" of a net: open it on the net canvas. - edit_requested = Signal(object) + #: "Open a copy in Model" of a net: the net, and where it came from ("from Inductive Miner in …"). + edit_requested = Signal(object, str) keep_requested = Signal() #: The user allowed the folder's custom boxes to run. custom_allowed_changed = Signal() @@ -116,7 +118,10 @@ def __init__(self, document, library: Library, folder: str | Path | None = None, self.runner = Runner(library, Cache(), folder=self.folder) self.run: Run | None = None self._running = False + #: Boxes to run once the current run is over (None: all of them), and + #: whether anything is queued at all (None alone would be ambiguous). self._queued: list[str] | None = None + self._has_queue = False self._stop = threading.Event() self._relay = _Relay() self._relay.status.connect(self._status_arrived) @@ -136,6 +141,12 @@ def __init__(self, document, library: Library, folder: str | Path | None = None, root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) self.header = PageHeader(self.workflow.name, self._subtitle()) + # Canvas: the boxes and wires. Summary: every result's figure, and the process map. + self.summary_switch = SegmentedControl(["Canvas", "Summary"], compact=True) + self.summary_switch.setToolTip("Canvas: the boxes and wires.\nSummary: every result's key " + "figure, and the process map of the first log.") + self.summary_switch.changed.connect(self.show_view) + self.header.actions.addWidget(self.summary_switch) self.keep_button = button("Keep", self.keep_requested.emit, kind="primary", tooltip="Save this workflow into the folder") self.keep_button.setVisible(False) @@ -173,6 +184,7 @@ def __init__(self, document, library: Library, folder: str | Path | None = None, self.scene.menu.connect(self._menu) self.scene.refused.connect(lambda text: self.status.emit(text)) self.scene.open_group.connect(self.open_group) + self.view.files_dropped.connect(self.add_files) # The canvas, and a side panel that appears when a box is clicked. # Drag the gap to resize the panel; double-click it for the default. @@ -204,7 +216,14 @@ def __init__(self, document, library: Library, folder: str | Path | None = None, splitter.reset_requested.connect(self.reset_layout) wrapper = QWidget() wrapper.setLayout(vbox(splitter, margins=(20, 0, 20, 16))) - root.addWidget(wrapper, 1) + self.summary = SummaryWidget(self) + self.summary.tile_clicked.connect(self._tile_clicked) + summary_wrapper = QWidget() + summary_wrapper.setLayout(vbox(scroll(self.summary), margins=(20, 0, 20, 16))) + self.body = QStackedWidget() + self.body.addWidget(wrapper) + self.body.addWidget(summary_wrapper) + root.addWidget(self.body, 1) self._picker: BoxPicker | None = None QShortcut(QKeySequence(Qt.Key_Delete), self.view, self.scene.remove_selected) @@ -227,12 +246,30 @@ def show_panel(self, show: bool) -> None: self.panel.setVisible(show) if show: self.splitter.setSizes(_sizes(WorkflowPage.LAYOUT.get("sizes"))) + QTimer.singleShot(0, lambda: motion.lift(self.panel)) # once it has its width self._layout_changed() def close_panel(self) -> None: """The panel's ✕: nothing selected, the canvas alone.""" self.select(None) + def show_view(self, index: int) -> None: + """Canvas (0) or Summary (1); the Summary is rebuilt from the run when shown.""" + if self.body.currentIndex() != index: + self.body.setCurrentIndex(index) + motion.lift(self.body) + if self.summary_switch.index() != index: + self.summary_switch.blockSignals(True) + self.summary_switch.buttons[index].setChecked(True) + self.summary_switch.blockSignals(False) + if index == 1: + self.summary.refresh() + + def _tile_clicked(self, node_id: str) -> None: + """A Summary tile: that box's Result, on the canvas.""" + self.show_view(0) + self.select(node_id, 0) + def reset_layout(self) -> None: """The side panel at its default width.""" self.splitter.setSizes([max(400, self.splitter.width() - 380), 380]) @@ -266,38 +303,95 @@ def picker(self) -> BoxPicker: """The *+ Add box* popover, built fresh each time (the library may have changed).""" if self._picker is not None: self._picker.deleteLater() - self._picker = BoxPicker(self.library, self) + self._picker = BoxPicker(self.library, self, self.folder) return self._picker def add_box_menu(self) -> None: """*+ Add box*: the picker under the button; the box lands in free space on the canvas.""" picker = self.picker() picker.chosen.connect(self.add_box_in_view) + picker.file_chosen.connect(lambda box_id, relative: self.add_box_in_view(box_id, {"file": relative})) corner = self.add_button.mapToGlobal(QPoint(0, self.add_button.height() + 4)) picker.open_at(QPoint(corner.x() + self.add_button.width() - picker.card_size()[0], corner.y())) - def add_box_in_view(self, box_id: str) -> None: - """Add ``box_id`` where it can be seen: bottom-left of the view, staggered.""" + def free_spot(self) -> QPointF: + """Where a new box lands when nowhere was pointed at: bottom-left of the view, staggered.""" count = len(self.workflow.nodes) % 6 rect = self.view.mapToScene(self.view.viewport().rect()).boundingRect() - self.add_box(box_id, QPointF(rect.left() + 30 + count * 24, rect.bottom() - 150 - count * 12)) + return QPointF(rect.left() + 30 + count * 24, rect.bottom() - 150 - count * 12) + + def add_box_in_view(self, box_id: str, settings: dict | None = None): + """Add ``box_id`` where it can be seen.""" + return self.add_box(box_id, self.free_spot(), settings) def quick_add(self, where: QPointF) -> None: """A double-click (or *Add box here…*) at ``where`` (scene coordinates): the picker there.""" picker = self.picker() - picker.chosen.connect(lambda box_id: self.add_box(box_id, QPointF(where.x() - 89, where.y() - 30))) + at = QPointF(where.x() - 89, where.y() - 30) + picker.chosen.connect(lambda box_id: self.add_box(box_id, at)) + picker.file_chosen.connect(lambda box_id, relative: self.add_box(box_id, at, {"file": relative})) picker.open_at(self.view.mapToGlobal(self.view.mapFromScene(where))) - def add_box(self, box_id: str, where: QPointF) -> None: - node = self.scene.add_node(box_id, where) + def add_box(self, box_id: str, where: QPointF, settings: dict | None = None): + """Put a box on the canvas (with ``settings``, e.g. the file it reads) and say so.""" + node = self.scene.add_node(box_id, where, settings) spec = self.workflow.spec(node) unchosen = [s for s in spec.settings if s.kind == "path" and node.settings.get(s.name) in (None, "")] + chosen = [s for s in spec.settings if s.kind == "path" and node.settings.get(s.name) not in (None, "")] if unchosen: # A box that needs a file: open its Settings, where the file is chosen. self.select(node.id, 3) self.status.emit(f"Added {spec.name}: press Choose… under {unchosen[0].name} in Settings, on the right") + elif chosen: + self.select(node.id) + self.status.emit(f"Added {spec.name} reading {Path(str(node.settings[chosen[0].name])).name}: " + "drag from the dot on its right to connect it") else: self.status.emit(f"Added {spec.name}: drag from a dot on the right of a box to connect it") + return node + + def add_box_fed_by(self, box_id: str, source_id: str | None): + """Add a box and connect it to ``source_id``'s output (the first input + that takes it), as dropping a wire would; then it runs.""" + node = self.add_box_in_view(box_id) + if source_id is not None and source_id in self.workflow.nodes: + ok, detail = self.workflow.can_connect(source_id, node.id) + if ok: + self.workflow.connect(source_id, node.id) + self.scene.rebuild() + self._edited([node.id]) + else: + self.status.emit(detail) + return node + + def add_files(self, paths: list[str], where: QPointF | None = None) -> list: + """Files dropped on the canvas (or chosen for it) become input boxes: + a log an *Open log*, a PNML file an *Open net*, and so on, at ``where`` + (None: in free space). A file inside the workflow's folder is kept by + its relative path, one elsewhere by its full path. Returns the nodes.""" + from ..studio.workspace import file_kind + added, refused = [], [] + for index, path in enumerate(paths): + kind = file_kind(Path(path)) + box_id = input_box_for(self.library, kind) if kind else None + if kind == "workflow": + refused.append(f"{Path(path).name} is a workflow: open it from the sidebar") + continue + if box_id is None: + refused.append(f"{Path(path).name}: not a log, net or transition system") + continue + value = str(Path(path)) + if self.folder is not None: + try: + value = Path(path).resolve().relative_to(Path(self.folder).resolve()).as_posix() + except ValueError: + pass + spot = self.free_spot() if where is None else QPointF(where.x() - 89 + index * 24, + where.y() - 30 + index * 70) + added.append(self.add_box(box_id, spot, {"file": value})) + if refused: + self.status.emit("; ".join(refused)) + return added self.refresh_title() def reload_library(self) -> None: @@ -333,9 +427,14 @@ def allow_custom(self) -> None: def run_from(self, changed: list[str] | None) -> None: """Run the boxes from ``changed`` on (all of them: None), in the background.""" if self._running: - self._queued = None if changed is None or self._queued is None else sorted(set(self._queued) | set(changed)) - if changed is None: + # Queue it for after this run (stopping the run early); "all" wins + # over any list. Before, a box added during a run was queued as + # "all", which then counted as nothing queued: it never ran. + if changed is None or (self._has_queue and self._queued is None): self._queued = None + else: + self._queued = sorted(set(self._queued or []) | set(changed)) + self._has_queue = True self._stop.set() return problems = [p for p in self.workflow.validate() if "connect" not in p.lower()] @@ -411,8 +510,17 @@ def _run_finished(self, outcome) -> None: self.status.emit(f"The run stopped: {outcome}") self.refresh_title() self._render_panel() - if self._queued is not None or (isinstance(outcome, Run) and outcome.stopped): - queued, self._queued = self._queued, None + if self.body.currentIndex() == 1: + self.summary.refresh() + if self._has_queue or (isinstance(outcome, Run) and outcome.stopped): + queued, self._queued, self._has_queue = self._queued, None, False + if queued is not None and isinstance(outcome, Run) and outcome.stopped: + # The stopped run left boxes unfinished: they run again too, or + # a box added during a run would never get its result. + unfinished = [node.id for node in self.workflow.nodes.values() + if outcome.result(node) is None + or outcome.result(node).status in (WAITING, RUNNING)] + queued = sorted(set(queued) | set(unfinished)) self.run_from(queued) def rerun(self) -> None: @@ -452,12 +560,17 @@ def _apply_pending_settings(self) -> None: for node_id, values in pending.items(): if node_id not in self.workflow.nodes: continue + before = dict(self.workflow.nodes[node_id].settings) try: self.workflow.set(node_id, **values) except (ValueError, TypeError) as error: self.status.emit(str(error)) continue - changed.append(node_id) + if self.workflow.nodes[node_id].settings != before: # the same value again: nothing to run + changed.append(node_id) + item = self.scene.boxes.get(node_id) + if item is not None: + item.update() # the eyebrow may name a new file if changed: self._mark_edited() self.run_from(changed) @@ -691,9 +804,9 @@ def _tab_widget(self, node, spec, result: Result | None, index: int) -> QWidget: column.setContentsMargins(0, 0, 0, 0) for port in spec.outputs: column.addWidget(label(port.name.upper(), "sectionLabel")) - column.addWidget(result_widget(result.values.get(port.name), self)) + column.addWidget(result_widget(result.values.get(port.name), self, node)) return host - widget = result_widget(result.value, self) + widget = result_widget(result.value, self, node) if self.run and len(self.run.variants) > 1 and self.run.result(node, 1) is not None: host = QWidget() host.setLayout(vbox(label(f"The first of {len(self.run.variants)} sweep values; the collecting " @@ -723,20 +836,31 @@ def pop_out_tab(self) -> None: pop_out(title, lambda: self._tab_widget(node, spec, result, index), self.window()) # -- results opening elsewhere ------------------------------------------------------------ + def _open_elsewhere(self, document) -> None: + """Open a result on its own page, which keeps a way back here.""" + document.opened_from = self.document + self.open_document.emit(document) + def open_as_log(self, log: EventLog) -> None: from ..studio.documents import LogDocument - self.open_document.emit(LogDocument(log)) + self._open_elsewhere(LogDocument(log)) def open_as_model(self, net: PetriNet) -> None: from ..studio.documents import ModelDocument - self.open_document.emit(ModelDocument(net, origin=f"from {self.workflow.name}")) + self._open_elsewhere(ModelDocument(net, origin=f"from {self.workflow.name}")) def open_as_cpn(self, net) -> None: from ..studio.documents import CpnDocument - self.open_document.emit(CpnDocument(net)) + self._open_elsewhere(CpnDocument(net)) def edit_copy(self, net: PetriNet) -> None: - self.edit_requested.emit(net) + """*Open a copy in Model ›*: the window opens a copy on the net canvas.""" + where = self.workflow.title(self.selected) if self.selected in self.workflow.nodes else "a box" + self.edit_requested.emit(net, f"from {where} in {self.workflow.name}") + + def layout_tidied(self) -> None: + """The user dragged a result's places or transitions: kept with the workflow.""" + self._mark_edited() def save_figure(self, figure: Figure) -> None: from ..studio.widgets import suggested_path diff --git a/openprocess/gui/flow/picker.py b/openprocess/gui/flow/picker.py index 794f23b..7d8eb88 100644 --- a/openprocess/gui/flow/picker.py +++ b/openprocess/gui/flow/picker.py @@ -19,12 +19,46 @@ from ...flow.library import GROUP_ORDER, Library from .. import theme -from ..studio import style +from ..studio import motion, style from ..studio.widgets import hbox, label, vbox +from ..studio.workspace import display_name, file_kind #: The groups shown before *Show all*: what most workflows are made of. CORE_GROUPS = GROUP_ORDER[:6] COLUMNS = 3 +#: How many of the folder's files the Input group lists before "… more". +FILE_LIMIT = 6 +#: The input box for each kind of file in the folder (see workspace.file_kind). +INPUT_BOXES = {"log": "open_log", "petri": "open_net", "cpn": "open_cpn", + "ts": "open_transition_system", "typed": "typed_log"} + + +def input_box_for(library: Library, kind: str | None) -> str | None: + """The id of the box that reads files of ``kind`` (None: no such box).""" + function = INPUT_BOXES.get(kind) + if function is None: + return None + for spec in library.specs.values(): + if spec.id.endswith("." + function) or spec.id == function: + return spec.id + return None + + +def folder_files(folder: str | Path | None, limit: int = 200) -> list[tuple[str, str]]: + """(relative path, kind) of the folder's logs, nets and transition systems.""" + if folder is None or not Path(folder).is_dir(): + return [] + found = [] + for path in sorted(Path(folder).rglob("*")): + if len(found) >= limit: + break + if not path.is_file() or path.name.startswith(".") or any( + part.startswith(".") for part in path.relative_to(folder).parts[:-1]): + continue + kind = file_kind(path) + if kind in INPUT_BOXES: + found.append((path.relative_to(folder).as_posix(), kind)) + return found #: Room around the card for its shadow, inside the see-through popup window. SHADOW_MARGIN = 24 @@ -37,8 +71,11 @@ class BoxPicker(QWidget): menus rather than a square box with a hard edge.""" chosen = Signal(str) + #: One of the folder's files was picked: the input box's id and the file, relative to the folder. + file_chosen = Signal(str, str) - def __init__(self, library: Library, parent: QWidget | None = None) -> None: + def __init__(self, library: Library, parent: QWidget | None = None, + folder: str | Path | None = None) -> None: super().__init__(parent, Qt.Popup | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint) self.setAttribute(Qt.WA_TranslucentBackground) self.card = QFrame() @@ -49,7 +86,9 @@ def __init__(self, library: Library, parent: QWidget | None = None) -> None: shadow.setColor(QColor(0, 0, 0, 110 if theme.is_dark() else 50)) self.card.setGraphicsEffect(shadow) self.library = library + self.folder = folder self.show_all = False + self.file_buttons: list[QPushButton] = [] self.search = QLineEdit() self.search.setObjectName("boxSearch") self.search.setPlaceholderText("Search boxes, e.g. alpha, filter, fitness") @@ -86,6 +125,7 @@ def open_at(self, point: QPoint) -> None: y = max(screen.top(), min(y, screen.bottom() - height)) self.move(x - SHADOW_MARGIN, y - SHADOW_MARGIN) self.show() + motion.fade_in(self) self.search.setFocus() def card_size(self) -> tuple[int, int]: @@ -115,7 +155,7 @@ def refill(self) -> None: widget.hide() # gone now, not at the next event loop turn: no ghosts under the new rows widget.setParent(None) widget.deleteLater() - self.buttons = [] + self.buttons, self.file_buttons = [], [] t = style.tokens() groups = self.shown_groups() for index, (group, specs) in enumerate(groups.items()): @@ -137,9 +177,23 @@ def refill(self) -> None: choice.clicked.connect(lambda _checked=False, box_id=spec.id: self._choose(box_id)) rows.addWidget(choice) self.buttons.append(choice) + if group == "Input": + self._add_files(rows) rows.addStretch(1) self.grid.addWidget(column, index // COLUMNS, index % COLUMNS, Qt.AlignTop) - if not groups: + files, _more = self.shown_files() + if "Input" not in groups and files: + # A search that matches files but no box: still a column for them. + column = QWidget() + column.setObjectName("plain") + rows = QVBoxLayout(column) + rows.setContentsMargins(0, 0, 0, 0) + rows.setSpacing(2) + rows.addWidget(label("INPUT", "sectionLabel")) + self._add_files(rows) + rows.addStretch(1) + self.grid.addWidget(column, len(groups) // COLUMNS, len(groups) % COLUMNS, Qt.AlignTop) + elif not groups: self.grid.addWidget(label("No box matches. Try another word, or clear the search.", "muted", wrap=True), 0, 0, 1, COLUMNS) for broken in self.library.broken if (self.show_all or self.search.text().strip()) else []: @@ -150,6 +204,49 @@ def refill(self) -> None: self._refresh_footer() self.adjustSize() + def shown_files(self) -> tuple[list[tuple[str, str]], int]: + """The folder's files to list under Input (those with an input box), + and how many more there are. A search looks through all of them.""" + wanted = self.search.text().strip().lower() + files = [(relative, kind) for relative, kind in folder_files(self.folder) + if input_box_for(self.library, kind) is not None + and all(word in relative.lower() for word in wanted.split())] + limit = 12 if wanted else FILE_LIMIT + return files[:limit], max(0, len(files) - limit) + + def _add_files(self, rows: QVBoxLayout) -> None: + """Under the Input boxes: the folder's files, one click each.""" + files, more = self.shown_files() + if not files: + return + from ..studio.app import _faded_icon # the sidebar's file icons (no cycle: app is loaded) + rows.addSpacing(6) + rows.addWidget(label("IN THIS FOLDER", "sectionLabel")) + names = [Path(relative).name for relative, _kind in files] + icons = {"log": "log", "cpn": "cpn", "ts": "ts", "petri": "model"} + for relative, kind in files: + name = Path(relative).name + text = display_name(name) + if names.count(name) > 1 and "/" in relative: + text += f" ({Path(relative).parent})" + choice = QPushButton(text) + choice.setObjectName("boxChoice") + choice.setIcon(_faded_icon(icons.get(kind, "model"))) + choice.setCursor(Qt.PointingHandCursor) + choice.setToolTip(f"{relative}\nAdds an input box that reads this file") + choice.clicked.connect(lambda _checked=False, r=relative, k=kind: self._choose_file(k, r)) + rows.addWidget(choice) + self.file_buttons.append(choice) + if more: + rows.addWidget(label(f"… {more} more (Choose… in Settings)", "muted")) + + def _choose_file(self, kind: str, relative: str) -> None: + box_id = input_box_for(self.library, kind) + if box_id is None: + return + self.close() + self.file_chosen.emit(box_id, relative) + def _refresh_footer(self) -> None: searching = bool(self.search.text().strip()) rest = [g for g in self.library.by_group() if g not in CORE_GROUPS] @@ -192,4 +289,4 @@ def _plain(group: str) -> str: return "your own boxes" if group == "Yours" else group -__all__ = ["BoxPicker", "CORE_GROUPS"] +__all__ = ["BoxPicker", "CORE_GROUPS", "folder_files", "input_box_for"] diff --git a/openprocess/gui/flow/summary.py b/openprocess/gui/flow/summary.py new file mode 100644 index 0000000..252e97f --- /dev/null +++ b/openprocess/gui/flow/summary.py @@ -0,0 +1,234 @@ +"""The *Summary* of an analysis: the key figure of every box, in one place. + +Nothing here is computed on its own. A tile is a box's result, as on the +canvas: a score's first metric, a table's size, a log's cases, a net's +places and transitions, in the order the boxes run, named after the box. A +click on a tile opens that box's Result on the canvas. The process map +under the tiles is the result of a *Directly-follows graph* box when the +analysis has one; when it has none, one click adds the box, fed by the +first log, so the map too is something you can click, read and re-run. +""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtWidgets import QFrame, QLabel, QSlider, QSizePolicy, QVBoxLayout, QWidget + +from ...flow.types import DFG, EventLog, Figure, PetriNet, Scores, Table, Text +from ..studio import style +from ..studio.graph_builders import dfg_specs +from ..studio.graph_view import GraphView +from ..studio.widgets import Card, ElidedLabel, Legend, LegendSwatch, button, flow, hbox, label + +#: How many tiles at most (an analysis with a sweep can have hundreds of results). +TILE_LIMIT = 24 + + +def _number(value) -> str: + if isinstance(value, bool): + return "yes" if value else "no" + if isinstance(value, float): + return f"{value:.2f}" if abs(value) < 1000 else f"{value:,.0f}" + if isinstance(value, int): + return f"{value:,}" + return str(value) + + +def tile_parts(value) -> tuple[str, str] | None: + """(the big figure, the line under it) for a result, or None for a result + that has no figure to show (a trace, a prediction model, …).""" + if isinstance(value, Scores): + items = [(k, v) for k, v in value.metrics.items() if not isinstance(v, (dict, list))] + if not items: + return None + first, rest = items[0], items[1:4] + return (f"{_number(first[1])} {first[0]}".strip(), + " · ".join(f"{k} {_number(v)}" for k, v in rest) or value.model) + if isinstance(value, Table): + return f"{len(value.rows):,} × {len(value.columns)}", f"rows × columns · {value.name}" + if isinstance(value, Figure): + return "figure", value.caption or value.name + if isinstance(value, EventLog): + return f"{len(value):,} cases", f"{value.event_count:,} events" + if isinstance(value, PetriNet): + return f"{len(value.places)} · {len(value.transitions)}", "places · transitions" + if isinstance(value, DFG): + return f"{len(value.activities)} · {len(value.all_edges())}", "activities · paths" + if isinstance(value, Text): + first = str(value).strip().splitlines()[0] if str(value).strip() else "" + return first[:40], value.name + return None + + +class SummaryTile(QFrame): + """One box's figure: the box (group and name), the figure, a line under it. Clickable.""" + + clicked = Signal(str) + + def __init__(self, node_id: str, group: str, caption: str, value: str, sub: str) -> None: + super().__init__() + self.node_id = node_id + self.setObjectName("card") + self.setCursor(Qt.PointingHandCursor) + self.setMinimumWidth(170) + self.setMaximumWidth(260) + self.setToolTip(f"{caption}: open its Result on the canvas") + column = QVBoxLayout(self) + column.setContentsMargins(14, 10, 14, 12) + column.setSpacing(2) + column.addWidget(ElidedLabel(group.upper(), "sectionLabel")) + column.addWidget(ElidedLabel(caption, "muted")) + big = ElidedLabel(value) + big.setObjectName("summaryValue") + column.addWidget(big) + column.addWidget(ElidedLabel(sub, "muted")) + + def mousePressEvent(self, event) -> None: # noqa: N802 + if event.button() == Qt.LeftButton: + self.clicked.emit(self.node_id) + event.accept() + return + super().mousePressEvent(event) + + +class SummaryWidget(QWidget): + """The Summary view of a workflow page; :meth:`refresh` rebuilds it from the run.""" + + tile_clicked = Signal(str) + + def __init__(self, page, parent=None) -> None: + super().__init__(parent) + self.page = page + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(14) + layout.addWidget(label( + "The key figure of every box, in the order the boxes run. Nothing is computed here: a tile " + "is the box's result, as on the canvas. Click one to open the box.", "muted", wrap=True)) + self.tiles_host = QWidget() + self.tiles_host.setObjectName("plain") + self.tiles_layout = QVBoxLayout(self.tiles_host) + self.tiles_layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.tiles_host) + # The process map: a Directly-follows graph box's result. + self.map_card = Card("Process map", "") + self.map_info = label("", "muted") + self.detail = QSlider(Qt.Horizontal) + self.detail.setRange(0, 100) + self.detail.setValue(100) + self.detail.setMinimumWidth(90) + self.detail.setMaximumWidth(220) + self.detail.setToolTip("A view setting: hides the rarer activities and paths of the drawing. " + "The box's result is unchanged.") + self.detail_label = label("100%", "muted") + self.map_view = GraphView() + self.map_view.setMinimumHeight(260) + self.map_view.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.legend = Legend() + self.map_card.add(hbox(label("Shown", "muted"), self.detail, self.detail_label, 16, self.map_info, None)) + self.map_card.add(self.map_view, 1) + self.map_card.add(self.legend) + self.map_card.setVisible(False) + layout.addWidget(self.map_card, 1) + # No such box yet: say so, and offer to add it. + self.no_map_card = Card("Process map", "The process map is the result of a Directly-follows graph " + "box (Discover ▸), so it too can be clicked, read and re-run. This " + "analysis has none yet.") + self.add_map_button = button("Add a Directly-follows graph box", self._add_map_box, + tooltip="Adds the box, fed by the first log, and runs it") + self.no_map_card.add(hbox(self.add_map_button, None)) + self.no_map_card.setVisible(False) + layout.addWidget(self.no_map_card) + layout.addStretch(0) + self.detail.valueChanged.connect(lambda _v: self._redraw_map()) + self._dfg: DFG | None = None + self.tiles: list[SummaryTile] = [] + + # -- building ---------------------------------------------------------------------------- + def refresh(self) -> None: + """Rebuild the tiles and the map from the page's current run.""" + page = self.page + while self.tiles_layout.count(): + item = self.tiles_layout.takeAt(0) + if item.widget() is not None: + item.widget().hide() + item.widget().deleteLater() + self.tiles = [] + run = page.run + tiles = [] + dfg = None + has_log = False + for node in page.workflow.order(): + result = run.result(node) if run is not None else None + if result is None or result.status != "done": + continue + value = result.value + if isinstance(value, EventLog): + has_log = True + if dfg is None and isinstance(value, DFG): + dfg = (node, value) + parts = tile_parts(value) + if parts is None: + continue + tile = SummaryTile(node.id, page.workflow.spec(node).group, page.workflow.title(node.id), *parts) + tile.clicked.connect(self.tile_clicked.emit) + tiles.append(tile) + if len(tiles) >= TILE_LIMIT: + break + self.tiles = tiles + if tiles: + self.tiles_layout.addWidget(flow(*tiles, spacing=10)) + else: + hint = QLabel("No results yet. Add boxes (a log, a miner, a check) and run: each one's key " + "figure appears here.") + hint.setObjectName("muted") + hint.setWordWrap(True) + self.tiles_layout.addWidget(hint) + self._dfg = None if dfg is None else dfg[1] + self.map_card.setVisible(dfg is not None) + self.no_map_card.setVisible(dfg is None and has_log) + self.add_map_button.setEnabled(self._map_box_id() is not None) + if dfg is not None: + node, _value = dfg + self.map_card.title_label.setText(f"Process map · the result of {page.workflow.title(node.id)}") + self._redraw_map(fit=True) + + def _map_box_id(self) -> str | None: + from .picker import input_box_for # noqa: F401 - the same lookup style, for one more box + return next((s.id for s in self.page.library.specs.values() if s.id.endswith(".directly_follows")), None) + + def _add_map_box(self) -> None: + """Add a Directly-follows graph box, fed by the first log, and run it.""" + box_id = self._map_box_id() + if box_id is None: + return + page = self.page + source = next((n for n in page.workflow.order() + if page.run is not None and page.run.result(n) is not None + and isinstance(page.run.result(n).value, EventLog)), None) + page.add_box_fed_by(box_id, source.id if source is not None else None) + + def _redraw_map(self, fit: bool = False) -> None: + dfg = self._dfg + if dfg is None: + return + detail = self.detail.value() / 100 + simplified = dfg.simplified(max(0.25, detail), detail) + self.detail_label.setText(f"{self.detail.value()}%") + nodes, edges = dfg_specs(simplified, "frequency") + self.map_view.graph.populate(nodes, edges, layer_gap=64) + self.map_info.setText(f"{len(simplified.activities)} of {len(dfg.activities)} activities · " + f"{len(simplified.all_edges())} of {len(dfg.all_edges())} paths") + self.legend.set_items([ + (LegendSwatch("ramp"), "activity: darker = more events (count inside)"), + (LegendSwatch("widths"), "thicker = path taken more often (label = times)"), + (LegendSwatch("line", style.tokens().text_muted), "grey = rare path"), + (LegendSwatch("dashed"), "case start / end"), + ]) + if fit: + QTimer.singleShot(0, self.map_view.fit) + else: + self.map_view.fit() + + +__all__ = ["SummaryWidget", "SummaryTile", "tile_parts"] diff --git a/openprocess/gui/flow/viewers.py b/openprocess/gui/flow/viewers.py index 98df180..c8d461c 100644 --- a/openprocess/gui/flow/viewers.py +++ b/openprocess/gui/flow/viewers.py @@ -69,10 +69,11 @@ def _table(columns: list[str], rows: list[list], max_rows: int = 500) -> QTableW return table -def _graph(nodes, edges, positions=None, layer_gap: float = 48.0, height: int = 260) -> GraphView: +def _graph(nodes, edges, positions=None, layer_gap: float = 48.0, height: int = 260, + movable: bool = False) -> GraphView: view = GraphView() view.setMinimumHeight(height) - view.graph.populate(nodes, edges, positions, layer_gap=layer_gap) + view.graph.populate(nodes, edges, positions, layer_gap=layer_gap, movable=movable) QTimer.singleShot(0, view.fit) return view @@ -135,9 +136,11 @@ def _meter(name: str, value) -> QWidget: # --------------------------------------------------------------------------- # Result viewers # --------------------------------------------------------------------------- -def result_widget(value, page=None) -> QWidget: +def result_widget(value, page=None, node=None) -> QWidget: """A viewer for ``value``. ``page`` (the workflow page) receives the - *Open as …* requests; None gives a read-only viewer.""" + *Open as …* requests; None gives a read-only viewer. ``node`` is the box + the value came from: a net drawn for it can be tidied by dragging, and + the layout is kept with the box (:attr:`~openprocess.flow.workflow.Node.layout`).""" host = QWidget() layout = QVBoxLayout(host) layout.setContentsMargins(0, 0, 0, 0) @@ -162,17 +165,29 @@ def add(widget) -> None: elif isinstance(value, PetriNet): nodes, edges = petri_net_specs(value, show_place_names=len(value.places) <= 40) positions = None - if all(p.position for p in value.places.values()) and all(t.position for t in value.transitions.values()): + ids = {n.id for n in nodes} + if node is not None and node.layout and ids <= set(node.layout): + positions = {k: v for k, v in node.layout.items() if k in ids} # as the user tidied it + elif all(p.position for p in value.places.values()) and all(t.position for t in value.transitions.values()): positions = {p.id: p.position for p in value.places.values()} positions |= {t.id: t.position for t in value.transitions.values()} - add(_graph(nodes, edges, positions)) + view = _graph(nodes, edges, positions, movable=page is not None and node is not None) + add(view) + if page is not None and node is not None: + def tidied(_id: str, _x: float, _y: float, graph=view.graph, n=node) -> None: + n.layout = graph.positions() + page.layout_tidied() + view.graph.node_moved.connect(tidied) add(label(value.summary() + (f" · {value.info['algorithm']}" if value.info.get("algorithm") else ""), "muted", wrap=True)) if page is not None: add(hbox(button("Open as model", lambda: page.open_as_model(value), tooltip="Open it as a model page: token game, analysis, conformance"), - button("✎ Edit a copy", lambda: page.edit_copy(value), - tooltip="Open a copy on the net canvas, to play and change it"), None)) + button("Open a copy in Model ›", lambda: page.edit_copy(value), + tooltip="A copy on the net canvas in Model, to play with and change; " + "this result stays as it is"), None)) + add(label("Drag places and transitions to tidy the drawing; the net itself does not change " + "here. To change it, open a copy in Model.", "muted", wrap=True)) elif isinstance(value, EventLog): from ...mining.stats import format_duration, summarise summary = summarise(value) @@ -680,19 +695,44 @@ def _path_control(self, setting: Setting, value) -> QComboBox: path typed or pasted (quotes and shell escapes are tolerated).""" control = QComboBox() control.setEditable(True) + # Sized by the panel, not by its longest entry: a folder with long + # paths would otherwise push Choose… (and the whole panel) off the edge. + control.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon) + control.setMinimumContentsLength(12) control.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) control.lineEdit().setPlaceholderText("Choose… or type a name in the workflow's folder") - for name in self._files(): - control.addItem(name) + control.setMaxVisibleItems(14) + # The list shows file names (with the subfolder only when two names + # clash); the entry's data is the path relative to the folder, which + # is what the field holds once one is picked. + files = self._files() + names = [Path(f).name for f in files] + for relative, name in zip(files, names): + shown = f"{name} ({Path(relative).parent})" if names.count(name) > 1 and "/" in relative else name + control.addItem(shown, relative) + control.setItemData(control.count() - 1, relative, Qt.ToolTipRole) control.setCurrentText("" if value in (None, "") else str(value)) + last = {"text": "" if value in (None, "") else str(value)} + + def picked(index: int, c=control): + c.setCurrentText(c.itemData(index) or c.itemText(index)) + typed() + def typed(s=setting, c=control): + # Only a real change is reported. Opening the list takes the focus + # from the field, which counts as "editing finished"; reporting the + # unchanged value would re-run the box and rebuild this panel under + # the open list. text = clean_path(c.currentText()) if text != c.currentText(): c.setCurrentText(text) + if text == last["text"]: + return + last["text"] = text self.changed.emit(s.name, text) control.lineEdit().editingFinished.connect(typed) - control.activated.connect(lambda _i: typed()) + control.activated.connect(picked) return control def _choose_file(self, setting: Setting, control: QComboBox) -> None: @@ -709,12 +749,11 @@ def _choose_file(self, setting: Setting, control: QComboBox) -> None: if not start and self.folder is not None: start = str(self.folder) path, _ = QFileDialog.getOpenFileName( - self, f"Choose {setting.name.replace('_', ' ')}", start, - "Logs and nets (*.xes *.gz *.csv *.txt *.pnml *.cpn);;All files (*)") + self, f"Choose {setting.name.replace('_', ' ')}", start, self._dialog_filter()) if not path: return control.setCurrentText(self.display_path(path)) - self.changed.emit(setting.name, control.currentText()) + control.lineEdit().editingFinished.emit() # reported once, through typed() def display_path(self, path: str | Path) -> str: """``path`` relative to the workflow's folder when it is inside it, else as given.""" @@ -726,16 +765,44 @@ def display_path(self, path: str | Path) -> str: pass return str(chosen) + def _kinds(self) -> tuple[str, ...]: + """The kinds of file this box can read, from what it gives: a box that + gives a Log reads event logs, one that gives a Petri net reads PNML, + and so on (nothing for a box author to declare). Empty: any kind.""" + kinds = [] + for port in self.spec.outputs: + for cls, kind in ((EventLog, "log"), (PetriNet, "petri"), (CPNet, "cpn"), + (TransitionSystem, "ts")): + if port.type is cls: + kinds.append(kind) + return tuple(kinds) + def _files(self) -> list[str]: + """The folder's files this box can read, relative to it (up to 200).""" + from ..studio.workspace import file_kind if self.folder is None or not Path(self.folder).is_dir(): return [] + kinds = self._kinds() names = [] for path in sorted(Path(self.folder).rglob("*")): - if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in ( - ".xes", ".gz", ".csv", ".txt", ".pnml", ".cpn") and len(names) < 200: - names.append(str(path.relative_to(self.folder))) + if len(names) >= 200: + break + if not path.is_file() or path.name.startswith("."): + continue + kind = file_kind(path) + if kind is None or kind == "workflow" or (kinds and kind not in kinds): + continue + names.append(str(path.relative_to(self.folder))) return names + def _dialog_filter(self) -> str: + """The Choose… dialog's file types, matching :meth:`_files`.""" + by_kind = {"log": "Event logs (*.xes *.xes.gz *.gz *.csv *.txt)", + "petri": "Petri nets (*.pnml)", "cpn": "Coloured Petri nets (*.cpn)", + "ts": "Transition systems (*.txt)"} + kinds = self._kinds() or tuple(by_kind) + return ";;".join([by_kind[k] for k in kinds] + ["All files (*)"]) + # --------------------------------------------------------------------------- # A tab in its own window diff --git a/openprocess/gui/studio/app.py b/openprocess/gui/studio/app.py index a323aba..c91cd29 100644 --- a/openprocess/gui/studio/app.py +++ b/openprocess/gui/studio/app.py @@ -1,33 +1,34 @@ -"""OpenProcess Studio: the main window of the process-mining workspace. +"""OpenProcess Studio: the main window, with its three spaces. Layout ------ :: + [ Mine | Model | Learn ] ← the switcher +------------+-------------------------------------------------+ - | OpenProcess | Page header (title, subtitle, actions) | - | | [Overview | Variants | Cases | ...] | - | EVENT LOGS | | - | ▤ log A | page content | - | MODELS | | - | ◇ α(L) | | - | COLOURED | | - | NETS | | - | ◈ plane | | - | Open… | | + | FOLDER | Page header (title, subtitle, actions) | + | Week 2 | [Result | How | Code | Settings] | + | ANALYSES | | + | ⧉ Discover| page content | + | LOGS · 3 ▸| | + | | | + | + New… | | +------------+-------------------------------------------------+ -The sidebar lists every open *document*: an event log, a Petri net, or a -coloured Petri net (CPN Tools model). Selecting one shows its page. -Discovering a model from a log adds the model to the sidebar, and exporting -a CPN simulation adds its event log, so a whole analysis lives side by side -in one window. +One window, one folder, three spaces (:mod:`.spaces`). **Mine** lists the +folder's analyses (workflows) with its event logs folded under them; +**Model** lists its Petri nets and coloured nets; **Learn** is the exercise +view, which takes the whole window. Selecting a document shows its page; +opening one switches to its space. Discovering a model from a log and +editing a copy of it moves the window to Model, and exporting a CPN +simulation as a log moves it to Mine, so a whole analysis lives in one +window without the two kinds of work sharing a list. With a folder open (File ▸ Open Folder…, e.g. "Week 2"; internally a :class:`.workspace.Workspace`), the folder and the sidebar always match: -* the sidebar lists every log and net in the folder, open or not (lighter), - with its subfolders (the Folder view) or grouped by kind; +* each space's sidebar lists its kinds of file in the folder, open or not + (lighter), under their subfolders; * folder → app: files added, renamed or deleted in Finder show up by themselves, an open file changed on disk is reloaded, and one deleted on disk is marked as missing; @@ -36,10 +37,11 @@ subfolders, renamed or moved to the Bin from the sidebar; * files opened from elsewhere can be copied or moved into the folder. -A subfolder with a ``question`` file is an *exercise*: clicking it swaps the -window for OpenProcess Learn (see :mod:`openprocess.gui.learn.mode`), a worksheet with answer -boxes beside the net, log or transition system the exercise gives. *Exit* -comes back to the folder exactly as it was. +A subfolder with a ``question`` file is an *exercise*. Learn (the switcher, +or Learn ▸ Open Exercise Pack…) swaps the window for OpenProcess Learn (see +:mod:`openprocess.gui.learn.mode`), a worksheet with answer boxes beside the +net, log or transition system the exercise gives. *Exit* comes back to the +space you left, exactly as it was. """ from __future__ import annotations @@ -60,10 +62,11 @@ QAction, QColor, QDesktopServices, QIcon, QKeySequence, QPainter, QPen, QPixmap, QShortcut, ) from PySide6.QtWidgets import ( + QButtonGroup, QAbstractItemView, QApplication, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QFrame, QGridLayout, QHBoxLayout, QInputDialog, QLabel, QMainWindow, QMenu, QMessageBox, QSizePolicy, QSplitter, QStackedWidget, QStyle, QStyledItemDelegate, - QToolButton, QTreeWidgetItem, QTreeWidgetItemIterator, QVBoxLayout, QWidget, + QToolBar, QToolButton, QTreeWidgetItem, QTreeWidgetItemIterator, QVBoxLayout, QWidget, ) from ...mining.csv_import import ColumnMapping, guess_mapping, read_csv, sniff @@ -71,9 +74,10 @@ from ...mining.pnml import read_pnml, write_pnml from ...mining.xes import read_xes, write_xes from .. import theme -from . import style +from . import motion, style from ..canvas import NetScene, NetView from .compare_page import ComparePage +from .connections import ConnectionsPage from .cpn_page import CpnPage from .petri_page import PetriNetPage from ..flow.page import WorkflowPage @@ -86,7 +90,8 @@ from .log_editor import EXAMPLES, NotationDialog # noqa: F401 - NotationDialog: older imports from .log_page import LogPage from .model_page import ModelPage -from .sidebar import FILE_ROLE, FOLDER_ROLE, SidebarTree +from .sidebar import FILE_ROLE, FOLDABLE_ROLE, FOLDER_ROLE, GROUP_ROLE, SidebarTree +from .spaces import FILE_KINDS, LABELS, LEARN, MAIN_KINDS, MINE, MODEL, SPACES, is_main, space_of from .widgets import ( Card, ElidedLabel, NoticeBar, SegmentedControl, button, dialog_folder, hbox, label, round_menus, scroll, set_dialog_folder, shortcut_text, vbox, @@ -110,6 +115,28 @@ #: Name of the Bin on this system (macOS says Bin in British English, as here). BIN = "Recycle Bin" if sys.platform == "win32" else "Bin" +def _run_pip(command: list[str]) -> tuple[int, str]: + """Run pip (``command``) and give back its exit code and output.""" + completed = subprocess.run(command, capture_output=True, text=True) + return completed.returncode, (completed.stdout or "") + (completed.stderr or "") + + +#: How Install… runs pip (a test puts a stand-in here). +PIP_RUNNER = _run_pip + +#: The Model palette's tools, in the net page's order (see CpnPage.TOOLS). +PALETTE = [("Select", "⬚", "Select and move things; double-click a place or transition to rename it"), + ("Place", "○", "Click on the canvas to add a place"), + ("Transition", "▭", "Click on the canvas to add a transition"), + ("Arc", "→", "Drag from a place to a transition (or the other way) to connect them")] + + +def _empty_on_disk(folder: Path) -> bool: + """Nothing in the folder but hidden files (a folder just made).""" + try: + return not any(not p.name.startswith(".") for p in folder.iterdir()) + except OSError: + return False def _file_suffix(path: Path) -> str: @@ -429,13 +456,18 @@ def __init__(self, persist: bool = False) -> None: #: Its subfolders and files, as last listed (see _rescan_workspace). self._tree: WorkspaceFolder | None = None self._tree_signature: tuple = () - #: In a folder: the sidebar shows its subfolders ("folder") or groups by kind ("kind"). - self.view_mode = "folder" - #: Expanded subfolders (relative paths), remembered in the folder's .openprocess. - self._expanded: set[str] = set() + #: The space shown, Mine or Model (Learn takes the whole window: see in_learn). + self.space = MINE + #: Each space's current document (its id), so switching back finds it again. + self._space_current: dict[str, int | None] = {MINE: None, MODEL: None} + #: Mine's LOGS section (the folder's logs) is unfolded; remembered per folder. + self._material_expanded = False + #: The space that was shown before Learn took the window. + self._space_before_learn = MINE #: Sidebar rows by what they stand for (rebuilt by _rebuild_sidebar). self.placeholders: dict[str, QTreeWidgetItem] = {} # files not open, by path key - self.folder_items: dict[str, QTreeWidgetItem] = {} # subfolders, by relative path + self.folder_items: dict[str, QTreeWidgetItem] = {} # subfolder captions, by relative path + self._folder_rows: list[QTreeWidgetItem] = [] # every caption (a path may have two) #: Reopening a folder: files still loading, and the one that was selected. self._restore_paths: dict[str, dict] = {} self._restore_selected: str | None = None @@ -519,6 +551,10 @@ def __init__(self, persist: bool = False) -> None: root.setCollapsible(0, False) self.content = QStackedWidget() self.content.addWidget(scroll(self._build_welcome(), horizontal=True)) + #: The hub picture: what flows in and out, and the tools that plug in (index 1). + self.connections_page = ConnectionsPage() + self.connections_page.install_requested.connect(self.install_tool) + self.content.addWidget(self.connections_page) # Above the pages: "a new version is out" (see check_automatically). from .updates import UpdateBar self.update_bar = UpdateBar() @@ -559,6 +595,7 @@ def __init__(self, persist: bool = False) -> None: self.modes.addWidget(root) self.modes.addWidget(self.learn_mode) self.setCentralWidget(self.modes) + self._build_space_bar() self.statusBar().showMessage("Ready") # ✎ Notes in the status bar: it never covers the page (see toggle_notes). self.notes_button = QToolButton() @@ -653,16 +690,6 @@ def _build_sidebar(self) -> QWidget: layout.setSpacing(8) layout.addWidget(self._build_sidebar_header()) - # In a folder: its subfolders, or grouped by kind as without one. - self.view_switch = SegmentedControl(["Folders", "By kind"], compact=True) - self.view_switch.setToolTip("Show the folder's subfolders, or group the files by kind") - self.view_switch.changed.connect( - lambda index: self.set_view_mode(("folder", "kind")[index])) - self.view_row = QWidget() - self.view_row.setLayout(hbox(self.view_switch, None, margins=(10, 0, 0, 2))) - self.view_row.setHidden(True) - layout.addWidget(self.view_row) - self.tree = SidebarTree() self.tree.setHeaderHidden(True) self.tree.setIndentation(14) @@ -684,22 +711,22 @@ def _build_sidebar(self) -> QWidget: shortcut = QShortcut(key, self.tree) shortcut.setContext(Qt.WidgetShortcut) shortcut.activated.connect(self.remove_selected) - # The Folder view: documents with no file (a model just discovered) - # on top, then the folder's subfolders and files, then open files - # from elsewhere and comparisons. + # One space at a time (see _fill_space_view). Mine: documents with no + # file on top, the analyses, then the logs folded away under them. + # Model: the models. Both: open files from elsewhere, comparisons. + # Exercises are Learn's: they are not listed here. self.unsaved_section = self._section("UNSAVED") - self.logs_section = self._section("EVENT LOGS") - self.petri_section = self._section("PETRI NETS") + self.analyses_section = self._section("ANALYSES") self.models_section = self._section("MODELS") - self.cpn_section = self._section("COLOURED NETS") - self.ts_section = self._section("TRANSITION SYSTEMS") - self.workflows_section = self._section("WORKFLOWS") - self.exercises_section = self._section("EXERCISES") + self.material_section = self._section("LOGS") + self.material_section.setData(0, FOLDABLE_ROLE, True) + self.material_section.setToolTip(0, "The folder's event logs and transition systems: " + "what analyses start from. Click to fold or unfold.") self.elsewhere_section = self._section("OTHER FILES") self.elsewhere_section.setToolTip(0, "Open files that are not in the folder") self.compare_section = self._section("COMPARISONS") # Shown in an empty folder, so an empty sidebar is not a mystery. - self.empty_hint = QTreeWidgetItem(self.tree, ["No logs or nets yet"]) + self.empty_hint = QTreeWidgetItem(self.tree, ["No analyses or logs yet"]) self.empty_hint.setFlags(Qt.NoItemFlags) self.empty_hint.setToolTip(0, "Save a net or export a log into the folder, or add " "files to it in Finder: they appear here.") @@ -710,8 +737,8 @@ def _build_sidebar(self) -> QWidget: self.more_row.setToolTip(0, "The sidebar lists up to 500 files, three subfolders deep. " "Open a smaller folder to see everything.") self.tree.currentItemChanged.connect(self._on_select) - # A file that is not open yet opens with one click (or Return). - self.tree.itemClicked.connect(lambda item, _column: self._open_placeholder(item)) + # A file that is not open yet opens with one click (or Return); the LOGS heading folds. + self.tree.itemClicked.connect(lambda item, _column: self._sidebar_clicked(item)) self.tree.itemActivated.connect(lambda item, _column: self._open_placeholder(item)) # Double-click an open document to rename it (section headings have no id). self.tree.itemDoubleClicked.connect(self._on_double_click) @@ -719,16 +746,203 @@ def _build_sidebar(self) -> QWidget: self.tree.itemCollapsed.connect(lambda item: self._folder_toggled(item, False)) layout.addWidget(self.tree, 1) - footer = QWidget() - footer.setObjectName("sidebarFooter") - footer.setLayout(vbox(button("+ Open file…", self.action_open), - button("✎ Log from notation…", self.action_notation), - button("⇄ Compare logs…", lambda: self.action_compare()), - button("⇄ Compare nets…", self.action_compare_nets), - spacing=0)) - layout.addWidget(footer) + # Model: the drawing palette, for the net page that is open (it replaces + # the page's own tool buttons while the sidebar shows). + self.palette = QWidget() + self.palette.setObjectName("palette") + grid = QGridLayout(self.palette) + grid.setContentsMargins(6, 4, 6, 8) + grid.setSpacing(4) + grid.addWidget(label("PALETTE", "sectionLabel"), 0, 0, 1, 2) + self.palette_group = QButtonGroup(self.palette) + self.palette_group.setExclusive(True) + for index, (text, symbol, tip) in enumerate(PALETTE): + tool = button(f"{symbol} {text}", kind="paletteTool", tooltip=tip) + tool.setCheckable(True) + self.palette_group.addButton(tool, index) + grid.addWidget(tool, 1 + index // 2, index % 2) + self.palette_group.idClicked.connect(self._palette_tool) + self.palette.setHidden(True) + layout.addWidget(self.palette) + # Both spaces: the hub picture. + connections = QWidget() + connections.setObjectName("sidebarFooter") + connections.setLayout(vbox(button("⚲ Connections", self.show_connections, + tooltip="What flows in, what flows out, and the tools that plug in"), + spacing=0)) + layout.addWidget(connections) + # A footer per space: what you start in it. + self.footers = {} + self.footers[MINE] = QWidget() + self.footers[MINE].setObjectName("sidebarFooter") + self.footers[MINE].setLayout(vbox( + button("+ New analysis", lambda: self.action_new_workflow(None)), + button("+ Open log…", self.action_open_log_box, + tooltip="An Open log box on the current analysis (or a new one)"), + button("✎ Log from notation…", self.action_notation_box, + tooltip="A Typed log box on the current analysis (or a new one)"), + button("⇄ Compare logs…", lambda: self.action_compare()), + spacing=0)) + self.footers[MODEL] = QWidget() + self.footers[MODEL].setObjectName("sidebarFooter") + self.footers[MODEL].setLayout(vbox( + button("+ New Petri net", self.action_new_petri), + button("+ New coloured net", self.action_new_cpn), + button("+ Open net…", self.action_open), + button("⇄ Compare nets…", self.action_compare_nets), + spacing=0)) + self.footers[MODEL].setHidden(True) + layout.addWidget(self.footers[MINE]) + layout.addWidget(self.footers[MODEL]) return sidebar + # -- the spaces: Mine, Model, Learn ------------------------------------------------ + def _build_space_bar(self) -> None: + """The switcher at the top of the window: Mine, Model, Learn, in a slim + bar under the title (and the menu, where the menu is in the window).""" + bar = QToolBar("Spaces") + bar.setObjectName("spaceBar") + bar.setMovable(False) + bar.setFloatable(False) + bar.setContextMenuPolicy(Qt.PreventContextMenu) + self.space_switch = SegmentedControl([LABELS[space] for space in SPACES]) + tips = {MINE: "Event logs and analyses: discover, check, compare", + MODEL: "Petri nets and coloured nets: draw, play, simulate, analyse", + LEARN: "Exercises: a worksheet beside its materials"} + for space, control in zip(SPACES, self.space_switch.buttons): + control.setToolTip(tips[space]) + self.space_switch.changed.connect(lambda index: self.set_space(SPACES[index])) + bar.addWidget(self.space_switch) + self.addToolBar(Qt.TopToolBarArea, bar) + self.space_bar = bar + + def set_space(self, space: str, remember: bool = True) -> bool: + """Show ``space``. Mine and Model swap the sidebar and the page; Learn + opens the folder's exercises (or the demo ones) over the whole window. + Returns False when nothing changed (Learn had nothing to open).""" + if space == LEARN: + if self.in_learn: + return True + opened = self._open_learn() + if not opened: + self._sync_space_switch() + return opened + was_learn = self.in_learn + if was_learn: + self.leave_learn(to=space) + if space == self.space and not was_learn: + self._sync_space_switch() + return False + current = self._current_document() + if current is not None and space_of(current) == self.space: + self._space_current[self.space] = current.id + self._set_space_quietly(space) + self._rebuild_sidebar() + if remember and self.workspace is not None: + self.workspace.update_settings(space=space) + wanted = self._space_current.get(space) + document = self._document(wanted) if wanted is not None else None + if document is not None and document in self.documents: + self._select_document(document) + else: + self.tree.clearSelection() + self.tree.setCurrentItem(None) + self.content.setCurrentIndex(0) + self._set_title(None) + motion.lift(self.modes) # the whole window moved: let it settle + return True + + def _set_space_quietly(self, space: str) -> None: + """Make ``space`` current without rebuilding or selecting anything.""" + self.space = space + for key, footer in self.footers.items(): + footer.setVisible(key == space) + self._sync_space_switch() + self._update_palette() + + # -- the Model palette and the Connections view ---------------------------------------- + def _palette_tool(self, index: int) -> None: + page = self.current_page() + if isinstance(page, CpnPage): + page.tool_switch.set_index(index) + + def _update_palette(self) -> None: + """The palette shows for a net page in Model, ticked at the page's tool.""" + if not hasattr(self, "palette"): + return + page = self.current_page() + show = self.space == MODEL and isinstance(page, CpnPage) and not self.in_learn + self.palette.setVisible(show) + if show: + index = page.tool_switch.index() + tool = self.palette_group.button(index) + if tool is not None and not tool.isChecked(): + tool.setChecked(True) + + def install_tool(self, name: str) -> None: + """*Install…* on a Connections card: pip installs the package into this + app's Python, in the background; the boxes that need it come alive.""" + import importlib + from .connections import package_for + package = package_for(name) + command = [sys.executable, "-m", "pip", "install", package] + answer = QMessageBox.question( + self, "Install a tool", f"Install {package} into this app's Python?\n\nThis runs:\n" + f"{' '.join(command)}\n\nThe app stays usable meanwhile.", + QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Yes) + if answer != QMessageBox.Yes: + return + self.connections_page.installing.add(name) + self.connections_page.refresh(self.library(), self.workspace.folder if self.workspace else None) + self.statusBar().showMessage(f"Installing {package}…") + + def done(result) -> None: + code, output = result + self.connections_page.installing.discard(name) + importlib.invalidate_caches() + self.connections_page.refresh(self.library(), self.workspace.folder if self.workspace else None) + if code == 0: + for page in self.pages.values(): + if isinstance(page, WorkflowPage) and hasattr(page, "reload_library"): + page.reload_library() + self.statusBar().showMessage(f"Installed {package}: its boxes are available (Run ▶ an " + "analysis that was waiting for it)", 10000) + else: + tail = "\n".join(output.strip().splitlines()[-20:]) + QMessageBox.warning(self, "Install a tool", f"pip could not install {package} " + f"(exit code {code}).\n\n{tail}") + self.statusBar().showMessage(f"Could not install {package}", 8000) + + run_in_background(lambda: PIP_RUNNER(command), done, + lambda message: done((1, str(message)))) + + def show_connections(self) -> None: + """The Connections view: what flows in and out, and the tools that plug in.""" + if self.in_learn: + self.leave_learn() + self.connections_page.refresh(self.library(), self.workspace.folder if self.workspace else None) + self.tree.clearSelection() + self.tree.setCurrentItem(None) + self.content.setCurrentWidget(self.connections_page) + self._set_title(None) + self._update_palette() + + def _sync_space_switch(self) -> None: + shown = LEARN if self.in_learn else self.space + index = SPACES.index(shown) + if self.space_switch.index() != index: + self.space_switch.blockSignals(True) + self.space_switch.buttons[index].setChecked(True) + self.space_switch.blockSignals(False) + + def _open_learn(self) -> bool: + """Learn from the switcher: the folder's exercises, else the demo ones.""" + from ...learn.pack import find_exercises + if self.workspace is not None and find_exercises(self.workspace.folder): + return self.open_exercise(self.workspace.folder) + self.open_demo_exercises() + return self.in_learn + def _build_sidebar_header(self) -> QWidget: """"OpenProcess Studio", or the workspace's name with a ⋯ menu.""" header = QWidget() @@ -776,6 +990,10 @@ def toggle_sidebar(self, show: bool | None = None, remember: bool = True) -> Non if show is None: show = self.sidebar.isHidden() self.sidebar.setHidden(not show) + for page in self.pages.values(): # no sidebar, no palette: the page's own tools + if isinstance(page, CpnPage): + page.tool_switch.setVisible(not show) + self._update_palette() action = getattr(self, "sidebar_action", None) if action is not None and action.isChecked() != show: action.blockSignals(True) @@ -934,7 +1152,7 @@ def _build_welcome(self) -> QWidget: workspace_card.add(hbox(button("Open folder…", self.action_open_workspace, kind="primary"), self.welcome_recent, None)) self.welcome_folder_card = workspace_card - grid.addWidget(workspace_card, 0, 0, 1, 2) + grid.addWidget(workspace_card, 0, 0, 1, 3) # Inside a workspace the top card is about that folder instead. inside = Card("Folder") self.welcome_inside_title = inside.title_label @@ -942,37 +1160,43 @@ def _build_welcome(self) -> QWidget: inside.add(self.welcome_inside_text) reveal = {"darwin": "Show in Finder", "win32": "Show in Explorer"}.get( sys.platform, "Open Folder") - inside.add(hbox(button("New Petri net", self.action_new_petri, kind="primary"), - button("New coloured net", self.action_new_cpn), + inside.add(hbox(button("New analysis", lambda: self.action_new_workflow(None), kind="primary"), + button("New Petri net", self.action_new_petri), button(reveal, lambda: self.workspace and self.reveal(str(self.workspace.folder))), None, button("Close folder", self.close_workspace))) inside.setHidden(True) self.welcome_inside_card = inside - grid.addWidget(inside, 0, 0, 1, 2) + grid.addWidget(inside, 0, 0, 1, 3) cards = [ - ("Open an event log", "XES, XES.GZ or CSV. Explore variants, the dotted chart " - "and the process map, then discover a model.", "Open event log…", self.action_open), - ("Type a textbook log", "Write L = [^3, ^2] exactly as in the course " - "and mine it immediately.", "Log from notation…", self.action_notation), - ("Draw a Petri net", "Places, transitions and arcs as in the lectures, or open a " - "PNML file. Soundness with counterexamples, footprint, reachability graph, " - "token game.", "New Petri net", self.action_new_petri), - ("Coloured Petri nets", "CPN Tools models (.cpn): edit, simulate step by step or " - "automatically, analyse the state space, and mine the simulated behaviour.", - "Open CPN model…", self.action_open_cpn), - ("Build a workflow", "Boxes on a canvas: a log, a miner, a check, a comparison. Click a " - "box to see its result and how it got there; change a setting and only what follows " - "runs again. Saved with everything needed to get the same numbers back.", - "New workflow", lambda: self.action_new_workflow(TEMPLATES[0][1])), + ("Mine", "Process mining. Open an event log and build an analysis from boxes: a " + "log, a miner, a check, a comparison. Every result shows how it got there, with " + "the code and the paper behind it.", + [("New analysis", lambda: self.action_new_workflow(TEMPLATES[0][1])), + ("Open event log…", self.action_open_log_box), + ("Log from notation…", self.action_notation_box)]), + ("Model", "Petri nets and coloured nets. Draw places, transitions and arcs, play " + "the token game, simulate, analyse the state space, and mine the simulated " + "behaviour.", + [("New Petri net", self.action_new_petri), + ("New coloured net", self.action_new_cpn), + ("Open CPN model…", self.action_open_cpn)]), + ("Learn", "Exercise packs. A worksheet beside its materials, answers checked as " + "you go, results hidden until you have worked them out.", + [("Demo exercises", self.open_demo_exercises), + ("Open exercise pack…", self.action_open_exercise)]), ] - for index, (heading, text, action, slot) in enumerate(cards): + for index, (heading, text, actions) in enumerate(cards): card = Card(heading) - card.setMinimumWidth(240) + card.setMinimumWidth(220) card.add(label(text, "muted", wrap=True)) - card.add(hbox(button(action, slot), None)) - grid.addWidget(card, 1 + index // 2, index % 2) + buttons = QVBoxLayout() + buttons.setSpacing(6) + for action, slot in actions: + buttons.addLayout(hbox(button(action, slot), None)) + card.add(buttons) + grid.addWidget(card, 1, index) holder = QWidget() holder.setLayout(grid) holder.setMaximumWidth(760) @@ -1056,6 +1280,11 @@ def _build_menus(self) -> None: view_menu = self.menuBar().addMenu("&View") view_menu.aboutToShow.connect(self._sync_notes_action) + for index, space in enumerate(SPACES): + self._action(view_menu, LABELS[space], f"Ctrl+Alt+{index + 1}", + lambda _on=False, sp=space: self.set_space(sp)) + self._action(view_menu, "Connections", None, self.show_connections) + view_menu.addSeparator() self._action(view_menu, "Show Welcome Page", "Ctrl+1", lambda: (self.in_learn and self.leave_learn(), self.content.setCurrentIndex(0))) @@ -1179,6 +1408,8 @@ def add_document(self, document, options: dict | None = None, key = self._key(document.path) if document.path else None restored = key is not None and key in self._restore_paths self._restore_paths.pop(key, None) + if not restored and space_of(document) != self.space: + self.set_space(space_of(document)) # a document opens in its own space self._rebuild_sidebar() if not restored or self._restore_selected in (None, key): self._select_document(document) @@ -1190,8 +1421,9 @@ def add_document(self, document, options: dict | None = None, def _make_page(self, document) -> QWidget: """Build the page showing ``document`` (and its holder in the stack).""" if isinstance(document, LogDocument): - page = LogPage(document) + page = LogPage(document, discover="handoff") page.open_model.connect(self.add_document) + page.discover_requested.connect(lambda key, doc=document: self.discover_in_analysis(doc, key)) page.open_log.connect(lambda log, source=document: self.add_document( log, near=source.path)) page.edited.connect(lambda doc=document: self._log_edited(doc)) @@ -1207,7 +1439,7 @@ def _make_page(self, document) -> QWidget: page = WorkflowPage(document, self.library(), self.workspace.folder if self.workspace is not None else None) page.open_document.connect(lambda doc, source=document: self.add_document(doc, near=source.path)) - page.edit_requested.connect(self.edit_petri_net) + page.edit_requested.connect(lambda net, origin, doc=document: self.edit_petri_net(net, origin, doc)) page.keep_requested.connect(lambda doc=document: self.keep_workflow(doc)) page.edited.connect(lambda doc=document: (self._refresh_item(doc), self._schedule_autosave(doc))) if self.workspace is not None: @@ -1220,8 +1452,12 @@ def _make_page(self, document) -> QWidget: elif isinstance(document, CpnDocument): plain = getattr(document.net, "plain", False) page = PetriNetPage(document) if plain else CpnPage(document) - page.log_generated.connect(lambda log, source=document: self.add_document( - LogDocument(log), near=source.path)) + page.log_generated.connect(lambda log, source=document: self.mine_log(log, source)) + # The sidebar's palette stands in for the page's tool buttons while it shows. + page.tool_switch.changed.connect(lambda _i: self._update_palette()) + page.tool_switch.setVisible(self.sidebar.isHidden()) + if plain: + page.check_requested.connect(lambda doc=document: self.check_against_log(doc)) page.dirty_changed.connect(lambda _dirty, doc=document: self._refresh_item(doc)) page.edited.connect(lambda doc=document: self._schedule_autosave(doc)) page.snap_changed.connect(self._snap_changed) @@ -1234,15 +1470,15 @@ def _make_page(self, document) -> QWidget: if plain: page.open_model.connect(self.add_document) else: - page = ModelPage(document, self.logs) + page = ModelPage(document, self.logs, conformance="handoff") + page.check_requested.connect(lambda doc=document: self.check_against_log(doc)) page.inspector_tabs.changed.connect( lambda i: self.remembered.__setitem__("model_inspector", i)) page.view_switch.changed.connect( lambda i: self.remembered.__setitem__("model_view", i)) - page.log_generated.connect(lambda log, source=document: self.add_document( - LogDocument(log), near=source.path or (source.source_log.path - if source.source_log else None))) - page.edit_requested.connect(self.edit_petri_net) + page.log_generated.connect(lambda log, source=document: self.mine_log(log, source)) + page.edit_requested.connect(lambda net, doc=document: self.edit_petri_net( + net, f"from {doc.name}", doc)) page.keep_requested.connect(lambda doc=document: self.keep_model(doc)) page.status.connect(lambda message: self.statusBar().showMessage(message, 8000)) page.saved.connect(lambda doc=document: self._document_saved(doc)) @@ -1250,6 +1486,12 @@ def _make_page(self, document) -> QWidget: page.header.title_double_clicked.connect( lambda doc=document: self.rename_document(doc)) page.header.title.set_hint("Double-click to rename") + source = getattr(document, "opened_from", None) + if source is not None and hasattr(page, "header"): + # Opened from a box's result (or a copy of one): a way back to that analysis. + origin = getattr(document, "origin", "") if isinstance(document, CpnDocument) else "" + text = f"‹ A copy, {origin}" if origin else f"‹ Back to {source.name}" + page.header.set_back(text, lambda _checked=False, s=source: self.back_to(s)) self.pages[document.id] = page # The page sits in a scroll area: if the window is made smaller than # the page's minimum size, scroll bars appear instead of the window @@ -1264,6 +1506,13 @@ def _make_page(self, document) -> QWidget: self.content.addWidget(holder) return page + def back_to(self, source) -> None: + """A page's Back link: the analysis it was opened from, if it is still open.""" + if source in self.documents: + self._select_document(source) + else: + self.statusBar().showMessage(f"{source.name} is no longer open", 6000) + def _snap_changed(self, on: bool) -> None: """Snap to grid was ticked in one editor: the same everywhere, and next time.""" self._set_setting("editor/snap_to_grid", on) @@ -1275,6 +1524,15 @@ def _snap_changed(self, on: bool) -> None: box.blockSignals(False) def _select_document(self, document) -> None: + """Make ``document`` the current one, switching to its space if need be. + While a folder is being reopened, a document of the other space is + only remembered as that space's current one.""" + space = space_of(document) + if space != self.space: + if self._restoring: + self._space_current[space] = document.id + return + self.set_space(space) item = self.items.get(document.id) if item is None: return @@ -1334,6 +1592,7 @@ def _on_select(self, current, previous=None) -> None: self.content.setCurrentWidget(holder) self._schedule_fit() self._set_title(self._document(current.data(0, Qt.UserRole))) + self._update_palette() def _restore_tab(self, page: QWidget) -> None: """Show the same tab on the newly selected page as on the last one.""" @@ -1454,13 +1713,20 @@ def remove_documents(self, ids: list[int], confirm: bool = True) -> bool: if box.clickedButton() is not remove: return False - # Choose what to select afterwards: the row below the last removed one. + # Choose what to select afterwards: the row below the last removed one, + # in this space (the other space keeps its own current document). order = [item.data(0, Qt.UserRole) for item in self._document_items()] + order += [d.id for d in self.documents if d.id not in order] removed = {d.id for d in documents} - after = [i for i in order[order.index(max(removed, key=order.index)) + 1:] - if i not in removed] if order else [] - before = [i for i in order if i not in removed] + shown = [i for i in order if space_of(self._document(i)) == self.space] + last = max((i for i in removed if i in shown), key=order.index, default=None) + after = [i for i in shown[shown.index(last) + 1:] if i not in removed] \ + if last is not None else [] + before = [i for i in shown if i not in removed] target = after[0] if after else (before[-1] if before else None) + for space, current in list(self._space_current.items()): + if current in removed: + self._space_current[space] = None for document in documents: self._drop_page(document) @@ -1481,6 +1747,7 @@ def remove_documents(self, ids: list[int], confirm: bool = True) -> bool: self.tree.setCurrentItem(None) self.content.setCurrentIndex(0) self._set_title(None) + self._update_palette() noun = "item" if len(documents) == 1 else "items" self.statusBar().showMessage(f"Closed {len(documents)} {noun}", 5000) return True @@ -1641,17 +1908,6 @@ def moved(path: str) -> Path | None: self._remember_recent(document.path) self.pages[document.id].refresh_title() self._update_autosave(document) - if self.workspace is not None: - old_relative, new_relative = self.workspace.relative(old), self.workspace.relative(new) - renamed = set() - for relative in self._expanded: - if relative == old_relative or relative.startswith(old_relative + "/"): - renamed.add(new_relative + relative[len(old_relative):]) - else: - renamed.add(relative) - if renamed != self._expanded: - self._expanded = renamed - self.workspace.update_settings(expanded=sorted(self._expanded)) self._save_session() def export_selected(self) -> None: @@ -1737,11 +1993,10 @@ def _sidebar_menu(self, position) -> None: # A section header: offer to clear that section. section = item ids = [section.child(i).data(0, Qt.UserRole) for i in range(section.childCount())] - kind = {id(self.logs_section): "Logs", id(self.petri_section): "Petri Nets", - id(self.models_section): "Models", id(self.unsaved_section): "Unsaved", + kind = {id(self.analyses_section): "Analyses", id(self.models_section): "Models", + id(self.material_section): "Logs", id(self.unsaved_section): "Unsaved", id(self.elsewhere_section): "Other Files", - id(self.compare_section): "Comparisons", - id(self.ts_section): "Transition Systems"}.get(id(section), "Coloured Nets") + id(self.compare_section): "Comparisons"}.get(id(section), "Items") ids = [i for i in ids if i is not None] # not the "not open" rows if ids: menu.addAction(f"Close All {kind}", lambda: self.remove_documents(ids)) @@ -1754,8 +2009,13 @@ def _sidebar_menu(self, position) -> None: if in_folder: menu.addAction("New Folder…", lambda: self.new_folder()) menu.addSeparator() - menu.addAction("Open File…", self.action_open) - menu.addAction("New Log from Notation…", self.action_notation) + if self.space == MODEL: + menu.addAction("New Petri Net", self.action_new_petri) + menu.addAction("Open Net…", self.action_open) + else: + menu.addAction("New Analysis", lambda: self.action_new_workflow(None)) + menu.addAction("Open Log…", self.action_open) + menu.addAction("New Log from Notation…", self.action_notation) elif len(ids) == 1 and isinstance(self._document(ids[0]), ComparisonDocument): menu.addAction("Export Figures as CSV…", self.export_selected) menu.addSeparator() @@ -1904,6 +2164,46 @@ def action_notation(self) -> None: if dialog.exec() == QDialog.Accepted: self.add_document(LogDocument(dialog.log(), notation=dialog.text())) + # -- Mine: files and typed logs land as boxes ----------------------------------------- + def analysis_for(self, name: str) -> WorkflowPage: + """The current analysis, or a new one called ``name`` when none is open.""" + page = self.current_page() + if isinstance(page, WorkflowPage) and not self.in_learn: + return page + from ...flow.workflow import Workflow + workflow = Workflow(self._unique_name(name or "Untitled"), self.library()) + self.add_document(WorkflowDocument(workflow)) + return self.current_page() + + def action_open_log_box(self) -> None: + """Mine's *Open log…*: the log as an Open log box on the current analysis.""" + paths, _ = QFileDialog.getOpenFileNames( + self, "Open log", dialog_folder(), + "Event logs (*.xes *.xes.gz *.gz *.csv *.txt);;All files (*)") + if paths: + self.boxes_for_files(paths) + + def boxes_for_files(self, paths: list[str]) -> list: + """Input boxes for ``paths`` on the current analysis (or a new one named + after the first file). Returns the nodes.""" + from .workspace import file_stem + page = self.analysis_for(file_stem(Path(paths[0]).name)) + nodes = page.add_files(paths) + self._select_document(page.document) + return nodes + + def action_notation_box(self) -> None: + """Mine's *Log from notation…*: a Typed log box with the text.""" + from ..flow.picker import input_box_for + dialog = NotationDialog(self) + if dialog.exec() != QDialog.Accepted: + return + log = dialog.log() + page = self.analysis_for(log.name) + page.add_box_in_view(input_box_for(self.library(), "typed"), + {"text": dialog.text(), "name": log.name}) + self._select_document(page.document) + def open_files(self, paths: list[str], folder: str | None = None) -> None: """Open files the user chose (Open…, Open Recent, a drop, the command line). @@ -2129,12 +2429,139 @@ def open_example_net(self, net) -> None: from ...model.plain import from_petri_net self.add_document(CpnDocument(from_petri_net(net))) - def edit_petri_net(self, net) -> None: - """Open a (discovered or imported) Petri net in the editor, as a copy.""" + def edit_petri_net(self, net, origin: str = "", source=None) -> None: + """*Open a copy in Model*: a discovered or imported Petri net on the net + canvas, as a copy named "… (copy)". The original (a box's result, a + model page) is never changed. ``origin`` says where it came from + ("from Inductive Miner in Discover and check") and ``source`` is the + document to go back to.""" from ...model.plain import from_petri_net editable = from_petri_net(net) - editable.name = f"{net.name} (edited)" - self.add_document(CpnDocument(editable)) + editable.name = f"{net.name} (copy)" + document = CpnDocument(editable, origin=origin) + document.opened_from = source + self.add_document(document) + self.statusBar().showMessage(f"A copy of {net.name}: change it here; the original stays as it is", + 8000) + + def mine_log(self, log, source) -> None: + """*Mine a simulated log ›*: the log becomes a file in the folder, next + to the model it came from, and opens in Mine as a new analysis with an + Open log box reading it. Without a folder it opens as a log page.""" + from ...flow.workflow import Workflow + from ..flow.picker import input_box_for + if self.workspace is None: + self.add_document(LogDocument(log), near=getattr(source, "path", None)) + return + near = getattr(source, "path", None) + folder = Path(near).resolve().parent if near and self.workspace.contains(near) else self.workspace.folder + target = unique_path(folder, safe_file_name(log.name) + ".xes") + try: + write_xes(log, str(target)) + except OSError as error: + QMessageBox.warning(self, "Mine the simulated log", f"Could not save the log:\n\n{error}") + return + self._created.add(self._key(target)) + library = self.library() + workflow = Workflow(self._unique_name(log.name), library) + workflow.add(input_box_for(library, "log"), {"file": self.workspace.relative(target)}, (0.0, 0.0)) + self.add_document(WorkflowDocument(workflow)) + self.statusBar().showMessage(f"Saved {target.name} into the folder; this analysis reads it", 8000) + + def _log_box_for(self, workflow, document, library, position=(0.0, 0.0)): + """A box that gives ``document``'s log in ``workflow``: Open log for a + file, Typed log for a log written in notation; a log that is neither + is written into the folder first. None (with a message) when it + cannot be done.""" + from ..flow.picker import input_box_for + path = document.path if document.path and not document.missing else None + if path is None and self.workspace is not None: + target = unique_path(self.workspace.folder, safe_file_name(document.name) + ".xes") + try: + write_xes(document.log, str(target)) + except OSError as error: + self.statusBar().showMessage(f"Could not save the log: {error}", 8000) + return None + self._created.add(self._key(target)) + path = str(target) + if path is not None: + relative = self.workspace.relative(path) if self.workspace is not None and \ + self.workspace.contains(path) else path + return workflow.add(input_box_for(library, "log"), {"file": relative}, position) + if document.notation: + return workflow.add(input_box_for(library, "typed"), + {"text": document.notation, "name": document.name}, position) + self.statusBar().showMessage("Export the log first (Export…): the analysis reads it from a file", 8000) + return None + + def discover_in_analysis(self, document, key: str) -> None: + """The log page's Discover: a new analysis in Mine with this log and the + chosen miner, connected and run, the miner's Result open.""" + from ...flow.workflow import Workflow + library = self.library() + + def spec_id(function: str) -> str | None: + return next((s.id for s in library.specs.values() if s.id.endswith("." + function)), None) + + miner = spec_id(key) + if miner is None: + self.statusBar().showMessage(f"The {key} box is not available", 8000) + return + workflow = Workflow(self._unique_name(f"Discover {document.name}"), library) + log = self._log_box_for(workflow, document, library) + if log is None: + return + node = workflow.add(miner, None, (300.0, 0.0)) + workflow.connect(log, node) + last = node + if key == "classical_states": # regions: the net is one box further + to_net = spec_id("regions_to_net") + if to_net is not None: + last = workflow.add(to_net, None, (600.0, 0.0)) + workflow.connect(node, last) + self.add_document(WorkflowDocument(workflow)) + page = self.current_page() + if isinstance(page, WorkflowPage): + page.select(last.id, 0) + self.statusBar().showMessage(f"{workflow.name}: click the miner for its result, how it was derived, " + "and the code", 10000) + + def check_against_log(self, document) -> None: + """*Check against a log ›* on a Petri net: in Mine, a new analysis with + Open net (this net's file), Open log (a file you choose) and Check fit, + connected and run.""" + from ...flow.workflow import Workflow + from ..flow.picker import input_box_for + if not document.path and isinstance(document, ModelDocument) and self.workspace is not None: + self.keep_model(document) # a discovered model: kept in the folder first + if not document.path or document.missing: + self.statusBar().showMessage("Save the net first (it has no file yet): the analysis reads " + "it from its file", 8000) + return + start = str(self.workspace.folder) if self.workspace is not None else dialog_folder() + path, _ = QFileDialog.getOpenFileName(self, "Check against a log", start, + "Event logs (*.xes *.xes.gz *.gz *.csv *.txt);;All files (*)") + if not path: + return + library = self.library() + check = next((s.id for s in library.specs.values() if s.id.endswith(".check_fit")), None) + if check is None: + self.statusBar().showMessage("The Check fit box is not available", 8000) + return + + def file_setting(p: str) -> str: + return self.workspace.relative(p) if self.workspace is not None and self.workspace.contains(p) else p + + workflow = Workflow(self._unique_name(f"{document.name} vs {file_stem(Path(path).name)}"), library) + net = workflow.add(input_box_for(library, file_kind(Path(document.path)) or "petri"), + {"file": file_setting(document.path)}, (0.0, 0.0)) + log = workflow.add(input_box_for(library, "log"), {"file": file_setting(path)}, (0.0, 140.0)) + fit = workflow.add(check, None, (300.0, 70.0)) + workflow.connect(net, fit) + workflow.connect(log, fit) + self.add_document(WorkflowDocument(workflow)) + self.statusBar().showMessage(f"{document.name} against {Path(path).name}: click Check fit for the " + "scores and how they were computed", 10000) def library(self): """The boxes available in this window (the folder's boxes/ included).""" @@ -2503,11 +2930,10 @@ def open_workspace(self, folder: str) -> bool: self.workspace = workspace self._show_notes_of_folder() settings = workspace.settings() - self.view_mode = "kind" if settings.get("view") == "kind" else "folder" - self._expanded = {e for e in settings.get("expanded", []) if isinstance(e, str)} - self.view_switch.blockSignals(True) - self.view_switch.set_index(0 if self.view_mode == "folder" else 1) - self.view_switch.blockSignals(False) + self._material_expanded = bool(settings.get("material_expanded", False)) + self._space_current = {MINE: None, MODEL: None} + saved = settings.get("space") + self._set_space_quietly(saved if saved in (MINE, MODEL) else MINE) set_dialog_folder(str(workspace.folder)) self._remember_workspace(str(workspace.folder)) self._show_workspace_header() @@ -2565,18 +2991,6 @@ def close_workspace(self) -> bool: self._save_session() return True - def set_view_mode(self, mode: str) -> None: - """In a folder: show its subfolders ("folder") or group files by kind ("kind").""" - if mode == self.view_mode: - return - self.view_mode = mode - self.view_switch.blockSignals(True) - self.view_switch.set_index(0 if mode == "folder" else 1) - self.view_switch.blockSignals(False) - if self.workspace is not None: - self.workspace.update_settings(view=mode) - self._rebuild_sidebar() - def _rescan_workspace(self, force: bool = False) -> None: """Bring the sidebar in line with the folder's files (and watch its subfolders).""" if self.workspace is None: @@ -2613,9 +3027,8 @@ def _stop_watching(self) -> None: # -- the sidebar's rows --------------------------------------------------------- def _sections(self) -> list[QTreeWidgetItem]: - return [self.unsaved_section, self.exercises_section, self.logs_section, - self.petri_section, self.models_section, self.cpn_section, self.ts_section, - self.workflows_section, self.elsewhere_section, self.compare_section] + return [self.unsaved_section, self.analyses_section, self.models_section, + self.material_section, self.elsewhere_section, self.compare_section] def _row_id(self, item: QTreeWidgetItem | None): """What a row stands for, so the same row can be found after a rebuild.""" @@ -2649,11 +3062,12 @@ def _listed_files(self) -> list: if self._key(f.path) not in open_paths] def _rebuild_sidebar(self) -> None: - """Lay out the sidebar from the open documents and the folder's files. + """Lay out the sidebar for the current space from the open documents + and the folder's files. Called after anything that changes them. The current row, the - selection, the expanded subfolders and the scroll position stay as - they were, and the page shown does not change. + selection and the scroll position stay as they were, and the page + shown does not change. """ tree = self.tree current = self._row_id(tree.currentItem()) @@ -2666,22 +3080,20 @@ def _rebuild_sidebar(self) -> None: tree.takeTopLevelItem(0) for section in self._sections(): section.takeChildren() - self.items, self.placeholders, self.folder_items = {}, {}, {} - folder_view = self.workspace is not None and self.view_mode == "folder" - tree.setRootIsDecorated(folder_view) - if folder_view: - self._fill_folder_view() - else: - self._fill_kind_view() + self.items, self.placeholders, self.folder_items, self._folder_rows = {}, {}, {}, [] + tree.setRootIsDecorated(True) + self._fill_space_view() for section in self._sections(): - section.setExpanded(True) + section.setExpanded(section is not self.material_section or self._material_expanded + or self.workspace is None) section.setHidden(section.childCount() == 0) self.more_row.setHidden(not (self._tree is not None and self._tree.truncated)) empty = self.workspace is not None and not self.items and not self.placeholders \ and not self.folder_items + self.empty_hint.setText(0, "No analyses or logs yet" if self.space == MINE else "No models yet") self.empty_hint.setHidden(not empty) - for relative, item in self.folder_items.items(): - item.setExpanded(relative in self._expanded) + for item in self._folder_rows: # captions are always open + item.setExpanded(True) # Put back the current row and the selection. rows = {self._row_id(item): item for item in self._all_rows()} if current in rows: @@ -2742,124 +3154,140 @@ def _file_row(self, file, text: str) -> QTreeWidgetItem: self.placeholders[key] = item return item - def _fill_kind_view(self) -> None: - """Rows grouped by kind: logs, Petri nets, models, coloured nets, comparisons.""" + def _fill_space_view(self) -> None: + """The rows of the current space. + + Mine: UNSAVED, ANALYSES (the workflows), LOGS (the folder's event + logs and transition systems, folded away by default). Model: UNSAVED, + MODELS (Petri nets and coloured nets). Both end with OTHER FILES (open + files from outside the folder) and COMPARISONS. Inside a section the + list is flat: the folder's own files first, then the files of each + subfolder under a caption naming it ("Week 5 / Part 1"), the same + way in both spaces, so nothing is nested and nothing moves between + them. Exercises are not listed: they are Learn's (the switcher opens + the folder's pack). + """ tree = self.tree - for section in (self.exercises_section, self.logs_section, self.petri_section, - self.models_section, self.cpn_section, self.ts_section, - self.workflows_section, self.compare_section): + space = self.space + in_folder = self.workspace is not None + main_section = self.analyses_section if space == MINE else self.models_section + tree.addTopLevelItem(self.unsaved_section) + # Every section goes in the tree (an empty one is hidden), so a section + # the space does not use is hidden rather than merely absent. + for section in (self.analyses_section, self.models_section, self.material_section): tree.addTopLevelItem(section) - if self._tree is not None: - for folder in self._tree.walk(): - if folder.exercise: - self.exercises_section.addChild(self._folder_row(folder)) + main_kinds = MAIN_KINDS[space] + self._fill_tree_section(main_section, main_kinds, + [d for d in self.documents if is_main(d, space)], empty_folders=True) + if space == MINE: + material_kinds = tuple(k for k in FILE_KINDS[space] if k not in main_kinds) + material = [d for d in self.documents + if isinstance(d, (LogDocument, TransitionSystemDocument))] + count = self._fill_tree_section(self.material_section, material_kinds, material) + self.material_section.setText(0, f"LOGS · {count}" if in_folder else "LOGS") for document in self.documents: - if isinstance(document, LogDocument): - section = self.logs_section - elif isinstance(document, TransitionSystemDocument): - section = self.ts_section - elif isinstance(document, WorkflowDocument): - section = self.workflows_section - elif isinstance(document, ComparisonDocument): - section = self.compare_section - elif isinstance(document, CpnDocument): - section = self.petri_section if getattr(document.net, "plain", False) \ - else self.cpn_section - else: - section = self.models_section - section.addChild(self._document_row(document)) - files = self._listed_files() - # Rows show the file's name; the subfolder ("logs/…") only when two - # files would otherwise look the same. The tooltip has the full path. - short = [display_name(f.path.name) for f in files] - clashes = {name for name in short if short.count(name) > 1} - sections = {"log": self.logs_section, "petri": self.petri_section, - "cpn": self.cpn_section, "ts": self.ts_section, "workflow": self.workflows_section} - for file, name in zip(files, short): - sections[file.kind].addChild(self._file_row(file, file.name if name in clashes - else name)) - if self.workspace is not None: # rows in a folder are alphabetical - for section in self._sections(): - self._sort_rows(section) + if isinstance(document, ComparisonDocument): + self.compare_section.addChild(self._document_row(document)) tree.addTopLevelItem(self.more_row) + for section in (self.elsewhere_section, self.compare_section): + self._sort_rows(section) + tree.addTopLevelItem(section) + self._sort_rows(self.unsaved_section) tree.addTopLevelItem(self.empty_hint) - def _fill_folder_view(self) -> None: - """Rows as in the folder: subfolders (expandable) and files, as in Finder.""" - tree = self.tree - root = tree.invisibleRootItem() - tree.addTopLevelItem(self.unsaved_section) - tops: list[QTreeWidgetItem] = [] - if self._tree is not None: - for folder in list(self._tree.walk())[1:]: - item = self._folder_row(folder) - parent = self.folder_items.get(folder.relative.rpartition("/")[0]) - (parent.addChild(item) if parent is not None else tops.append(item)) - self.folder_items[folder.relative] = item - - def parent_row(relative: str): - folder = relative.rpartition("/")[0] - while folder and folder not in self.folder_items: - folder = folder.rpartition("/")[0] - return self.folder_items.get(folder) + def _fill_tree_section(self, section: QTreeWidgetItem, kinds: tuple, documents: list, + empty_folders: bool = False) -> int: + """Fill ``section`` with the folder's files of ``kinds`` that are not + open (lighter rows) and with the open ``documents``: the folder's own + files first, then each subfolder's under a caption naming it. A + document with no file goes to UNSAVED, one from outside the folder to + OTHER FILES. With ``empty_folders``, a subfolder with nothing in it + at all gets a caption too (a folder just made, waiting for files; it + shows in both spaces until it has some). Returns how many files and + documents the section holds.""" + count = 0 + captions: dict[str, QTreeWidgetItem] = {} + by_relative = {f.relative: f for f in (self._tree.walk() if self._tree else [])} + # An exercise's materials belong to Learn: they are not listed here. + exercises = tuple(rel + "/" for rel, folder in by_relative.items() if folder.exercise and rel) + + def caption(relative: str) -> QTreeWidgetItem: + if relative not in captions: + folder = by_relative.get(relative) + item = self._caption_row(relative, folder.path if folder is not None + else self.workspace.folder / relative) + section.addChild(item) + captions[relative] = item + self._folder_rows.append(item) + self.folder_items.setdefault(relative, item) + return captions[relative] for file in self._listed_files(): + if file.kind not in kinds or file.relative.startswith(exercises): + continue item = self._file_row(file, display_name(file.path.name)) - parent = parent_row(file.relative) - (parent.addChild(item) if parent is not None else tops.append(item)) - for document in self.documents: + above = file.relative.rpartition("/")[0] + (caption(above) if above else section).addChild(item) + count += 1 + for document in documents: item = self._document_row(document) - if isinstance(document, ComparisonDocument): - self.compare_section.addChild(item) - elif not document.path: + if not document.path: self.unsaved_section.addChild(item) + elif self.workspace is None: + section.addChild(item) + count += 1 elif not self.workspace.contains(document.path): self.elsewhere_section.addChild(item) else: - parent = parent_row(self.workspace.relative(document.path)) - (parent.addChild(item) if parent is not None else tops.append(item)) - for item in sorted(tops, key=self._sort_key): - root.addChild(item) - for item in self.folder_items.values(): + above = self.workspace.relative(document.path).rpartition("/")[0] + (caption(above) if above else section).addChild(item) + count += 1 + if empty_folders: + for relative, folder in by_relative.items(): + if relative and not folder.exercise and relative not in captions \ + and not relative.startswith(exercises) and _empty_on_disk(folder.path): + caption(relative) + for item in (section, *captions.values()): self._sort_rows(item) - tree.addTopLevelItem(self.more_row) - for section in (self.elsewhere_section, self.compare_section): - self._sort_rows(section) - tree.addTopLevelItem(section) - tree.addTopLevelItem(self.empty_hint) + return count - def _folder_row(self, folder) -> QTreeWidgetItem: - """A subfolder's row; an exercise gets its own icon and opens with a click.""" - item = QTreeWidgetItem([folder.name]) - item.setData(0, FOLDER_ROLE, str(folder.path)) - if folder.exercise: - item.setIcon(0, _icon("exercise")) - item.setData(0, EXERCISE_ROLE, True) - item.setToolTip(0, f"{folder.relative}\nExercise — click to do it") - else: - item.setIcon(0, _icon("folder")) - item.setToolTip(0, folder.relative) + def _caption_row(self, relative: str, path: Path) -> QTreeWidgetItem: + """A subfolder's caption ("Week 5 / Part 1"): the rows under it are its + files. Not a folder to open or fold, but it takes a drop and a + right-click (New Folder…, Rename…, Show in Finder, Move to Bin).""" + item = QTreeWidgetItem([relative.replace("/", " / ")]) + item.setFlags(Qt.ItemIsEnabled) + item.setData(0, FOLDER_ROLE, str(path)) + item.setData(0, GROUP_ROLE, True) + item.setFont(0, theme.ui_font(11)) + item.setForeground(0, QColor(style.tokens().text_muted)) + item.setToolTip(0, f"{relative}\nA subfolder: the files in it are listed under it") return item @staticmethod def _sort_key(item: QTreeWidgetItem): - """Folders first, then by name, ignoring case (as Finder sorts).""" - return (0 if item.data(0, FOLDER_ROLE) else 1, item.text(0).casefold()) + """Files first, by name ignoring case, then the subfolders' captions by path.""" + return (1 if item.data(0, GROUP_ROLE) else 0, item.text(0).casefold()) def _sort_rows(self, parent: QTreeWidgetItem) -> None: children = parent.takeChildren() parent.addChildren(sorted(children, key=self._sort_key)) def _folder_toggled(self, item: QTreeWidgetItem, expanded: bool) -> None: - """A subfolder was expanded or collapsed: remember it in the folder.""" - path = item.data(0, FOLDER_ROLE) - if not path or self.workspace is None: + """The LOGS section was folded or unfolded: remember it in the folder.""" + if item is self.material_section: + self._material_expanded = expanded + if self.workspace is not None: + self.workspace.update_settings(material_expanded=expanded) return - relative = self.workspace.relative(path) - changed = (relative not in self._expanded) if expanded else (relative in self._expanded) - if changed: - (self._expanded.add if expanded else self._expanded.discard)(relative) - self.workspace.update_settings(expanded=sorted(self._expanded)) + if item.data(0, GROUP_ROLE) and not expanded: + item.setExpanded(True) # a caption never folds + + def _sidebar_clicked(self, item) -> None: + if item is self.material_section: + item.setExpanded(not item.isExpanded()) + return + self._open_placeholder(item) def _open_placeholder(self, item) -> None: try: @@ -2936,7 +3364,6 @@ def _show_workspace_header(self) -> None: active = self.workspace is not None self.workspace_caption.setHidden(not active) self.workspace_button.setHidden(not active) - self.view_row.setHidden(not active) self.sidebar_title.setText(self.workspace.name if active else APPLICATION_NAME) self.sidebar_title.setToolTip(str(self.workspace.folder) if active else "") self.close_workspace_action.setEnabled(active) @@ -3582,11 +4009,6 @@ def new_folder(self, inside: str | None = None) -> None: except OSError as error: QMessageBox.warning(self, "New Folder", str(error.strerror or error)) return - if parent.resolve() != self.workspace.folder: - self._expanded.add(self.workspace.relative(parent)) - self.workspace.update_settings(expanded=sorted(self._expanded)) - if self.view_mode != "folder": - self.set_view_mode("folder") self._rescan_workspace() item = self.folder_items.get(self.workspace.relative(target)) if item is not None: @@ -3640,8 +4062,8 @@ def trash_paths_quietly(self, paths: list[str]) -> None: move_to_trash(path) def _drop_folder(self, item) -> str | None: - """Where a drop on this sidebar row goes, in the Folder view.""" - if self.workspace is None or self.view_mode != "folder": + """Where a drop on this sidebar row goes (None: no folder is open).""" + if self.workspace is None: return None root = str(self.workspace.folder) if item is None: @@ -3655,7 +4077,7 @@ def _drop_folder(self, item) -> str | None: def _sidebar_drop(self, paths: list[str], folder: str | None, internal: bool) -> None: """Files dropped on the sidebar: moved within the folder, or brought in.""" - if folder is None: # not the Folder view + if folder is None: # no folder open if not internal: self._open_dropped(paths) return @@ -3785,7 +4207,10 @@ def open_exercise(self, folder: str | Path) -> bool: self._flush_autosaves() if not self.learn_mode.open_pack(root, start): return False + self._space_before_learn = self.space self.modes.setCurrentWidget(self.learn_mode) + self._sync_space_switch() + motion.lift(self.modes) self.learn_mode._update_bar() # the window's title names the exercise self.statusBar().showMessage("Answers are saved in each exercise's folder as you go", 6000) @@ -3795,10 +4220,15 @@ def open_exercise(self, folder: str | Path) -> bool: def in_learn(self) -> bool: return self.modes.currentWidget() is self.learn_mode - def leave_learn(self) -> None: - """Exit: back to the folder, as it was (with your answers' files now in it).""" + def leave_learn(self, to: str | None = None) -> None: + """Exit: back to the space you came from (``to``: another), as it was, + with your answers' files now in the folder.""" self.learn_mode.close_view() self.modes.setCurrentIndex(0) + if to is not None and to != self.space: + self._set_space_quietly(to) + self._sync_space_switch() + motion.lift(self.modes) if self.workspace is not None: self._rescan_workspace(force=True) self._set_title(self._current_document()) # back to the file you had open diff --git a/openprocess/gui/studio/connections.py b/openprocess/gui/studio/connections.py new file mode 100644 index 0000000..dffb667 --- /dev/null +++ b/openprocess/gui/studio/connections.py @@ -0,0 +1,185 @@ +"""The *Connections* view: what flows into OpenProcess, what flows out, and +the tools that plug in, drawn as a hub. + +Three bands. **In**: the file formats and sources the input boxes read. +**OpenProcess**: the boxes, by group, and the folder's own. **Out**: what the +output boxes and the exports hand to other tools. Beside them, **Tools**: +the optional packages boxes need (``needs="pandas"``), each with whether it +is installed, how to install it, and the boxes it brings. Nothing here is +declared twice: the cards are read from the box library. +""" + +from __future__ import annotations + +import sys +from importlib.util import find_spec + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QScrollArea, QVBoxLayout, QWidget + +from ...flow.library import Library +from .widgets import Card, PageHeader, button, flow, hbox, label + +#: (title, what it is, the input boxes' function names) +INPUTS = [ + ("Event logs", "XES (also gzipped), CSV, or a .txt in the course notation", ("open_log",)), + ("Petri nets", "PNML files, from any tool", ("open_net",)), + ("Coloured nets", "CPN Tools .cpn files", ("open_cpn",)), + ("Transition systems", "ts.txt, typed as s0 -a-> s1", ("open_transition_system",)), + ("Typed logs", "the course notation, [^3, ]", ("typed_log",)), + ("Public datasets", "the BPI Challenge logs, from the cache folder", ("open_dataset",)), + ("Simulations", "a model played out, as a log", ("simulate_log",)), +] +#: (title, what it is, the output boxes' function names) +OUTPUTS = [ + ("PNML", "Petri nets, for ProM, PM4Py and others", ("save_pnml",)), + ("Event logs", "XES, for any mining tool", ("save_log",)), + ("Tables", "CSV, for pandas, R or a spreadsheet", ("save_table",)), + ("Figures", "SVG or PNG, for a paper", ("save_figure",)), + ("Experiment", "a zip of everything, from ⋯ ▸ Export experiment", ()), + ("Python", "every box is a function: call it from a script or a notebook", ()), +] +#: What pip installs for each tool (the package itself, pinned as the app's extras pin it). +PACKAGES = {"pandas": "pandas>=2.0", "numpy": "numpy>=1.24", "scipy": "scipy>=1.10", + "matplotlib": "matplotlib>=3.7", "pm4py": "pm4py"} + + +def package_for(tool: str) -> str: + return PACKAGES.get(tool, tool) + + +def can_install() -> bool: + """Whether the app can run pip for itself: a Python install with pip, not + the downloaded app (whose bundled Python has no pip and is read-only).""" + return not getattr(sys, "frozen", False) and find_spec("pip") is not None + + +def _by_function(library: Library) -> dict[str, object]: + return {spec.id.rsplit(".", 1)[-1]: spec for spec in library.specs.values()} + + +def tools(library: Library) -> list[dict]: + """The optional packages boxes need: name, installed?, how to install, the boxes.""" + found: dict[str, list[str]] = {} + for spec in library.specs.values(): + for need in getattr(spec, "needs", ()) or (): + found.setdefault(need, []).append(spec.name) + return [{"name": name, "installed": find_spec(name.split(".")[0]) is not None, + "install": f"pip install {package_for(name)}", "boxes": sorted(set(boxes))} + for name, boxes in sorted(found.items())] + + +class ConnectionsPage(QWidget): + """The hub picture, rebuilt by :meth:`refresh` from the window's library.""" + + #: *Install…* on a tool's card: the tool's name (the window runs pip). + install_requested = Signal(str) + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.installing: set[str] = set() + self.install_buttons: dict[str, object] = {} + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + self.header = PageHeader("Connections", "What flows in, what flows out, and the tools that plug in") + root.addWidget(self.header) + self.body = QWidget() + self.body.setObjectName("plain") + self.body_layout = QVBoxLayout(self.body) + self.body_layout.setContentsMargins(20, 4, 20, 24) + self.body_layout.setSpacing(18) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.NoFrame) + scroll.setWidget(self.body) + root.addWidget(scroll, 1) + self.cards: dict[str, Card] = {} + + def refresh(self, library: Library, folder=None) -> None: + while self.body_layout.count(): + item = self.body_layout.takeAt(0) + if item.widget() is not None: + item.widget().hide() + item.widget().deleteLater() + self.cards = {} + functions = _by_function(library) + + def band(caption: str, text: str, cards: list[Card]) -> None: + self.body_layout.addWidget(label(caption, "sectionLabel")) + if text: + self.body_layout.addWidget(label(text, "muted", wrap=True)) + self.body_layout.addWidget(flow(*cards, spacing=10)) + + def card(title: str, text: str, boxes: list[str], status: str = "", tone: str = "good") -> Card: + item = Card(title, text) + item.setMinimumWidth(200) + item.setMaximumWidth(300) + if status: + chip = label(status, "statusChip") + chip.setStyleSheet(f"background: {_tone(tone)}; color: white;") + item.header.addWidget(chip) + if boxes: + item.add(label("Boxes: " + ", ".join(boxes), "muted", wrap=True)) + self.cards[title] = item + return item + + inputs = [] + for title, text, names in INPUTS: + specs = [functions[n] for n in names if n in functions] + inputs.append(card(title, text, [s.name for s in specs], + "" if specs else "no box", "good" if specs else "muted")) + band("IN", "What the input boxes read. Drop any of these on the canvas, or pick them in + Add box.", + inputs) + + groups = library.by_group() + custom = [s for s in library.specs.values() if getattr(s, "custom", False)] + middle = card("OpenProcess", f"{len(library)} boxes in {len(groups)} groups: " + + ", ".join(groups) + ".", []) + own = card("Your own boxes", "A Python function in the folder's boxes/ subfolder is a box: " + "type hints make its inputs and settings, its docstring its help.", + [s.name for s in custom][:12], + f"{len(custom)} in this folder" if custom else ("none yet" if folder else "open a folder"), + "good" if custom else "muted") + band("OPENPROCESS", "", [middle, own]) + + tool_cards = [] + self.install_buttons = {} + for tool in tools(library): + name = tool["name"] + if tool["installed"]: + text, status, tone = "Installed.", "ready", "good" + elif name in self.installing: + text, status, tone = "Installing… (a minute or two; the app stays usable)", "installing", "muted" + elif can_install(): + text, status, tone = f"Not installed. Install… runs {tool['install']} for this app.", \ + "not installed", "warning" + else: + text, status, tone = (f"Not installed. The downloaded app cannot install packages: in a " + f"Python install, run {tool['install']}."), "not installed", "warning" + item = card(name, text, tool["boxes"], status, tone) + if not tool["installed"] and can_install() and name not in self.installing: + install = button("Install…", lambda _checked=False, n=name: self.install_requested.emit(n), + kind="primary", tooltip=f"Runs {tool['install']} in the background") + self.install_buttons[name] = install + item.add(hbox(install, None)) + tool_cards.append(item) + band("TOOLS", "Optional packages that boxes need. A box whose package is missing is listed " + "greyed out until it is installed.", tool_cards or [card("None needed", "Every box runs " + "with what is installed.", [])]) + + outputs = [] + for title, text, names in OUTPUTS: + specs = [functions[n] for n in names if n in functions] + outputs.append(card(title, text, [s.name for s in specs])) + band("OUT", "What leaves OpenProcess for other tools.", outputs) + self.body_layout.addStretch(1) + + +def _tone(tone: str) -> str: + from . import style + return {"good": style.STATUS["good"], "warning": style.STATUS["warning"], + "muted": style.tokens().text_muted}.get(tone, style.tokens().text_muted) + + +__all__ = ["ConnectionsPage", "tools", "can_install", "package_for", "INPUTS", "OUTPUTS"] diff --git a/openprocess/gui/studio/cpn_page.py b/openprocess/gui/studio/cpn_page.py index 9ace055..d326f51 100644 --- a/openprocess/gui/studio/cpn_page.py +++ b/openprocess/gui/studio/cpn_page.py @@ -597,10 +597,10 @@ def _build_simulation_tab(self) -> QWidget: self.history_list.setMaximumHeight(240) self.history_list.itemDoubleClicked.connect(self._rewind_to_item) history.add(self.history_list) - self.export_log_button = button("Export as event log…", self._export_log, - tooltip="Turn the firings so far into an event log " - "and open it for mining (dotted chart, " - "discovery, …)") + self.export_log_button = button("Mine the simulated log ›", self._export_log, + tooltip="Turn the firings so far into an event log, save " + "it in the folder and open it in Mine (dotted chart, " + "discovery, conformance, …)") history.add(hbox(None, self.export_log_button)) layout.addWidget(history) layout.addStretch(1) @@ -690,6 +690,9 @@ def _build_state_space_tab(self) -> QWidget: card = Card("State space", "Every reachable marking (with its model time) and the " "binding elements between them, explored breadth-first from the initial " "marking — the analysis CPN Tools calls the state space tool.") + from ...analysis.state_space import StateSpace + from .provenance import add_code + add_code(card, StateSpace) self.max_nodes = QSpinBox() self.max_nodes.setRange(10, 1_000_000) self.max_nodes.setValue(20_000) diff --git a/openprocess/gui/studio/documents.py b/openprocess/gui/studio/documents.py index 7e4ad29..73c4f04 100644 --- a/openprocess/gui/studio/documents.py +++ b/openprocess/gui/studio/documents.py @@ -28,6 +28,8 @@ class LogDocument: #: The text the log was written in, for a log typed in the course's #: notation or read from a ``….log.txt`` file (None for XES and CSV logs). notation: str | None = None + #: The document (a workflow) this one was opened from, for the way back; not saved. + opened_from: object | None = field(default=None, repr=False) def __post_init__(self) -> None: self.classifier: Classifier = self.log.default_classifier() @@ -80,6 +82,8 @@ class ModelDocument: id: int = field(default_factory=lambda: next(_ids)) #: Its file was deleted or moved away while it was open. missing: bool = False + #: The document (a workflow) this one was opened from, for the way back; not saved. + opened_from: object | None = field(default=None, repr=False) @property def name(self) -> str: @@ -95,10 +99,14 @@ class CpnDocument: id: int = field(default_factory=lambda: next(_ids)) #: Edited since it was opened or last saved. dirty: bool = False + #: For a copy: where it came from ("from Inductive Miner in Discover and check"). + origin: str = "" #: Edits are saved by themselves (a file in the open folder): no "edited" mark. autosave: bool = False #: Its file was deleted or moved away while it was open. missing: bool = False + #: The document (a workflow) this one was opened from, for the way back; not saved. + opened_from: object | None = field(default=None, repr=False) @property def name(self) -> str: @@ -146,6 +154,8 @@ class TransitionSystemDocument: id: int = field(default_factory=lambda: next(_ids)) #: Its file was deleted or moved away while it was open. missing: bool = False + #: The document (a workflow) this one was opened from, for the way back; not saved. + opened_from: object | None = field(default=None, repr=False) @property def name(self) -> str: diff --git a/openprocess/gui/studio/graph_view.py b/openprocess/gui/studio/graph_view.py index 4448d37..e043fd1 100644 --- a/openprocess/gui/studio/graph_view.py +++ b/openprocess/gui/studio/graph_view.py @@ -145,10 +145,18 @@ def mousePressEvent(self, event) -> None: # noqa: N802 scene = self.scene() if isinstance(scene, GraphScene) and event.button() == Qt.LeftButton: scene.node_clicked.emit(self.spec.id) - event.accept() - return + if not (self.flags() & QGraphicsItem.ItemIsMovable): + event.accept() + return super().mousePressEvent(event) + def itemChange(self, change, value): # noqa: N802 + if change == QGraphicsItem.ItemPositionHasChanged: + scene = self.scene() + if isinstance(scene, GraphScene): + scene.node_dragged(self) + return super().itemChange(change, value) + def paint(self, painter: QPainter, option, widget=None) -> None: t = style.tokens() spec = self.spec @@ -398,18 +406,24 @@ def paint(self, painter: QPainter, option, widget=None) -> None: # --------------------------------------------------------------------------- class GraphScene(QGraphicsScene): node_clicked = Signal(str) + #: A node was dragged (the scene is movable): its id and new position. + node_moved = Signal(str, float, float) def __init__(self) -> None: super().__init__() self.nodes: dict[str, NodeItem] = {} self.edges: list[EdgeItem] = [] + #: Nodes can be dragged to tidy the drawing (nothing else changes). + self.movable = False def populate(self, nodes: list[NodeSpec], edges: list[EdgeSpec], positions: dict[str, tuple[float, float]] | None = None, - layer_gap: float = 70.0) -> None: - """Replace the scene contents. Lays out unless ``positions`` is given.""" + layer_gap: float = 70.0, movable: bool = False) -> None: + """Replace the scene contents. Lays out unless ``positions`` is given. + ``movable``: the nodes can be dragged (see :attr:`node_moved`).""" self.clear() self.nodes, self.edges = {}, [] + self.movable = movable sizes = {n.id: node_size(n) for n in nodes} # Make room between columns for the widest edge label, so labels # never sit on top of the nodes they connect (capped for sanity). @@ -432,6 +446,10 @@ def populate(self, nodes: list[NodeSpec], edges: list[EdgeSpec], item = NodeItem(spec) x, y = positions[spec.id] item.setPos(x, y) + if movable: + item.setFlag(QGraphicsItem.ItemIsMovable, True) + item.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True) + item.setCursor(Qt.OpenHandCursor) self.addItem(item) self.nodes[spec.id] = item pairs = {(e.source, e.target) for e in edges if e.source != e.target} @@ -450,6 +468,19 @@ def populate(self, nodes: list[NodeSpec], edges: list[EdgeSpec], rect = self.itemsBoundingRect().adjusted(-40, -40, 40, 40) self.setSceneRect(rect) + def node_dragged(self, item: NodeItem) -> None: + """A node moved: its edges follow (as straight lines now) and listeners hear.""" + for edge in self.edges: + if edge.source_item is item or edge.target_item is item: + edge.route = [edge.source_item.pos(), edge.target_item.pos()] + edge.prepareGeometryChange() + edge.rebuild() + self.node_moved.emit(item.spec.id, item.pos().x(), item.pos().y()) + + def positions(self) -> dict[str, tuple[float, float]]: + """Every node's position, by id (to keep a tidied layout).""" + return {node_id: (item.pos().x(), item.pos().y()) for node_id, item in self.nodes.items()} + def update_node(self, spec: NodeSpec) -> None: """Change a node's appearance in place (token game, overlays).""" item = self.nodes.get(spec.id) diff --git a/openprocess/gui/studio/log_page.py b/openprocess/gui/studio/log_page.py index 623e28a..951f8ec 100644 --- a/openprocess/gui/studio/log_page.py +++ b/openprocess/gui/studio/log_page.py @@ -31,6 +31,7 @@ from .documents import LogDocument, ModelDocument from .dotted_chart import DottedChartPanel from .graph_builders import dependency_specs, dfg_specs, petri_net_specs +from .provenance import add_code from .graph_view import GraphView from .widgets import ( suggested_path, @@ -89,6 +90,8 @@ def _table(model: QStandardItemModel) -> QTableView: class LogPage(ConcealsResults, QWidget): open_model = Signal(object) # ModelDocument + #: Discover (the studio's log page): build the analysis in Mine with this algorithm. + discover_requested = Signal(str) open_log = Signal(object) # LogDocument (a filtered copy) status = Signal(str) #: Emitted after the log was exported; the document now lives in that file. @@ -96,9 +99,13 @@ class LogPage(ConcealsResults, QWidget): #: The log was edited (Edit…): the document holds the new log. edited = Signal() - def __init__(self, document: LogDocument, parent=None) -> None: + def __init__(self, document: LogDocument, parent=None, discover: str = "inline") -> None: + """``discover``: "inline" runs the miners on this page (Learn, where the + exercise is the context); "handoff" (the studio) sends discovery to an + analysis in Mine, where every step is shown and recorded.""" super().__init__(parent) self.document = document + self.discover_mode = discover self._built: set[int] = set() root = QVBoxLayout(self) @@ -238,7 +245,8 @@ def _show_tab(self, index: int) -> None: if index not in self._built: builder = [self._build_overview, self._build_variants, self._build_cases, self._build_dotted, self._build_map, self._build_footprint, - self._build_discover][index] + self._build_discover_handoff if self.discover_mode == "handoff" + else self._build_discover][index] builder(self.pages[index].layout()) self._built.add(index) self.stack.setCurrentIndex(index) @@ -492,6 +500,7 @@ def redraw(fit: bool = True) -> None: card = Card("Process map", "Directly-follows graph: an arrow a → b means b directly " "followed a in some case. Drag the sliders to simplify.") + add_code(card, discover_dfg, type(dfg).simplified) top_row = hbox(mode, 12, info) top_row.setStretch(2, 1) # the info text takes the slack card.add(top_row) @@ -508,6 +517,7 @@ def _build_footprint(self, layout) -> None: table = footprint_table(fp) card = Card("Footprint matrix", "Log-based ordering relations derived from the " "directly-follows relation >_L (row activity vs column activity).") + add_code(card, footprint_of_log) card.add(label("→ causality: a > b and not b > a ← inverse " "‖ parallel: a > b and b > a # choice: neither", "muted", wrap=True)) card.add(table, 1) @@ -519,6 +529,43 @@ def _build_footprint(self, layout) -> None: layout.addWidget(card, 1) # -------------------------------------------------------------- discover + #: The miners offered on the studio's Discover tab, each the box that runs it. + DISCOVER_BOXES = [ + ("inductive_miner", "Inductive Miner", "Divide-and-conquer on the DFG. Always sound, always " + "fits the log perfectly."), + ("alpha_miner", "α-algorithm", "Classic footprint-based miner, in eight steps; cannot " + "handle short loops, noise or silent steps."), + ("heuristics_miner", "Heuristics Miner", "Frequency-based dependency measures, robust to " + "noise; which forks are AND or XOR is learned from the log."), + ("classical_states", "State-based regions", "The log becomes a transition system, whose " + "minimal regions become places (two boxes: Classical states, Regions to net)."), + ] + + def _build_discover_handoff(self, layout) -> None: + """The studio's Discover tab: no miner runs here. It hands the log to an + analysis in Mine, where the boxes show their result, how it was derived, + the code, and keep a record that can be run again.""" + card = Card("Discover a model", "Discovery happens on the canvas, where you can see what is " + "done: the box's result, how it was derived step by step, the code it ran, " + "and a record that runs again. Pick a miner; the analysis is built for you, " + "with this log already connected.") + chooser = QComboBox() + for key, name, _text in self.DISCOVER_BOXES: + chooser.addItem(name, key) + chooser.setSizeAdjustPolicy(QComboBox.AdjustToContents) + about = label(self.DISCOVER_BOXES[0][2], "muted", wrap=True) + chooser.currentIndexChanged.connect( + lambda i: about.setText(self.DISCOVER_BOXES[i][2])) + go = button("Discover in Mine →", lambda: self.discover_requested.emit(chooser.currentData()), + kind="primary", tooltip="A new analysis: Open log (this log), the miner, connected and run") + self.discover_chooser, self.discover_button = chooser, go + card.add(hbox(label("Algorithm"), chooser, None, go)) + card.add(about) + card.add(label("Then add a check (Check fit, Soundness), a comparison, or a sweep over a " + "setting, all on the same canvas.", "muted", wrap=True)) + layout.addWidget(card) + layout.addStretch(1) + def _build_discover(self, layout) -> None: algorithms = [ ("im", "Inductive Miner", "Divide-and-conquer on the DFG. Always sound, always fits " diff --git a/openprocess/gui/studio/model_page.py b/openprocess/gui/studio/model_page.py index 47ff825..e45f7e6 100644 --- a/openprocess/gui/studio/model_page.py +++ b/openprocess/gui/studio/model_page.py @@ -42,6 +42,7 @@ from .graph_builders import petri_net_specs, state_graph_specs from .graph_view import GraphView from .log_page import _item, _table, derivation_widget +from .provenance import add_code from .widgets import ( suggested_path, Card, ElidedLabel, PageHeader, SegmentedControl, StatTile, Verdict, button, fitness_status, flow, hbox, label, @@ -100,10 +101,17 @@ class ModelPage(ConcealsResults, QWidget): #: "Keep" was pressed: save this discovered model into the open folder. keep_requested = Signal() - def __init__(self, document: ModelDocument, open_logs, parent=None) -> None: - """``open_logs`` is a callable returning the currently open LogDocuments.""" + #: "Check against a log ›" (the studio's conformance tab): the window builds the analysis in Mine. + check_requested = Signal() + + def __init__(self, document: ModelDocument, open_logs, parent=None, conformance: str = "inline") -> None: + """``open_logs`` is a callable returning the currently open LogDocuments. + ``conformance``: "inline" replays logs on this page (Learn, where the + exercise is the context); "handoff" (the studio) sends it to an + analysis in Mine, where every step is shown and recorded.""" super().__init__(parent) self.document = document + self.conformance_mode = conformance self.net = document.net self.open_logs = open_logs self.marking: Marking = self.net.initial_marking @@ -172,7 +180,7 @@ def _build_canvas(self) -> QWidget: self.speed.setValue(4) self.speed.setFixedWidth(110) self.speed.setToolTip("Speed: firings per second") - self.generate_button = _tool("Generate event log…", "Play the model out many times and " + self.generate_button = _tool("Mine a simulated log ›", "Play the model out many times and " "add the resulting event log to the sidebar") self.sim_hint = label("", "muted", wrap=True) self.simulate_widgets = [self.play_button, self.speed, self.speed_label_widget(), @@ -437,7 +445,9 @@ def _build_analysis(self) -> QWidget: self.soundness_card = Card("Soundness", "Classical soundness of the WF-net: option " "to complete, proper completion, no dead transitions. " "Hover over a property (ⓘ) for its definition.") + add_code(self.soundness_card, check_soundness, short_circuit, analyse) self.properties_card = Card("Behavioural properties") + add_code(self.properties_card, analyse, reachability_graph, coverability_graph) check = button("Run analysis", self._run_analysis, kind="primary") layout.addWidget(check) layout.addWidget(self.soundness_card) @@ -525,8 +535,23 @@ def _build_conformance(self) -> QWidget: layout = QVBoxLayout(page) layout.setContentsMargins(0, 0, 4, 0) layout.setSpacing(12) + if self.conformance_mode == "handoff": + card = Card("Check against a log", "Conformance happens on the canvas, where you can see " + "what is done: Check fit's scores, how each case replayed, the code, and a " + "record that runs again. Pick a log; the analysis is built for you, with " + "this model already connected.") + self.check_log_button = button("Check against a log →", self.check_requested.emit, kind="primary", + tooltip="A new analysis in Mine: Open net (this model), Open log, " + "Check fit, connected and run") + card.add(hbox(self.check_log_button, None)) + card.add(label("Then add Token replay or Alignments for the per-case view, Soundness, or a " + "comparison with another model.", "muted", wrap=True)) + layout.addWidget(card) + layout.addStretch(1) + return page chooser = Card("Replay a log", "Measures how well this model and a log agree.") + add_code(chooser, token_replay, align_log, precision, generalisation, simplicity) self.log_box = QComboBox() self.refresh_logs() self.run_conformance = button("Check conformance", self._check, kind="primary") @@ -584,6 +609,8 @@ def _build_conformance(self) -> QWidget: return page def refresh_logs(self) -> None: + if not hasattr(self, "log_box"): # the handoff tab has no log to choose + return current = self.log_box.currentData() if self.log_box.count() else None self.log_box.clear() logs = self.open_logs() diff --git a/openprocess/gui/studio/motion.py b/openprocess/gui/studio/motion.py new file mode 100644 index 0000000..3fce3b5 --- /dev/null +++ b/openprocess/gui/studio/motion.py @@ -0,0 +1,95 @@ +"""Motion: the few short transitions that make the window read as one flow. + +Nothing here changes what a widget does; it only smooths how it appears. + +* :func:`lift` -- a veil in the page's colour over a widget that lifts in + about 180 ms, for a change that replaces what the widget shows (a space + switch, the side panel appearing, Canvas ↔ Summary). +* :func:`fade_in` -- a popup window fading in (the + Add box picker). +* A box on the canvas pulses once when it finishes (see + :meth:`~openprocess.gui.flow.canvas.BoxItem.set_status`). + +Set the environment variable ``OPENPROCESS_NO_MOTION=1`` to turn it all off +(for screenshots, slow machines, or motion sensitivity); :data:`enabled` can +also be switched at run time. +""" + +from __future__ import annotations + +import os + +from PySide6.QtCore import Property, QEasingCurve, QPropertyAnimation, Qt +from PySide6.QtGui import QColor, QPainter +from PySide6.QtWidgets import QWidget + +from . import style + +#: Whether transitions play at all. +enabled = os.environ.get("OPENPROCESS_NO_MOTION", "") != "1" +LIFT_MS = 180 +FADE_MS = 120 + + +class Veil(QWidget): + """A sheet in the page's colour over its parent, whose opacity animates.""" + + def __init__(self, parent: QWidget, colour: QColor) -> None: + super().__init__(parent) + self._opacity = 1.0 + self.colour = colour + self.setAttribute(Qt.WA_TransparentForMouseEvents) + self.setAttribute(Qt.WA_NoSystemBackground) + self.animation: QPropertyAnimation | None = None + + def get_opacity(self) -> float: + return self._opacity + + def set_opacity(self, value: float) -> None: + self._opacity = max(0.0, min(1.0, float(value))) + self.update() + + opacity = Property(float, get_opacity, set_opacity) + + def paintEvent(self, _event) -> None: # noqa: N802 + painter = QPainter(self) + colour = QColor(self.colour) + colour.setAlphaF(self._opacity) + painter.fillRect(self.rect(), colour) + painter.end() + + +def lift(widget: QWidget | None, duration: int = LIFT_MS) -> Veil | None: + """Cover ``widget`` with a veil that lifts: what it now shows settles in. + Returns the veil (None when motion is off or the widget is not showing).""" + if not enabled or widget is None or not widget.isVisible(): + return None + veil = Veil(widget, QColor(style.tokens().page)) + veil.setGeometry(widget.rect()) + veil.show() + veil.raise_() + animation = QPropertyAnimation(veil, b"opacity", veil) + animation.setDuration(duration) + animation.setStartValue(1.0) + animation.setEndValue(0.0) + animation.setEasingCurve(QEasingCurve.OutCubic) + animation.finished.connect(veil.deleteLater) + veil.animation = animation + animation.start() + return veil + + +def fade_in(window: QWidget, duration: int = FADE_MS) -> QPropertyAnimation | None: + """A top-level window (a popup) fades in as it shows.""" + if not enabled or not window.isWindow(): + return None + window.setWindowOpacity(0.0) + animation = QPropertyAnimation(window, b"windowOpacity", window) + animation.setDuration(duration) + animation.setStartValue(0.0) + animation.setEndValue(1.0) + animation.setEasingCurve(QEasingCurve.OutCubic) + animation.start() + return animation + + +__all__ = ["enabled", "lift", "fade_in", "Veil"] diff --git a/openprocess/gui/studio/petri_page.py b/openprocess/gui/studio/petri_page.py index 9901f67..aabada3 100644 --- a/openprocess/gui/studio/petri_page.py +++ b/openprocess/gui/studio/petri_page.py @@ -55,6 +55,7 @@ Card, Verdict, button, flow, footprint_table, hbox, label, status_for, ) from . import instances +from .provenance import add_code from .definition_view import attach_definition, show_reference from .workers import run_in_background @@ -76,6 +77,10 @@ def _math(name: str, status: str, detail: str, definition: str | None = None, class PetriNetPage(ConcealsResults, CpnPage): """Draw, play and analyse a plain Petri net (see the module docstring).""" + #: "Check against a log ›" was pressed: the window builds the analysis in Mine. + check_requested = Signal() + + #: The net as a model for conformance checking / the model views. open_model = Signal(object) @@ -89,9 +94,9 @@ def __init__(self, document, parent=None) -> None: self.structure_toggle.hide() self._toggle_structure(False) self.values_button.hide() - self.export_log_button.setText("Generate event log…") - self.export_log_button.setToolTip("Play the net out many times (random choices) and " - "open the traces as an event log") + self.export_log_button.setText("Mine a simulated log ›") + self.export_log_button.setToolTip("Play the net out many times (random choices), save the " + "traces as an event log in the folder and open it in Mine") self.inspector_tabs.changed.connect(self._inspector_tab_changed) # Plain nets have no variables and no clock. self.bindings_card.title_label.setText("Enabled transitions") @@ -294,6 +299,12 @@ def _build_analysis_tab(self) -> QWidget: # The hint wraps, so the row never makes the inspector wider than it is. layout.addLayout(hbox(label("Hover over a property (ⓘ) for its definition.", "muted", wrap=True), definitions, self.analysis_button)) + # Conformance is Mine's job: one click builds the analysis there. + self.check_log_button = button("Check against a log ›", self.check_requested.emit, + tooltip="In Mine: a new analysis with this net, a log you " + "choose and Check fit, connected and run") + layout.addLayout(hbox(label("How well does it fit a log? Fitness, precision and more, " + "with every step shown.", "muted", wrap=True), self.check_log_button)) self.soundness_card = Card("Soundness", f"From one token in {I}: (i) every run can " f"still reach [{O}], (ii) [{O}] is the only marking with a " f"token in {O}, (iii) every transition can fire.") @@ -311,17 +322,21 @@ def _build_analysis_tab(self) -> QWidget: "its initial marking.") self.footprint_card = Card("Footprint", "The ordering relations of the net's " "behaviour: which transition can directly follow which.") - # The card captions explain the idea; hovering shows the maths. + # The card captions explain the idea; hovering shows the maths; { } code opens the code. for card, key in ((self.soundness_card, "sound"), (self.theorem_card, "short_circuit"), (self.invariants_card, "incidence_matrix"), (self.footprint_card, "footprint")): attach_definition(card.caption_label, key) + for card, functions in ((self.soundness_card, (check_soundness, check_workflow_net)), + (self.theorem_card, (short_circuit, analyse)), + (self.structure_card, (check_workflow_net,)), + (self.invariants_card, (invariants,)), + (self.properties_card, (analyse,)), + (self.footprint_card, (footprint_of_net,))): + add_code(card, *functions) self.more_card = Card("More") - self.more_card.add(label("Look at every reachable marking, or replay an event log " - "on this net (token replay, alignments).", "muted", - wrap=True)) - self.more_card.add(flow(button("Reachability graph…", self.show_reachability_graph), - button("Conformance with a log…", self.open_as_model))) + self.more_card.add(label("Look at every reachable marking of the net.", "muted", wrap=True)) + self.more_card.add(flow(button("Reachability graph…", self.show_reachability_graph))) for card in (self.soundness_card, self.theorem_card, self.structure_card, self.invariants_card, self.properties_card, self.footprint_card, self.more_card): diff --git a/openprocess/gui/studio/provenance.py b/openprocess/gui/studio/provenance.py new file mode 100644 index 0000000..5fe89c3 --- /dev/null +++ b/openprocess/gui/studio/provenance.py @@ -0,0 +1,66 @@ +"""Where a figure comes from: a *code* link on every card that computes something. + +A box on the canvas shows its code on the Code tab. The pages compute a +few things of their own too (a process map, a footprint, soundness, the +state space, regions), and those must be just as open: each such card gets +a small ``{ } code`` link that opens the file the figure is computed in, +scrolled to the function, so nothing in the app is a black box. +""" + +from __future__ import annotations + +import inspect + +from PySide6.QtWidgets import QMenu, QPushButton + +from ...flow.box import Called +from .widgets import button + + +def _name(obj) -> str: + return getattr(obj, "__qualname__", getattr(obj, "__name__", str(obj))) + + +def called_from(obj) -> Called: + """The function (or class) as a :class:`Called`: its file, line and source.""" + target = inspect.unwrap(obj) + if isinstance(target, property): + target = target.fget + file = inspect.getsourcefile(target) or "" + try: + lines, line = inspect.getsourcelines(target) + except (OSError, TypeError): + lines, line = [], 0 + return Called(name=_name(target), module=getattr(target, "__module__", "") or "", + file=file, line=line, source="".join(lines)) + + +def code_button(*objects, text: str = "{ } code") -> QPushButton: + """A link that opens the code of ``objects`` (one: straight away; several: a menu).""" + names = ", ".join(_name(o) for o in objects) + link = button(text, kind="ghost", + tooltip=f"The code that computes this: {names}. Opens the file, scrolled to the function.") + link.setObjectName("ghost") + + def show(_checked=False) -> None: + from ..flow.viewers import show_file + if len(objects) == 1: + show_file(called_from(objects[0]), link.window()) + return + menu = QMenu(link) + for obj in objects: + menu.addAction(_name(obj), lambda _c=False, o=obj: show_file(called_from(o), link.window())) + menu.exec(link.mapToGlobal(link.rect().bottomLeft())) + + link.clicked.connect(show) + return link + + +def add_code(card, *objects) -> QPushButton: + """Put a ``{ } code`` link in a card's title row, for what the card computes.""" + link = code_button(*objects) + card.header.addWidget(link) + return link + + +__all__ = ["add_code", "code_button", "called_from"] diff --git a/openprocess/gui/studio/regions_view.py b/openprocess/gui/studio/regions_view.py index bf36402..171a55d 100644 --- a/openprocess/gui/studio/regions_view.py +++ b/openprocess/gui/studio/regions_view.py @@ -36,13 +36,14 @@ ) from ...mining.discovery.state_regions import RegionResult, region_result -from ...mining.regions import check_region, format_states +from ...mining.regions import analyse_regions, check_region, format_states from ...mining.transition_system import ( TransitionSystem, parse_states, parse_transition_system, ) from . import instances, style from ..learn.concealment import ConcealsResults from .definition_view import attach_definition +from .provenance import add_code from .graph_view import EdgeSpec, GraphView, NodeSpec from .widgets import Card, PageHeader, Verdict, button, hbox, label, scroll, status_for, \ suggested_path @@ -250,6 +251,7 @@ def __init__(self, result: RegionResult, show_net: bool = True, flat: bool = Fal regions = Card("Regions", f"{len(analysis.regions)} non-trivial region(s), " f"{len(analysis.minimal)} minimal. Each minimal region becomes a " "place of the net.") + add_code(regions, analyse_regions, check_region) attach_definition(regions.caption_label, "minimal_region", instances.regions(analysis, "minimal_region")) lines = [f"r{number} = {format_states(region, order)}" @@ -273,6 +275,7 @@ def __init__(self, result: RegionResult, show_net: bool = True, flat: bool = Fal net_card = Card("Synthesised net", "One place per minimal region, an arc from each " "pre-region and to each post-region, a token in every minimal region " "holding the initial state.") + add_code(net_card, region_result) attach_definition(net_card.caption_label, "region_synthesis", instances.regions(analysis, "region_synthesis", synthesis)) for warning in result.warnings: diff --git a/openprocess/gui/studio/sidebar.py b/openprocess/gui/studio/sidebar.py index 6a040bf..59cf537 100644 --- a/openprocess/gui/studio/sidebar.py +++ b/openprocess/gui/studio/sidebar.py @@ -4,7 +4,9 @@ * ``Qt.UserRole`` -- the id of an open document; * :data:`FILE_ROLE` -- the path of a file in the folder that is not open yet; -* :data:`FOLDER_ROLE` -- the path of a subfolder (in the Folder view). +* :data:`FOLDER_ROLE` -- the path of a subfolder; +* :data:`FOLDABLE_ROLE` -- a heading that folds open and shut (a click toggles it); +* :data:`GROUP_ROLE` -- a subfolder's caption: the rows under it are the files in it. The tree also handles dragging: rows can be dragged onto a subfolder (to move the files on disk) or out of the window (to Finder, say), and files dragged @@ -26,6 +28,10 @@ FILE_ROLE = Qt.UserRole + 1 #: Sidebar item data: the path of a subfolder. FOLDER_ROLE = Qt.UserRole + 2 +#: Sidebar item data: a heading that folds (the LOGS section in Mine): it gets a chevron. +FOLDABLE_ROLE = Qt.UserRole + 5 +#: Sidebar item data: a subfolder's caption, which groups the files in it (always open, no chevron). +GROUP_ROLE = Qt.UserRole + 6 class SidebarTree(QTreeWidget): @@ -54,8 +60,9 @@ def drawBranches(self, painter: QPainter, rect, index) -> None: # noqa: N802 the selected row's highlight in the indentation, so that is covered up.""" painter.fillRect(rect, QColor(style.tokens().sidebar)) item = self.itemFromIndex(index) - if item is None or item.childCount() == 0 or not item.data(0, FOLDER_ROLE): - return # only subfolders get one, not section headings + if item is None or item.childCount() == 0 or item.data(0, GROUP_ROLE) \ + or not (item.data(0, FOLDER_ROLE) or item.data(0, FOLDABLE_ROLE)): + return # only a foldable heading gets one; captions never fold size = 3.5 centre = QPointF(rect.right() - self.indentation() / 2 + 2, rect.center().y() + 1) path = QPainterPath() diff --git a/openprocess/gui/studio/spaces.py b/openprocess/gui/studio/spaces.py new file mode 100644 index 0000000..f8a8ce8 --- /dev/null +++ b/openprocess/gui/studio/spaces.py @@ -0,0 +1,60 @@ +"""The three spaces of the window: Mine, Model and Learn. + +One window, one folder, three spaces switched at the top of the window. +Each space has its own sidebar and footer, so process mining and net +modelling never share a list: + +* **Mine** -- event logs and analyses (workflows): discover, check, compare. + Its sidebar lists the folder's analyses, and the logs underneath them. +* **Model** -- Petri nets and coloured nets: draw, play, simulate, analyse. + Its sidebar lists the folder's models. +* **Learn** -- an exercise pack, which takes the whole window + (:mod:`openprocess.gui.learn.mode`). + +A document belongs to exactly one space (:func:`space_of`), and opening or +selecting it switches the window there. The two spaces hand things to each +other through two kinds of object only, an event log and a net; each handoff +is one button that moves the whole window. +""" + +from __future__ import annotations + +from .documents import (ComparisonDocument, CpnDocument, LogDocument, ModelDocument, + TransitionSystemDocument, WorkflowDocument) + +MINE, MODEL, LEARN = "mine", "model", "learn" +SPACES = (MINE, MODEL, LEARN) +LABELS = {MINE: "Mine", MODEL: "Model", LEARN: "Learn"} + +#: The folder's file kinds (see :func:`~.workspace.file_kind`) each space lists. +FILE_KINDS = {MINE: ("workflow", "log", "ts"), MODEL: ("petri", "cpn")} +#: The file kinds a space's main section is made of; the rest are its material. +MAIN_KINDS = {MINE: ("workflow",), MODEL: ("petri", "cpn")} + + +def space_of(document) -> str: + """The space a document lives in: nets in Model, everything else in Mine.""" + if isinstance(document, (ModelDocument, CpnDocument)): + return MODEL + return MINE + + +def space_of_kind(kind: str | None) -> str: + """The space a file of ``kind`` opens in.""" + return MODEL if kind in ("petri", "cpn") else MINE + + +def is_main(document, space: str) -> bool: + """Whether a document sits in the space's main section (analyses or models) + rather than among its material (logs, transition systems, comparisons).""" + if space == MODEL: + return isinstance(document, (ModelDocument, CpnDocument)) + return isinstance(document, WorkflowDocument) + + +def material_kinds(document) -> bool: + return isinstance(document, (LogDocument, TransitionSystemDocument, ComparisonDocument)) + + +__all__ = ["MINE", "MODEL", "LEARN", "SPACES", "LABELS", "FILE_KINDS", "MAIN_KINDS", + "space_of", "space_of_kind", "is_main", "material_kinds"] diff --git a/openprocess/gui/studio/style.py b/openprocess/gui/studio/style.py index 12131c0..b21bc2f 100644 --- a/openprocess/gui/studio/style.py +++ b/openprocess/gui/studio/style.py @@ -348,6 +348,23 @@ def stylesheet() -> str: #revealRow QLabel {{ background: transparent; }} #revealTitle {{ font-weight: 600; }} + /* The Summary's tiles and the Model palette. */ + #summaryValue {{ font-size: 22px; font-weight: 600; }} + QPushButton#paletteTool {{ background: {t.surface}; border: 1px solid {t.border}; border-radius: 7px; + padding: 5px 8px; text-align: left; color: {t.text_secondary}; }} + QPushButton#paletteTool:hover {{ color: {t.text}; }} + QPushButton#paletteTool:checked {{ background: {t.accent_soft}; color: {t.text}; border-color: {t.accent}; }} + + /* A page's way back to the analysis it was opened from. */ + QPushButton#backLink {{ background: transparent; border: none; padding: 0 0 2px 0; + color: {t.accent}; font-size: 12px; font-weight: 500; text-align: left; }} + QPushButton#backLink:hover {{ text-decoration: underline; }} + + /* ---- the space bar: Mine / Model / Learn at the top of the window ---- */ + QToolBar#spaceBar {{ background: {t.sidebar}; border: none; border-bottom: 1px solid {t.border}; + padding: 4px 10px; spacing: 8px; }} + QToolBar#spaceBar::separator {{ width: 0; }} + /* ---- segmented control ---------------------------------------------- */ #segmented {{ background: {qc(t.text, 0.07).name(QColor.HexArgb)}; border-radius: 8px; }} #segmented QPushButton {{ background: transparent; border: none; border-radius: 6px; diff --git a/openprocess/gui/studio/widgets.py b/openprocess/gui/studio/widgets.py index 6774090..81a510b 100644 --- a/openprocess/gui/studio/widgets.py +++ b/openprocess/gui/studio/widgets.py @@ -7,10 +7,12 @@ from __future__ import annotations +import sys + from PySide6.QtCore import QEvent, QObject, QPoint, QPointF, QRect, QRectF, QSize, Qt, Signal from PySide6.QtGui import QColor, QFontMetricsF, QPainter, QPainterPath, QPen from PySide6.QtWidgets import ( - QButtonGroup, QFrame, QMenu, QHBoxLayout, QLabel, QLayout, QPushButton, QSizePolicy, QStyle, QStyledItemDelegate, + QButtonGroup, QComboBox, QFrame, QMenu, QHBoxLayout, QLabel, QLayout, QPushButton, QSizePolicy, QStyle, QStyledItemDelegate, QVBoxLayout, QWidget, ) @@ -528,11 +530,25 @@ def __init__(self, title: str = "", subtitle: str = "", parent=None) -> None: self.subtitle = ElidedLabel(subtitle, "pageSubtitle") text.addWidget(self.title) text.addWidget(self.subtitle) + self._text = text + self.back: QPushButton | None = None layout.addLayout(text, 1) self.actions = QHBoxLayout() self.actions.setSpacing(8) layout.addLayout(self.actions) + def set_back(self, text: str, slot) -> None: + """A link above the title, back to where this page was opened from + ("‹ Back to Discover and check").""" + if self.back is None: + self.back = button(text, slot, kind="backLink") + self.back.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) + self._text.insertWidget(0, self.back, 0, Qt.AlignLeft) + else: + self.back.setText(text) + self.back.clicked.disconnect() + self.back.clicked.connect(slot) + def set_text(self, title: str, subtitle: str) -> None: self.title.setText(title) self.subtitle.setText(subtitle) @@ -554,21 +570,40 @@ class _RoundedMenus(QObject): POPUPS = ("QComboBoxPrivateContainer", "QTipLabel") def eventFilter(self, watched, event) -> bool: # noqa: N802 + if event.type() == QEvent.MouseButtonPress and isinstance(watched, QComboBox): + # Size the list before it opens. Resizing the popup once it is + # showing (what _fit_list did) makes macOS flicker and close it, + # so a list with long entries could not be used at all. + self._prepare_list(watched) if event.type() == QEvent.Show and isinstance(watched, QWidget) \ - and watched.metaObject().className() == "QComboBoxPrivateContainer": + and watched.metaObject().className() == "QComboBoxPrivateContainer" \ + and sys.platform != "darwin": self._fit_list(watched) if event.type() == QEvent.Polish and isinstance(watched, QWidget) \ and (isinstance(watched, QMenu) or watched.metaObject().className() in self.POPUPS) \ and not watched.testAttribute(Qt.WA_WState_Created): watched.setAttribute(Qt.WA_TranslucentBackground) - import sys if sys.platform != "darwin": # Windows and Linux draw a square shadow; macOS follows the shape. watched.setWindowFlag(Qt.NoDropShadowWindowHint) return False + @staticmethod + def _prepare_list(combo: "QComboBox") -> None: + """Before the list opens: at least as wide as its longest entry (within + reason), so the popup comes up at its final size.""" + view = combo.view() + if view is None or view.model() is None or combo.count() == 0: + return + needed = view.sizeHintForColumn(0) + 2 * view.frameWidth() + 24 + screen = combo.screen() + if screen is not None: + needed = min(needed, int(screen.availableGeometry().width() * 0.6)) + if needed > combo.width(): + view.setMinimumWidth(needed) + @staticmethod def _fit_list(container: QWidget) -> None: """A combo box's list is as wide as the box: widen it to its longest diff --git a/tests/test_exercises_gui.py b/tests/test_exercises_gui.py index 4b480e0..d8bf6e7 100644 --- a/tests/test_exercises_gui.py +++ b/tests/test_exercises_gui.py @@ -87,19 +87,20 @@ def test_a_pack_opens_on_its_overview_and_steps_through(app, demo): window.close() -def test_clicking_an_exercise_in_the_sidebar_opens_it(app, demo): +def test_learn_in_the_switcher_opens_the_folders_exercises(app, demo): + """Exercises are Learn's, not rows in Mine: the switcher opens the open + folder's pack on its overview, and Mine takes you back.""" from openprocess.gui.studio.app import EXERCISE_ROLE window = _window() window.open_workspace(str(demo)) - window.set_view_mode("folder") - rows = [i for i in window._all_rows() if i.data(0, EXERCISE_ROLE)] - assert len(rows) == 7 - window._open_placeholder(rows[2]) - assert window.in_learn - assert window.learn_mode.view.exercise.folder.name == "Exercise 3.1 The alpha-algorithm" - # The pack is the open folder: all four, with the overview one step away. + assert not [i for i in window._all_rows() if i.data(0, EXERCISE_ROLE)] + assert window.set_space("learn") and window.in_learn + assert window.space_switch.index() == 2 assert len(window.learn_mode.pack.exercises) == 7 + assert window.learn_mode.view is None # the overview, not an exercise + window.set_space("mine") + assert not window.in_learn and window.space == "mine" and window.space_switch.index() == 0 window.close() @@ -373,11 +374,13 @@ def test_discover_with_state_based_regions(app): from openprocess.gui.studio.regions_view import RegionsPanel from openprocess.mining import EventLog, parse_simple_log - window = _window() + from openprocess.gui.studio.log_page import LogPage + log = EventLog.from_simple_log(parse_simple_log("[^3, ^2, ]"), "L") - window.add_document(LogDocument(log)) - page = window.current_page() + page = LogPage(LogDocument(log)) # Learn's log page: the miners run inline + page.resize(1200, 800) + page.show() page.tabs.set_index(6) # Discover from PySide6.QtWidgets import QComboBox chooser = next(box for box in page.findChildren(QComboBox) @@ -391,7 +394,7 @@ def test_discover_with_state_based_regions(app): representation.setCurrentIndex(representation.findData("sequence")) assert wait_for(app, lambda: any(p.result.ts.states[0] == "⟨⟩" for p in page.findChildren(RegionsPanel))) - window.close() + page.close() def test_compare_nets_outside_an_exercise(app): diff --git a/tests/test_flow.py b/tests/test_flow.py index a9d068f..e9939c6 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -543,3 +543,17 @@ def test_a_typed_path_setting_is_cleaned_up(tmp_path): assert setting.coerce(spaced) == spaced assert clean_path("~/logs/a.xes") == str(Path.home() / "logs" / "a.xes") assert "Choose…" in setting.help and "folder" in setting.help + + +def test_a_tidied_layout_is_kept_with_the_box(tmp_path): + """Node.layout (where the user dragged a result's places and transitions) + survives a save and a load; a box without one has none.""" + wf = Workflow("Tidy") + log = wf.add(typed_log) + miner = wf.add(alpha_miner) + wf.connect(log, miner) + miner.layout = {"p_start": (0.0, 10.0), "t_a": (120.0, 10.5)} + again = Workflow.from_dict(wf.to_dict(), wf.library) + assert again.nodes[miner.id].layout == {"p_start": (0.0, 10.0), "t_a": (120.0, 10.5)} + assert again.nodes[log.id].layout == {} + assert "layout" not in next(b for b in wf.to_dict()["boxes"] if b["id"] == log.id) diff --git a/tests/test_flow_gui.py b/tests/test_flow_gui.py index 2cd5418..59d926a 100644 --- a/tests/test_flow_gui.py +++ b/tests/test_flow_gui.py @@ -29,6 +29,10 @@ def _pump(app, seconds: float) -> None: while time.time() < end: app.processEvents() time.sleep(0.01) + # Under load one processEvents can outlast the budget; drain what came due + # meanwhile (a page's deferred fit, say), so the test sees a settled window. + for _ in range(3): + app.processEvents() def _wait_run(app, page, seconds: float = 6.0) -> None: @@ -318,7 +322,7 @@ def test_the_page_opens_calm_and_the_panel_comes_with_a_click(app): page = window.current_page() _wait_run(app, page) header = [b.text() for b in page.header.findChildren(QPushButton) if b.isVisible()] - assert header == ["+ Add box", "Run ▶", "⋯"] + assert header == ["Canvas", "Summary", "+ Add box", "Run ▶", "⋯"] assert [a.text() for a in page.more_menu.actions() if a.text()] == ["Re-run", "Record…", "Export experiment…", "Save"] assert not page.panel.isVisible() and not hasattr(page, "box_tree") log, miner = page.workflow.order()[:2] @@ -398,10 +402,10 @@ def test_the_pages_no_longer_offer_as_a_workflow(app): path = Path(__file__).parent / "data" / "plane_wilma_10.xes" window.add_document(LogDocument(read_xes(path), path=str(path))) log_page = window.current_page() - log_page.tabs.set_index(6) # Discover + log_page.tabs.set_index(6) # Discover: the handoff to Mine _pump(app, 0.8) texts = [b.text() for b in log_page.findChildren(QPushButton)] - assert any(t.startswith("Open as model") for t in texts) + assert any(t.startswith("Discover in Mine") for t in texts) assert not any("workflow" in t.lower() for t in texts) window.action_new_cpn() net_page = window.current_page() @@ -541,7 +545,8 @@ def fake_dialog(parent, title, start, filters): monkeypatch.setattr(QFileDialog, "getOpenFileName", staticmethod(fake_dialog)) asked["answer"] = str(inside) choose.click() - assert asked["title"] == "Choose file" and Path(asked["start"]) == week and "*.xes" in asked["filters"] + assert asked["title"] == "Choose file" and Path(asked["start"]) == week + assert asked["filters"] == "Event logs (*.xes *.xes.gz *.gz *.csv *.txt);;All files (*)" assert field.currentText() == "orders.log.txt" # relative: the folder may move _pump(app, 0.6) # settings apply after a short pause _wait_run(app, page) @@ -568,3 +573,362 @@ def fake_dialog(parent, title, start, filters): assert field.currentText() == str(inside) page.document.dirty = False page.close() + + +def test_picking_a_file_from_the_list_runs_the_box(app, tmp_path): + """Opening the file field's list must not rebuild the panel under it + (focus leaving the field is not a change), and picking an entry runs + the box with that file.""" + from PySide6.QtWidgets import QComboBox + + from openprocess.flow.library import library_for + from openprocess.flow.workflow import Workflow + from openprocess.gui.flow.page import WorkflowPage + from openprocess.gui.studio.documents import WorkflowDocument + + week = tmp_path / "Week 2" + (week / "logs").mkdir(parents=True) + (week / "logs" / "orders.log.txt").write_text("[^3, ^2, ]", encoding="utf-8") + (week / "net.pnml").write_text("", encoding="utf-8") # not a log: not listed + (week / "notes.txt").write_text("not a log either", encoding="utf-8") + library = library_for(week) + wf = Workflow("Pick from the list", library) + node = wf.add(next(s for s in library.specs.values() if s.id.endswith("open_log"))) + page = WorkflowPage(WorkflowDocument(wf), library, week) + page.resize(1300, 760) + page.show() + page.run_from(None) + _wait_run(app, page) + page.select(node.id, 3) + _pump(app, 0.1) + field = next(c for c in page.panel_host.findChildren(QComboBox) if c.isEditable()) + assert [field.itemText(i) for i in range(field.count())] == ["orders.log.txt"] # logs only, short names + assert field.itemData(0) == "logs/orders.log.txt" # the path behind + runs_before = page.run + field.lineEdit().setFocus() + field.showPopup() # the focus leaves the field + field.lineEdit().editingFinished.emit() + _pump(app, 0.8) # longer than the settings delay + assert page.run is runs_before # nothing ran: the value did not change + assert field.view().isVisible() # and the list is still open + field.hidePopup() + field.setCurrentIndex(0) + field.activated.emit(0) + _pump(app, 0.6) + _wait_run(app, page) + assert page.run.result(node).status == "done", page.run.result(node).error + assert page.scene.boxes[node.id].subtitle == "6 cases · 23 events" + assert page.workflow.nodes[node.id].settings["file"] == Path("logs/orders.log.txt") + page.document.dirty = False + page.close() + + +def test_files_flow_into_the_canvas(app, tmp_path, monkeypatch): + """The folder's files are one click away in the picker's Input group, a + file dropped on the canvas becomes its input box, the box names its file, + and Mine's footer puts a log or a typed log on the current analysis.""" + from PySide6.QtCore import QPointF + + from openprocess.gui.studio import app as studio_app + from openprocess.gui.studio.app import StudioWindow + from openprocess.gui.studio.documents import WorkflowDocument + + week = tmp_path / "Week 2" + (week / "logs").mkdir(parents=True) + (week / "logs" / "orders.log.txt").write_text("[^3, ^2, ]", encoding="utf-8") + (week / "net.pnml").write_text( + (Path(__file__).resolve().parents[1] / "examples" / "petri" / "order_handling_sound.pnml").read_text(), + encoding="utf-8") + elsewhere = tmp_path / "elsewhere.log.txt" + elsewhere.write_text("[]", encoding="utf-8") + (week / "notes.txt").write_text("not a log", encoding="utf-8") + + window = StudioWindow() + window.resize(1400, 900) + window.show() + assert window.open_workspace(str(week)) + window.action_new_workflow(None) + page = window.current_page() + _wait_run(app, page) + + # The picker's Input group lists the folder's files; one click adds the box that reads it. + page.add_box_menu() + picker = page._picker + assert [b.text() for b in picker.file_buttons] == ["orders", "net"] # by path: logs/… first + picker.search.setText("orders") + assert [b.text() for b in picker.file_buttons] == ["orders"] + picker.file_buttons[0].click() + _wait_run(app, page) + node = _node(page, "open_log") + assert node.settings["file"] == Path("logs/orders.log.txt") + assert page.scene.boxes[node.id].eyebrow() == "INPUT · orders.log.txt" + assert page.run.result(node).status == "done" + assert page.selected == node.id and page.tab == 0 # its Result, not Settings + + # A drop on the canvas: the matching box for each file, at that spot; a file from + # outside the folder keeps its full path; a stray file is refused with a message. + statuses = [] + page.status.connect(statuses.append) + nodes = page.add_files([str(week / "net.pnml"), str(elsewhere), str(week / "notes.txt")], QPointF(300, 200)) + _wait_run(app, page) + assert [page.workflow.spec(n).name for n in nodes] == ["Open net", "Open log"] + assert nodes[0].settings["file"] == Path("net.pnml") and nodes[1].settings["file"] == elsewhere + assert page.scene.boxes[nodes[0].id].eyebrow() == "INPUT · net.pnml" + assert all(page.run.result(n).status == "done" for n in nodes), [page.run.result(n).error for n in nodes] + assert any("notes.txt" in s for s in statuses) + + # Mine's footer: Open log… lands on the current analysis as a box … + monkeypatch.setattr(studio_app.QFileDialog, "getOpenFileNames", + staticmethod(lambda *a, **k: ([str(week / "logs" / "orders.log.txt")], ""))) + before = len(page.workflow.nodes) + window.action_open_log_box() + assert len(page.workflow.nodes) == before + 1 and window.current_page() is page + # … and with no analysis open, on a new one named after the file. + window.content.setCurrentIndex(0) + window.tree.setCurrentItem(None) + window.action_open_log_box() + new_page = window.current_page() + assert new_page is not page and new_page.workflow.name == "orders" + assert [new_page.workflow.spec(n).name for n in new_page.workflow.order()] == ["Open log"] + # Log from notation…: a Typed log box with the text. + monkeypatch.setattr(studio_app.NotationDialog, "exec", lambda self: studio_app.QDialog.Accepted) + monkeypatch.setattr(studio_app.NotationDialog, "text", lambda self: "[^2]") + window.action_notation_box() + typed = _node(new_page, "typed_log") + assert typed.settings["text"] == "[^2]" + _wait_run(app, new_page) + assert page.scene.boxes[node.id].eyebrow() == "INPUT · orders.log.txt" + for document in list(window.documents): + document.dirty = False + window.close() + + +def test_a_page_opened_from_a_box_has_a_way_back(app): + """Open as log from a box's result: the log page shows "‹ Back to …", which + returns to the analysis (switching the space if need be).""" + from openprocess.gui.flow.templates import TEMPLATES + from openprocess.gui.studio.app import StudioWindow + from openprocess.gui.studio.documents import LogDocument + + window = StudioWindow() + window.resize(1400, 900) + window.show() + window.action_new_workflow(TEMPLATES[0][1]) + page = window.current_page() + _wait_run(app, page) + log_node = page.workflow.order()[0] + page.open_as_log(page.run.result(log_node).value) + _pump(app, 0.2) + log_page = window.current_page() + document = window.documents[-1] + assert isinstance(document, LogDocument) and document.opened_from is page.document + assert log_page.header.back is not None and log_page.header.back.text() == f"‹ Back to {page.workflow.name}" + log_page.header.back.click() + assert window.current_page() is page and window.space == "mine" + # A discovered net opens in Model; its Back link crosses the spaces. + miner = page.workflow.order()[1] + page.open_as_model(page.run.result(miner).value) + _pump(app, 0.2) + assert window.space == "model" and window.current_page().header.back is not None + window.current_page().header.back.click() + assert window.space == "mine" and window.current_page() is page + for document in list(window.documents): + document.dirty = False + window.close() + + +def test_a_result_net_can_be_tidied_but_not_changed(app): + """Dragging a place in a box's Result moves it (edges follow), the layout + is kept with the box and shown again; the net is untouched.""" + from PySide6.QtWidgets import QGraphicsItem + + from openprocess.gui.flow.templates import TEMPLATES + from openprocess.gui.studio.app import StudioWindow + from openprocess.gui.studio.graph_view import GraphView + + window = StudioWindow() + window.resize(1400, 900) + window.show() + window.action_new_workflow(TEMPLATES[0][1]) + page = window.current_page() + _wait_run(app, page) + miner = page.workflow.order()[1] + net = page.run.result(miner).value + page.select(miner.id, 0) + _pump(app, 0.2) + view = next(v for v in page.panel_host.findChildren(GraphView)) + graph = view.graph + place_id, item = next(iter(graph.nodes.items())) + assert item.flags() & QGraphicsItem.ItemIsMovable + edge = next(e for e in graph.edges if e.source_item is item or e.target_item is item) + before_edge = edge.path().pointAtPercent(0.5) + page.document.dirty = False + item.setPos(item.pos().x() + 90, item.pos().y() - 40) + _pump(app, 0.1) + assert edge.path().pointAtPercent(0.5) != before_edge # the edge followed + assert miner.layout[place_id] == (item.pos().x(), item.pos().y()) # kept with the box + assert set(miner.layout) == set(graph.nodes) + assert page.document.dirty # an edit, to save + assert len(net.places) == len(graph.nodes) - len(net.transitions) # the net is the same + # Shown again, the drawing is as tidied. + page.select(miner.id, 1) + page.select(miner.id, 0) + _pump(app, 0.2) + again = next(v for v in page.panel_host.findChildren(GraphView)).graph + assert again.nodes[place_id].pos() == item.pos() + page.document.dirty = False + window.close() + + +def test_the_handoffs_between_mine_and_model(app, tmp_path, monkeypatch): + """Open a copy in Model: a "(copy)" in Model that says where it is from; + Mine a simulated log: a file in the folder and an analysis in Mine reading + it; Check against a log: an analysis with the net, the log and Check fit.""" + from openprocess.gui.flow.templates import TEMPLATES + from openprocess.gui.studio import app as studio_app + from openprocess.gui.studio.app import StudioWindow + from openprocess.gui.studio.documents import CpnDocument, WorkflowDocument + + week = tmp_path / "Week 2" + week.mkdir() + (week / "orders.log.txt").write_text("[^3, ^2, ]", encoding="utf-8") + window = StudioWindow() + window.resize(1400, 900) + window.show() + assert window.open_workspace(str(week)) + window.action_new_workflow(TEMPLATES[0][1]) + page = window.current_page() + _wait_run(app, page) + miner = page.workflow.order()[1] + page.select(miner.id) + net = page.run.result(miner).value + places_before = len(net.places) + + # Open a copy in Model. + page.edit_copy(net) + _pump(app, 0.2) + copy = window.documents[-1] + assert isinstance(copy, CpnDocument) and copy.name.endswith("(copy)") and window.space == "model" + assert copy.origin == f"from {page.workflow.title(miner.id)} in {page.workflow.name}" + back = window.current_page().header.back + assert back.text() == f"‹ A copy, {copy.origin}" + assert Path(copy.path).name == f"{copy.name}.pnml" # saved in the folder + assert len(net.places) == places_before # the original is untouched + model_page = window.current_page() + + # Check against a log: an analysis in Mine with the three boxes, connected. + monkeypatch.setattr(studio_app.QFileDialog, "getOpenFileName", + staticmethod(lambda *a, **k: (str(week / "orders.log.txt"), ""))) + model_page.check_log_button.click() + _pump(app, 0.2) + check = window.current_page() + assert window.space == "mine" and isinstance(check.document, WorkflowDocument) + assert check.workflow.name == f"{copy.name} vs orders" + names = [check.workflow.spec(n).name for n in check.workflow.order()] + assert names == ["Open net", "Open log", "Check fit"] or names == ["Open log", "Open net", "Check fit"] + assert len(check.workflow.edges) == 2 + _wait_run(app, check) + fit = next(n for n in check.workflow.nodes.values() if check.workflow.spec(n).name == "Check fit") + assert check.run.result(fit).status == "done", check.run.result(fit).error + + # Mine a simulated log: the play-out becomes a file in the folder and an analysis reading it. + monkeypatch.setattr(studio_app.QInputDialog, "getInt", staticmethod(lambda *a, **k: (20, True))) + model_page.export_log_button.click() + assert _wait_for_documents(app, window, lambda docs: any( + isinstance(d, WorkflowDocument) and d.workflow.name.startswith("Play-out") for d in docs)) + mined = window.current_page() + assert window.space == "mine" and mined.workflow.name.startswith("Play-out") + log_box = mined.workflow.order()[0] + assert mined.workflow.spec(log_box).name == "Open log" + assert (week / Path(str(log_box.settings["file"]))).exists() + _wait_run(app, mined) + assert mined.run.result(log_box).status == "done" + back.click() # the copy's way back + assert window.current_page() is page and window.space == "mine" + for document in list(window.documents): + document.dirty = False + window.close() + + +def _wait_for_documents(app, window, condition, seconds: float = 8.0) -> bool: + end = time.time() + seconds + while time.time() < end: + app.processEvents() + if condition(window.documents): + return True + time.sleep(0.02) + return condition(window.documents) + + +def test_the_summary_writes_itself(app): + """Canvas | Summary: a tile per result in run order, the first log's process + map with a detail slider, and a tile click opens the box on the canvas.""" + from openprocess.gui.flow.templates import TEMPLATES + from openprocess.gui.studio.app import StudioWindow + + window = StudioWindow() + window.resize(1400, 900) + window.show() + window.action_new_workflow(TEMPLATES[0][1]) + page = window.current_page() + _wait_run(app, page) + page.summary_switch.set_index(1) + _pump(app, 0.3) + assert page.body.currentIndex() == 1 + titles = [page.workflow.title(t.node_id) for t in page.summary.tiles] + assert titles == [page.workflow.title(n.id) for n in page.workflow.order()] # one per box, in run order + assert len(titles) == 3 + # No Directly-follows graph box yet: no map is computed behind the scenes; one click adds the box. + assert not page.summary.map_card.isVisibleTo(page) and page.summary.no_map_card.isVisibleTo(page) + page.summary.add_map_button.click() + _pump(app, 0.3) + _wait_run(app, page) + names = [page.workflow.spec(n).name for n in page.workflow.order()] + assert "Directly-follows graph" in names and len(page.workflow.edges) == 4 # fed by the log + page.summary.refresh() + _pump(app, 0.2) + assert page.summary.map_card.isVisibleTo(page) and page.summary.map_view.graph.nodes + assert "Directly-follows graph" in page.summary.map_card.title_label.text() + full = len(page.summary.map_view.graph.nodes) + page.summary.detail.setValue(10) + assert len(page.summary.map_view.graph.nodes) < full + assert len(page.summary.tiles) == 4 # the new box has a tile too + last = page.summary.tiles[2] + last.clicked.emit(last.node_id) + _pump(app, 0.1) + assert page.body.currentIndex() == 0 and page.summary_switch.index() == 0 + assert page.selected == last.node_id and page.tab == 0 + page.document.dirty = False + window.close() + + +def test_motion_settles_the_window_and_can_be_turned_off(app, monkeypatch): + """A space switch and Canvas ↔ Summary lift a veil that is gone a moment + later; a box pulses once when it finishes; OPENPROCESS_NO_MOTION turns it off.""" + from openprocess.gui.flow.templates import TEMPLATES + from openprocess.gui.studio import motion + from openprocess.gui.studio.app import StudioWindow + from openprocess.gui.studio.motion import Veil + + window = StudioWindow() + window.resize(1400, 900) + window.show() + window.action_new_workflow(TEMPLATES[0][1]) + page = window.current_page() + _wait_run(app, page) + box = page.scene.boxes[page.workflow.order()[0].id] + assert box.pulse_animation is not None # it finished: one pulse + window.set_space("model") + assert window.modes.findChildren(Veil) + _pump(app, 0.5) + assert not window.modes.findChildren(Veil) # lifted and gone + window.set_space("mine") + _pump(app, 0.5) + page.summary_switch.set_index(1) + assert page.body.findChildren(Veil) + _pump(app, 0.5) + assert not page.body.findChildren(Veil) + monkeypatch.setattr(motion, "enabled", False) + assert motion.lift(window.modes) is None and motion.fade_in(window) is None + page.document.dirty = False + window.close() diff --git a/tests/test_studio_gui.py b/tests/test_studio_gui.py index 42fdf36..0c4fc26 100644 --- a/tests/test_studio_gui.py +++ b/tests/test_studio_gui.py @@ -50,6 +50,10 @@ def _pump(app, seconds: float) -> None: while time.time() < end: app.processEvents() time.sleep(0.01) + # Under load one processEvents can outlast the budget; drain what came due + # meanwhile (a page's deferred fit, say), so the test sees a settled window. + for _ in range(3): + app.processEvents() def test_studio_end_to_end(app): @@ -73,10 +77,14 @@ def test_studio_end_to_end(app): window.add_document(ModelDocument(result.net, origin="test", derivation=result, source_log=log)) model_page = window.current_page() - model_page._check() + # In the studio, conformance is a handoff to an analysis; Learn's model page replays inline. + assert model_page.conformance_mode == "handoff" and model_page.check_log_button is not None + from openprocess.gui.studio.model_page import ModelPage + inline = ModelPage(window.documents[-1], window.logs) + inline._check() _pump(app, 2.0) - assert model_page.conformance is not None - replay, alignments = model_page.conformance + assert inline.conformance is not None + replay, alignments = inline.conformance assert alignments.average_fitness == pytest.approx(1.0) # Step through: a real mouse click on an enabled transition fires it. @@ -108,7 +116,7 @@ def test_studio_end_to_end(app): # dotted chart: zoom, back, reconfigure log_page = window.pages[log.id] - window.tree.setCurrentItem(window.items[log.id]) + window._select_document(log) # back to Mine log_page.tabs.set_index(3) _pump(app, 0.3) from openprocess.gui.studio.dotted_chart import DottedChartPanel @@ -250,7 +258,7 @@ def test_cpn_page_edit_simulate_analyse(app, tmp_path): assert isinstance(window.documents[-1], LogDocument) # Editing a guard recompiles, marks the model dirty and resets the simulation. - window.tree.setCurrentItem(window.items[page.document.id]) + window._select_document(page.document) # the log opened in Mine; back to Model page.mode_switch.set_index(0) move_up = next(t for t in page.net.all_transitions() if t.name == "Move up") page.reveal_element(move_up.id) @@ -1265,39 +1273,45 @@ def wait_for(condition, seconds=6.0): window.open_path(str(root / "examples" / "petri" / "order_handling_unsound.pnml")) assert window.open_workspace(str(week)) - # The outside file was closed; the folder's files are listed (here grouped by kind). - window.set_view_mode("kind") + # The outside file was closed. Mine lists the folder's logs (folded under + # their subfolder); Model lists its nets. assert [d.name for d in window.documents] == [] assert window.sidebar_title.text() == "Week 2" assert not window.workspace_caption.isHidden() assert window.windowTitle() == f"Week 2 — {APPLICATION_NAME}" - assert rows(window.petri_section) == ["order"] - assert rows(window.cpn_section) == ["transfer"] - assert rows(window.logs_section) == ["boarding"] # the subfolder is in the tooltip - assert window.logs_section.child(0).toolTip(0).startswith("logs/boarding.xes") + assert window.space == "mine" and window.analyses_section.isHidden() + assert window.material_section.text(0) == "LOGS · 1" + assert rows(window.material_section) == ["logs"] + assert rows(window.folder_items["logs"]) == ["boarding"] + assert window.folder_items["logs"].child(0).toolTip(0).startswith("logs/boarding.xes") + assert window.set_space("model") and window.space_switch.index() == 1 + assert rows(window.models_section) == ["order", "transfer"] # One click opens a file; its row becomes the open document. - window._open_placeholder(window.petri_section.child(0)) + window._open_placeholder(window.models_section.child(0)) net = window.documents[-1] - assert net.name == "order" and window.petri_section.childCount() == 1 - assert window.petri_section.child(0).data(0, FILE_ROLE) is None + assert net.name == "order" and window.models_section.childCount() == 2 + assert window.models_section.child(0).data(0, FILE_ROLE) is None assert window.windowTitle() == "order — Week 2" # A double-click right after that click does not ask to rename it. window._on_double_click(window.items[net.id], 0) - # A log opens in the background. - window._open_placeholder(window.logs_section.child(0)) + # A log opens in the background, in Mine. + window.set_space("mine") + window._open_placeholder(window.folder_items["logs"].child(0)) assert wait_for(lambda: any(isinstance(d, LogDocument) for d in window.documents)) - assert rows(window.logs_section) == ["Boarding"] + assert window.space == "mine" and rows(window.folder_items["logs"]) == ["Boarding"] # Closing a file lists it again, ready to reopen. + window.set_space("model") + assert window.windowTitle() == "order — Week 2" # Model remembers its document window.remove_documents([net.id]) - assert rows(window.petri_section) == ["order"] - assert window.petri_section.child(0).data(0, FILE_ROLE) + assert rows(window.models_section) == ["order", "transfer"] + assert window.models_section.child(0).data(0, FILE_ROLE) # Files added in Finder appear by themselves (the folder is watched). shutil.copy(week / "order.pnml", week / "another net.pnml") - assert wait_for(lambda: rows(window.petri_section) == ["another net", "order"]) + assert wait_for(lambda: rows(window.models_section) == ["another net", "order", "transfer"]) # A new net's Save dialog starts in the workspace folder. window.action_new_petri() @@ -1310,10 +1324,10 @@ def save_dialog(_parent, _title, suggested, _filters): assert window.current_page().export() assert Path(offered["path"]).parent == week window._rescan_workspace() - assert rows(window.petri_section) == ["another net", "my model", "order"] - assert sum(1 for i in range(3) if window.petri_section.child(i).data(0, FILE_ROLE)) == 2 + assert rows(window.models_section) == ["another net", "my model", "order", "transfer"] + assert sum(1 for i in range(4) if window.models_section.child(i).data(0, FILE_ROLE)) == 3 - # What was open is remembered in the folder and comes back next time. + # What was open, and which space, is remembered in the folder and comes back next time. assert (week / ".openprocess").exists() window.close() again = StudioWindow() @@ -1321,12 +1335,16 @@ def save_dialog(_parent, _title, suggested, _filters): assert again.open_workspace(str(week)) assert wait_for(lambda: len(again.documents) == 2) assert sorted(d.name for d in again.documents) == ["Boarding", "my model"] + assert again.space == "model" assert again.windowTitle() == "my model — Week 2" # it was selected + again.set_space("mine") + assert again.windowTitle() == f"Week 2 — {APPLICATION_NAME}" # nothing was current in Mine # Closing the workspace closes everything and goes back to "OpenProcess Studio". assert again.close_workspace() assert again.documents == [] and again.sidebar_title.text() == APPLICATION_NAME - assert all(s.isHidden() for s in (again.logs_section, again.petri_section, again.cpn_section)) + assert all(s.isHidden() for s in (again.analyses_section, again.models_section, + again.material_section)) again.close() @@ -1557,43 +1575,54 @@ def _layout(window) -> list[str]: return lines -def test_folder_view_shows_subfolders_and_remembers_them(app, tmp_path): - from openprocess.gui.studio.app import FOLDER_ROLE, StudioWindow +def test_each_space_lists_its_own_files_and_remembers_them(app, tmp_path): + """Mine lists logs under LOGS (folded), Model lists the nets; in both, the + list is flat, with each subfolder's files under a caption naming it, so + nothing is nested and nothing moves between the spaces. The space and + the unfolded LOGS are remembered in the folder.""" + from openprocess.gui.studio.app import FOLDER_ROLE, GROUP_ROLE, StudioWindow from openprocess.gui.studio.workspace import Workspace week = _week(tmp_path) window = StudioWindow() window.show() window.open_workspace(str(week)) - assert window.view_mode == "folder" and not window.view_row.isHidden() - # Folders first, then files, as in Finder; subfolders start collapsed. - assert _layout(window) == ["empty", "logs", "models", "transfer"] - window.folder_items["models"].setExpanded(True) - assert _layout(window) == ["empty", "logs", "models", " order", "transfer"] + assert window.space == "mine" and window.space_switch.index() == 0 + assert not window.footers["mine"].isHidden() and window.footers["model"].isHidden() + # No analyses yet, so ANALYSES holds only the empty subfolder's caption (a + # folder with nothing in it shows in both spaces, waiting); LOGS is folded. + assert _layout(window) == ["ANALYSES", " empty", "LOGS · 1"] + window.material_section.setExpanded(True) + assert _layout(window) == ["ANALYSES", " empty", "LOGS · 1", " logs", " boarding"] + caption = window.folder_items["logs"] + assert caption.data(0, GROUP_ROLE) and caption.data(0, FOLDER_ROLE) == str(week.resolve() / "logs") + caption.setExpanded(False) # a caption never folds + assert caption.isExpanded() + + # Model: the folder's own files first, then each subfolder's under its caption. + window.set_space("model") + assert not window.footers["model"].isHidden() and window.footers["mine"].isHidden() + assert _layout(window) == ["MODELS", " transfer", " empty", " models", " order"] assert window.folder_items["models"].data(0, FOLDER_ROLE) == str(week.resolve() / "models") - # Opening a file keeps it in its subfolder; a new net goes into the folder. + # Opening a file keeps it under its caption. window._open_placeholder(window.placeholders[window._key(week / "models" / "order.pnml")]) assert window.documents[-1].name == "order" - assert _layout(window) == ["empty", "logs", "models", " order", "transfer"] + assert _layout(window) == ["MODELS", " transfer", " empty", " models", " order"] assert window.tree.currentItem() is window.items[window.documents[-1].id] + for i in range(window.models_section.childCount()): # the rows are laid out + assert window.tree.visualItemRect(window.models_section.child(i)).height() > 0 - # The view and the expanded subfolders are remembered in the folder. - window.set_view_mode("kind") - assert "PETRI NETS" in _layout(window) and "models" not in _layout(window) - # Regression: the headings' rows were in the tree but not laid out (an - # empty By kind view), so check what the view actually shows. - for section in (window.logs_section, window.petri_section, window.cpn_section): - for i in range(section.childCount()): - assert window.tree.visualItemRect(section.child(i)).height() > 0 - assert Workspace(week).settings() == {"view": "kind", "expanded": ["models"]} + # The space and the unfolded LOGS are remembered in the folder. + assert Workspace(week).settings() == {"material_expanded": True, "space": "model"} window.close() again = StudioWindow() again.show() again.open_workspace(str(week)) - assert again.view_mode == "kind" and again.view_switch.index() == 1 - again.set_view_mode("folder") - assert again.folder_items["models"].isExpanded() + assert again.space == "model" and again.space_switch.index() == 1 + again.set_space("mine") + assert again.material_section.isExpanded() + assert _layout(again) == ["ANALYSES", " empty", "LOGS · 1", " logs", " boarding"] again.close() @@ -1617,6 +1646,7 @@ def test_organising_files_from_the_sidebar(app, tmp_path, monkeypatch, fake_bin) window = StudioWindow() window.show() window.open_workspace(str(week)) + window.set_space("model") # the nets' space # New Folder, inside "models". answers.append("assignment 1") @@ -1726,7 +1756,8 @@ def arcs() -> int: _pump(app, 1.5) assert not path.exists() # autosave does not bring it back - # A new subfolder made in Finder, with a log copied straight into it. + # A new subfolder made in Finder, with a log copied straight into it (listed in Mine). + window.set_space("mine") (week / "week 3").mkdir() write_xes(EventLog.from_simple_log(parse_simple_log("[]"), "New"), week / "week 3" / "fresh.xes") @@ -2235,17 +2266,29 @@ def test_log_tabs_fit_the_window(app): while not isinstance(area, QScrollArea): area = area.parentWidget() names = [b.text() for b in page.tabs.buttons] - for name in ("Process map", "Discover"): + for name in ("Process map", "Discover"): # Discover: the handoff card, in the studio page.tabs.set_index(names.index(name)) _pump(app, 0.3) - for box in page.findChildren(QComboBox): + assert page.height() <= area.viewport().height() + # Learn's log page runs the miners inline: its drawn process tree keeps its zoom bar in the card. + from openprocess.gui.studio.log_page import LogPage + inline = LogPage(LogDocument(read_xes(DATA / "plane_wilma_10.xes"))) + holder = QScrollArea() + holder.setWidgetResizable(True) + holder.setWidget(inline) + holder.resize(1200, 760) + holder.show() + inline.tabs.set_index([b.text() for b in inline.tabs.buttons].index("Discover")) + _pump(app, 0.3) + for box in inline.findChildren(QComboBox): if box.findData("im") >= 0: box.setCurrentIndex(box.findData("im")) _pump(app, 2.0) - assert page.height() <= area.viewport().height() - tree = page.findChildren(TreeView)[0] + assert inline.height() <= holder.viewport().height() + tree = inline.findChildren(TreeView)[0] bar = tree.zoom_controls assert bar.isVisible() and tree.viewport().geometry().contains(bar.geometry()) + holder.close() window.close() @@ -2300,3 +2343,160 @@ def offset() -> int: assert [t.name for t in page.net.all_transitions()] == ["pay"] page.document.dirty = False window.close() + + +def test_connections_view_and_the_model_palette(app, tmp_path): + """Connections draws what flows in and out and the tools that plug in, from + the library; in Model, a net page's tools are a palette in the sidebar.""" + from openprocess.gui.studio import connections + from openprocess.gui.studio.app import StudioWindow + + window = StudioWindow() + window.show() + window.show_connections() + assert window.content.currentWidget() is window.connections_page + cards = window.connections_page.cards + assert {"Event logs", "Petri nets", "PNML", "OpenProcess", "Your own boxes"} <= set(cards) + tools = {t["name"]: t for t in connections.tools(window.library())} + assert "pandas" in tools and "Describe log" in tools["pandas"]["boxes"] + assert tools["pandas"]["name"] in cards + + window.action_new_petri() + page = window.current_page() + assert window.space == "model" and not window.palette.isHidden() + assert page.tool_switch.isHidden() # the palette stands in for it + window.palette_group.button(1).click() + assert page.tool_switch.index() == 1 and page.scene.tool == "place" + page.tool_switch.set_index(3) + assert window.palette_group.checkedId() == 3 + window.toggle_sidebar(False, remember=False) + assert not page.tool_switch.isHidden() # no sidebar: the page's own tools + window.toggle_sidebar(True, remember=False) + assert page.tool_switch.isHidden() + window.set_space("mine") + assert window.palette.isHidden() + window.set_space("model") + assert not window.palette.isHidden() and window.current_page() is page + page.document.dirty = False + window.close() + + +def test_the_studio_log_page_hands_discovery_to_the_canvas(app, tmp_path): + """In the studio, the log page's Discover tab runs no miner: it builds an + analysis in Mine with the log and the chosen miner, connected and run, + so every step is shown. (Learn keeps the inline miners.)""" + from PySide6.QtWidgets import QComboBox + + from openprocess.gui.studio.app import StudioWindow + from openprocess.gui.studio.documents import LogDocument, WorkflowDocument + from openprocess.gui.studio.log_page import LogPage + from openprocess.mining import EventLog, parse_simple_log + + week = tmp_path / "Week 2" + week.mkdir() + window = StudioWindow() + window.show() + window.open_workspace(str(week)) + log = EventLog.from_simple_log(parse_simple_log("[^3, ^2, ]"), "L") + window.add_document(LogDocument(log, notation="[^3, ^2, ]")) + page = window.current_page() + assert isinstance(page, LogPage) and page.discover_mode == "handoff" + page.tabs.set_index(6) # Discover + _pump(app, 0.1) + assert not [b for b in page.findChildren(QComboBox) if b.findData("im") >= 0] # no inline miners + page.discover_chooser.setCurrentIndex(page.discover_chooser.findData("alpha_miner")) + page.discover_button.click() + _pump(app, 0.2) + analysis = window.current_page() + assert isinstance(analysis.document, WorkflowDocument) and window.space == "mine" + assert analysis.workflow.name == "Discover L" + names = [analysis.workflow.spec(n).name for n in analysis.workflow.order()] + assert names == ["Open log", "α-algorithm"] and len(analysis.workflow.edges) == 1 + miner = analysis.workflow.order()[1] + assert analysis.selected == miner.id # its Result is open + end = time.time() + 8 + while time.time() < end and (analysis.run is None or analysis._running): + _pump(app, 0.02) + assert analysis.run.result(miner).status == "done", analysis.run.result(miner).error + # Learn's log page keeps the miners inline. + inline = LogPage(LogDocument(log)) + assert inline.discover_mode == "inline" + for document in list(window.documents): + document.dirty = False + window.close() + + +def test_a_tool_can_be_installed_from_connections(app, monkeypatch): + """Install… on a tool's card runs pip for this app's Python in the + background, after asking; the card says Installing…, then the boxes + that need the tool are available.""" + from openprocess.gui.studio import app as studio_app, connections + from openprocess.gui.studio.app import StudioWindow + + fake = {"name": "fakestats", "installed": False, "install": "pip install fakestats", "boxes": ["Fake box"]} + monkeypatch.setattr(connections, "tools", lambda library: [fake]) + monkeypatch.setattr(connections, "can_install", lambda: True) + monkeypatch.setattr(studio_app.QMessageBox, "question", + staticmethod(lambda *a, **k: studio_app.QMessageBox.Yes)) + ran = [] + + def pip(command): + ran.append(command) + fake["installed"] = True + return 0, "Successfully installed fakestats" + monkeypatch.setattr(studio_app, "PIP_RUNNER", pip) + + window = StudioWindow() + window.show() + window.show_connections() + page = window.connections_page + assert "fakestats" in page.install_buttons + page.install_buttons["fakestats"].click() + assert "fakestats" in page.installing # at once: Installing… + assert "fakestats" not in page.install_buttons + end = time.time() + 6 + while time.time() < end and page.installing: + _pump(app, 0.05) + assert ran == [[studio_app.sys.executable, "-m", "pip", "install", "fakestats"]] + assert not page.installing and "fakestats" not in page.install_buttons # installed: no button + assert "Installed fakestats" in window.statusBar().currentMessage() + window.close() + + +def test_every_computed_card_links_to_its_code(app): + """What a page computes on its own (a process map, a footprint, soundness, + regions) is as open as a box: its card has a { } code link that opens the + file, scrolled to the function.""" + from PySide6.QtWidgets import QPlainTextEdit, QPushButton + + from openprocess.gui.flow import viewers + from openprocess.gui.studio.app import StudioWindow + from openprocess.gui.studio.documents import LogDocument + from openprocess.mining import read_xes + + window = StudioWindow() + window.resize(1400, 900) + window.show() + window.add_document(LogDocument(read_xes(DATA / "plane_wilma_10.xes"), path=str(DATA / "plane_wilma_10.xes"))) + page = window.current_page() + names = [b.text() for b in page.tabs.buttons] + page.tabs.set_index(names.index("Footprint")) + _pump(app, 0.2) + links = [b for b in page.findChildren(QPushButton) if b.text() == "{ } code" and b.isVisible()] + assert links + links[0].click() + _pump(app, 0.2) + shown = viewers._windows[-1].findChild(QPlainTextEdit).toPlainText() + assert "def footprint_of_log" in shown + for dialog in list(viewers._windows): + dialog.close() + window.action_new_petri() + net_page = window.current_page() + net_page.inspector_tabs.set_index(2) # Analysis + _pump(app, 0.3) + texts = [b.text() for b in net_page.findChildren(QPushButton)] + assert texts.count("{ } code") >= 6 # soundness, theorem, structure, invariants, … + assert not any("Conformance with a log" in t for t in texts) # one route: Check against a log › + for document in window.documents: + document.dirty = False + window.close()