From 571a066239089d4ca3d2127f750b7af8c1ef1043 Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:44:57 +0200 Subject: [PATCH 1/7] 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. A switcher at the top of the window (in the title bar on macOS) picks Mine, Model or Learn. Mine lists the folder's analyses with its logs folded under them; Model lists its models; Learn is the exercise view as before, reached from the switcher too. A document opens in its own space, each space remembers its current document, and the folder remembers which space was open. The Folders / By kind toggle and the one-list-of-everything headings are gone. Subfolders stay inside each list; an empty folder shows in both spaces; an exercise's materials belong to Learn. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 31 ++ README.md | 21 +- openprocess/__init__.py | 2 +- openprocess/gui/studio/app.py | 511 +++++++++++++++++++----------- openprocess/gui/studio/sidebar.py | 2 +- openprocess/gui/studio/spaces.py | 60 ++++ openprocess/gui/studio/style.py | 5 + tests/test_exercises_gui.py | 1 - tests/test_studio_gui.py | 103 +++--- 9 files changed, 498 insertions(+), 238 deletions(-) create mode 100644 openprocess/gui/studio/spaces.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f7a0cd6..0c31052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,37 @@ 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.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. +- **Subfolders stay.** Inside each list, files sit under their subfolder as in + Finder, and the folders you unfold are remembered. A folder with no files + yet shows in both spaces, so you can put things in it. An exercise's + materials belong to Learn and are not listed. +- **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. + ## 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..acc50c2 100644 --- a/README.md +++ b/README.md @@ -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. +- **Subfolders stay.** Inside each list, files sit under their subfolder as + on disk (folders first, then files, by name); which subfolders are open is + remembered for each folder. 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 diff --git a/openprocess/__init__.py b/openprocess/__init__.py index c71b325..bd93be6 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.0" __all__ = [ "Arc", "BindingElement", "CPNet", "Marking", "Multiset", "Page", "Place", diff --git a/openprocess/gui/studio/app.py b/openprocess/gui/studio/app.py index a323aba..bc84706 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; @@ -63,7 +64,7 @@ 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 @@ -87,6 +88,7 @@ from .log_page import LogPage from .model_page import ModelPage from .sidebar import FILE_ROLE, FOLDER_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, @@ -429,13 +431,20 @@ 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" + #: 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 #: Expanded subfolders (relative paths), remembered in the folder's .openprocess. self._expanded: set[str] = set() #: 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_rows: list[tuple[str, QTreeWidgetItem]] = [] # every subfolder row (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 @@ -559,6 +568,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 +663,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 +684,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: exercises, open files from elsewhere, + # comparisons. 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.material_section = self._section("LOGS") + self.material_section.setToolTip(0, "The folder's event logs and transition systems: " + "what analyses start from. Click to fold or unfold.") self.exercises_section = self._section("EXERCISES") 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.") @@ -719,16 +719,108 @@ 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) + # 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), + button("✎ Log from notation…", self.action_notation), + 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. On macOS it + sits in the title bar; elsewhere in a slim bar under the menu.""" + 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 + self.setUnifiedTitleAndToolBarOnMac(True) + + 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) + 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() + + 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() @@ -934,7 +1026,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 +1034,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 from notation…", self.action_notation)]), + ("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 +1154,10 @@ 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)) + 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 +1281,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) @@ -1275,6 +1379,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 @@ -1454,13 +1567,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) @@ -1737,11 +1857,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 +1873,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() @@ -2503,11 +2627,11 @@ 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 +2689,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 +2725,9 @@ 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.exercises_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,7 +2761,8 @@ 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 @@ -2666,21 +2779,19 @@ 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(): + for relative, item in self._folder_rows: item.setExpanded(relative in self._expanded) # Put back the current row and the selection. rows = {self._row_id(item): item for item in self._all_rows()} @@ -2742,91 +2853,107 @@ 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 EXERCISES, OTHER + FILES (open files from outside the folder) and COMPARISONS. Inside a + section, files sit under their subfolder as in Finder. + """ 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") + if self._tree is not None: + for folder in self._tree.walk(): + if folder.exercise: + self.exercises_section.addChild(self._folder_row(folder)) + tree.addTopLevelItem(self.exercises_section) 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``, laid out as in the + folder: a subfolder that holds any of them is a row that folds open, + files sit under theirs. A document with no file goes to UNSAVED, one + from outside the folder to OTHER FILES. With ``empty_folders``, a + subfolder with no files at all is listed 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 + folders: 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 folder_row(relative: str) -> QTreeWidgetItem: + if relative in folders: + return folders[relative] + above = relative.rpartition("/")[0] + parent = folder_row(above) if above else section + folder = by_relative.get(relative) + item = self._folder_row(folder) if folder is not None \ + else QTreeWidgetItem([relative.rpartition("/")[2]]) + parent.addChild(item) + folders[relative] = item + self._folder_rows.append((relative, item)) + self.folder_items.setdefault(relative, item) + return item 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] + (folder_row(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] + (folder_row(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 folders \ + and not relative.startswith(exercises) \ + and not any(f.files for f in folder.walk()): + folder_row(relative) + for item in (section, *folders.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.""" @@ -2851,7 +2978,12 @@ def _sort_rows(self, parent: QTreeWidgetItem) -> None: 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.""" + """A subfolder (or the LOGS section) was expanded or collapsed: 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 path = item.data(0, FOLDER_ROLE) if not path or self.workspace is None: return @@ -2936,7 +3068,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) @@ -3585,8 +3716,6 @@ def new_folder(self, inside: str | None = None) -> None: 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 +3769,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 +3784,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 +3914,9 @@ 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() 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 +3926,14 @@ 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() 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/sidebar.py b/openprocess/gui/studio/sidebar.py index 6a040bf..901417b 100644 --- a/openprocess/gui/studio/sidebar.py +++ b/openprocess/gui/studio/sidebar.py @@ -4,7 +4,7 @@ * ``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. 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 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..2c9d025 100644 --- a/openprocess/gui/studio/style.py +++ b/openprocess/gui/studio/style.py @@ -348,6 +348,11 @@ def stylesheet() -> str: #revealRow QLabel {{ background: transparent; }} #revealTitle {{ font-weight: 600; }} + /* ---- 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/tests/test_exercises_gui.py b/tests/test_exercises_gui.py index 4b480e0..0ad2558 100644 --- a/tests/test_exercises_gui.py +++ b/tests/test_exercises_gui.py @@ -92,7 +92,6 @@ def test_clicking_an_exercise_in_the_sidebar_opens_it(app, demo): 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]) diff --git a/tests/test_studio_gui.py b/tests/test_studio_gui.py index 42fdf36..b406560 100644 --- a/tests/test_studio_gui.py +++ b/tests/test_studio_gui.py @@ -108,7 +108,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 +250,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 +1265,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 +1316,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 +1327,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,7 +1567,10 @@ def _layout(window) -> list[str]: return lines -def test_folder_view_shows_subfolders_and_remembers_them(app, tmp_path): +def test_each_space_lists_its_own_files_and_remembers_them(app, tmp_path): + """Mine lists logs under LOGS IN THIS FOLDER (folded), Model lists the nets; + both keep the folder's subfolders. The space, the unfolded section and + the expanded subfolders are remembered in the folder.""" from openprocess.gui.studio.app import FOLDER_ROLE, StudioWindow from openprocess.gui.studio.workspace import Workspace @@ -1565,35 +1578,43 @@ def test_folder_view_shows_subfolders_and_remembers_them(app, 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"] + 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 (a folder with + # no files shows in both spaces, waiting for some); the logs are folded away. + assert _layout(window) == ["ANALYSES", " empty", "LOGS · 1"] + window.material_section.setExpanded(True) + assert _layout(window) == ["ANALYSES", " empty", "LOGS · 1", " logs"] + window.folder_items["logs"].setExpanded(True) + assert _layout(window)[-3:] == ["LOGS · 1", " logs", " boarding"] + + # Model: the nets, folders first, as in Finder; subfolders start collapsed. + window.set_space("model") + assert not window.footers["model"].isHidden() and window.footers["mine"].isHidden() + assert _layout(window) == ["MODELS", " empty", " models", " transfer"] window.folder_items["models"].setExpanded(True) - assert _layout(window) == ["empty", "logs", "models", " order", "transfer"] + assert _layout(window) == ["MODELS", " empty", " models", " order", " transfer"] 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 in its subfolder. 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", " empty", " models", " order", " transfer"] 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, the section and the expanded subfolders are remembered in the folder. + assert Workspace(week).settings() == {"expanded": ["logs", "models"], + "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.space == "model" and again.space_switch.index() == 1 assert again.folder_items["models"].isExpanded() + again.set_space("mine") + assert again.material_section.isExpanded() and again.folder_items["logs"].isExpanded() again.close() @@ -1617,6 +1638,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 +1748,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") From ee5d2f672afa2640977f8bffa42f0ba4116aaf74 Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:58:26 +0200 Subject: [PATCH 2/7] Spaces: flat lists under subfolder captions, LOGS folds on click, no exercises in Mine The per-space folder trees made the sidebar look different in each space. Each list is now flat: the folder's own files first, then each subfolder's under a small caption naming it, the same rule in Mine and Model. Only the LOGS heading folds, with a chevron, on a single click; its state is kept per folder. Exercises are Learn's and no longer listed in Mine. A folder shows without files only when there is nothing in it at all. The switcher is a plain bar under the title, since the unified title bar did not take on macOS. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 10 +- README.md | 8 +- openprocess/gui/studio/app.py | 175 ++++++++++++++---------------- openprocess/gui/studio/sidebar.py | 13 ++- tests/test_exercises_gui.py | 16 +-- tests/test_studio_gui.py | 40 +++---- 6 files changed, 129 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c31052..418c673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,10 +27,12 @@ moves on disk. 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. -- **Subfolders stay.** Inside each list, files sit under their subfolder as in - Finder, and the folders you unfold are remembered. A folder with no files - yet shows in both spaces, so you can put things in it. An exercise's - materials belong to Learn and are not listed. +- **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. diff --git a/README.md b/README.md index acc50c2..ce0c7f7 100644 --- a/README.md +++ b/README.md @@ -240,10 +240,10 @@ which space you were in. **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. -- **Subfolders stay.** Inside each list, files sit under their subfolder as - on disk (folders first, then files, by name); which subfolders are open is - remembered for each folder. An empty folder shows in both spaces until it - has files. +- **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 diff --git a/openprocess/gui/studio/app.py b/openprocess/gui/studio/app.py index bc84706..15635ba 100644 --- a/openprocess/gui/studio/app.py +++ b/openprocess/gui/studio/app.py @@ -37,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 @@ -87,7 +88,7 @@ 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, @@ -114,6 +115,14 @@ BIN = "Recycle Bin" if sys.platform == "win32" else "Bin" +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: """The whole extension, so ``log.xes.gz`` gives ``.xes.gz`` (not just ``.gz``).""" return file_suffix(path.name) @@ -439,12 +448,10 @@ def __init__(self, persist: bool = False) -> None: self._material_expanded = False #: The space that was shown before Learn took the window. self._space_before_learn = MINE - #: Expanded subfolders (relative paths), remembered in the folder's .openprocess. - self._expanded: set[str] = set() #: 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_rows: list[tuple[str, QTreeWidgetItem]] = [] # every subfolder row (a path may have two) + 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 @@ -686,15 +693,15 @@ def _build_sidebar(self) -> QWidget: shortcut.activated.connect(self.remove_selected) # 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: exercises, open files from elsewhere, - # comparisons. + # 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.analyses_section = self._section("ANALYSES") self.models_section = self._section("MODELS") 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.exercises_section = self._section("EXERCISES") 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") @@ -710,8 +717,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) @@ -744,8 +751,8 @@ def _build_sidebar(self) -> QWidget: # -- the spaces: Mine, Model, Learn ------------------------------------------------ def _build_space_bar(self) -> None: - """The switcher at the top of the window: Mine, Model, Learn. On macOS it - sits in the title bar; elsewhere in a slim bar under the menu.""" + """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) @@ -761,7 +768,6 @@ def _build_space_bar(self) -> None: bar.addWidget(self.space_switch) self.addToolBar(Qt.TopToolBarArea, bar) self.space_bar = bar - self.setUnifiedTitleAndToolBarOnMac(True) def set_space(self, space: str, remember: bool = True) -> bool: """Show ``space``. Mine and Model swap the sidebar and the page; Learn @@ -1761,17 +1767,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: @@ -2627,7 +2622,6 @@ def open_workspace(self, folder: str) -> bool: self.workspace = workspace self._show_notes_of_folder() settings = workspace.settings() - self._expanded = {e for e in settings.get("expanded", []) if isinstance(e, str)} self._material_expanded = bool(settings.get("material_expanded", False)) self._space_current = {MINE: None, MODEL: None} saved = settings.get("space") @@ -2726,8 +2720,7 @@ def _stop_watching(self) -> None: # -- the sidebar's rows --------------------------------------------------------- def _sections(self) -> list[QTreeWidgetItem]: return [self.unsaved_section, self.analyses_section, self.models_section, - self.material_section, self.exercises_section, self.elsewhere_section, - self.compare_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.""" @@ -2765,8 +2758,8 @@ def _rebuild_sidebar(self) -> None: 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()) @@ -2791,8 +2784,8 @@ def _rebuild_sidebar(self) -> None: 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_rows: - 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: @@ -2858,9 +2851,13 @@ def _fill_space_view(self) -> None: 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 EXERCISES, OTHER - FILES (open files from outside the folder) and COMPARISONS. Inside a - section, files sit under their subfolder as in Finder. + 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 space = self.space @@ -2880,11 +2877,6 @@ def _fill_space_view(self) -> None: 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") - if self._tree is not None: - for folder in self._tree.walk(): - if folder.exercise: - self.exercises_section.addChild(self._folder_row(folder)) - tree.addTopLevelItem(self.exercises_section) for document in self.documents: if isinstance(document, ComparisonDocument): self.compare_section.addChild(self._document_row(document)) @@ -2898,39 +2890,36 @@ def _fill_space_view(self) -> None: 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``, laid out as in the - folder: a subfolder that holds any of them is a row that folds open, - files sit under theirs. A document with no file goes to UNSAVED, one - from outside the folder to OTHER FILES. With ``empty_folders``, a - subfolder with no files at all is listed 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.""" + 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 - folders: dict[str, QTreeWidgetItem] = {} + 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 folder_row(relative: str) -> QTreeWidgetItem: - if relative in folders: - return folders[relative] - above = relative.rpartition("/")[0] - parent = folder_row(above) if above else section - folder = by_relative.get(relative) - item = self._folder_row(folder) if folder is not None \ - else QTreeWidgetItem([relative.rpartition("/")[2]]) - parent.addChild(item) - folders[relative] = item - self._folder_rows.append((relative, item)) - self.folder_items.setdefault(relative, item) - return item + 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)) above = file.relative.rpartition("/")[0] - (folder_row(above) if above else section).addChild(item) + (caption(above) if above else section).addChild(item) count += 1 for document in documents: item = self._document_row(document) @@ -2943,55 +2932,54 @@ def folder_row(relative: str) -> QTreeWidgetItem: self.elsewhere_section.addChild(item) else: above = self.workspace.relative(document.path).rpartition("/")[0] - (folder_row(above) if above else section).addChild(item) + (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 folders \ - and not relative.startswith(exercises) \ - and not any(f.files for f in folder.walk()): - folder_row(relative) - for item in (section, *folders.values()): + 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) 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 (or the LOGS section) was expanded or collapsed: remember it in the folder.""" + """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 - path = item.data(0, FOLDER_ROLE) - if not path or self.workspace is None: + 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 - 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)) + self._open_placeholder(item) def _open_placeholder(self, item) -> None: try: @@ -3713,9 +3701,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)) self._rescan_workspace() item = self.folder_items.get(self.workspace.relative(target)) if item is not None: diff --git a/openprocess/gui/studio/sidebar.py b/openprocess/gui/studio/sidebar.py index 901417b..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. +* :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/tests/test_exercises_gui.py b/tests/test_exercises_gui.py index 0ad2558..70f5e55 100644 --- a/tests/test_exercises_gui.py +++ b/tests/test_exercises_gui.py @@ -87,18 +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)) - 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() diff --git a/tests/test_studio_gui.py b/tests/test_studio_gui.py index b406560..6d768db 100644 --- a/tests/test_studio_gui.py +++ b/tests/test_studio_gui.py @@ -1568,10 +1568,11 @@ def _layout(window) -> list[str]: def test_each_space_lists_its_own_files_and_remembers_them(app, tmp_path): - """Mine lists logs under LOGS IN THIS FOLDER (folded), Model lists the nets; - both keep the folder's subfolders. The space, the unfolded section and - the expanded subfolders are remembered in the folder.""" - from openprocess.gui.studio.app import FOLDER_ROLE, StudioWindow + """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) @@ -1580,41 +1581,40 @@ def test_each_space_lists_its_own_files_and_remembers_them(app, tmp_path): window.open_workspace(str(week)) 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 (a folder with - # no files shows in both spaces, waiting for some); the logs are folded away. + # 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"] - window.folder_items["logs"].setExpanded(True) - assert _layout(window)[-3:] == ["LOGS · 1", " logs", " boarding"] + 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 nets, folders first, as in Finder; subfolders start collapsed. + # 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", " empty", " models", " transfer"] - window.folder_items["models"].setExpanded(True) - assert _layout(window) == ["MODELS", " empty", " models", " order", " transfer"] + 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. + # 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) == ["MODELS", " empty", " 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 space, the section and the expanded subfolders are remembered in the folder. - assert Workspace(week).settings() == {"expanded": ["logs", "models"], - "material_expanded": True, "space": "model"} + # 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.space == "model" and again.space_switch.index() == 1 - assert again.folder_items["models"].isExpanded() again.set_space("mine") - assert again.material_section.isExpanded() and again.folder_items["logs"].isExpanded() + assert again.material_section.isExpanded() + assert _layout(again) == ["ANALYSES", " empty", "LOGS · 1", " logs", " boarding"] again.close() From 89a310f4ef10854cbe037d5e3886589666d5cefb Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:04:46 +0200 Subject: [PATCH 3/7] =?UTF-8?q?The=20file=20setting's=20Choose=E2=80=A6=20?= =?UTF-8?q?button=20stays=20in=20view=20in=20a=20folder=20with=20long=20pa?= =?UTF-8?q?ths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file field sized itself to its longest entry, so a folder with long paths pushed the button and the panel's text past the panel's edge. It is now sized by the panel. Co-Authored-By: Claude Fable 5.1 --- openprocess/gui/flow/viewers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openprocess/gui/flow/viewers.py b/openprocess/gui/flow/viewers.py index 98df180..0d84c3e 100644 --- a/openprocess/gui/flow/viewers.py +++ b/openprocess/gui/flow/viewers.py @@ -680,6 +680,10 @@ 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(): From 8a638fdafbb6595f3eba2f77e26346f3142a364c Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:11:33 +0200 Subject: [PATCH 4/7] The file setting's list can be used: opening it no longer re-runs the box Opening the list took the focus from the field, which counted as "editing finished" and reported the unchanged value; 400 ms later the box re-ran and the panel was rebuilt under the open list, so nothing could be picked. The field now reports only a real change, and the page runs nothing when a setting is set to the value it already had. Co-Authored-By: Claude Fable 5.1 --- openprocess/gui/flow/page.py | 4 ++- openprocess/gui/flow/viewers.py | 11 ++++++++- tests/test_flow_gui.py | 44 +++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/openprocess/gui/flow/page.py b/openprocess/gui/flow/page.py index 918d464..273e302 100644 --- a/openprocess/gui/flow/page.py +++ b/openprocess/gui/flow/page.py @@ -452,12 +452,14 @@ 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) if changed: self._mark_edited() self.run_from(changed) diff --git a/openprocess/gui/flow/viewers.py b/openprocess/gui/flow/viewers.py index 0d84c3e..8aa0c4a 100644 --- a/openprocess/gui/flow/viewers.py +++ b/openprocess/gui/flow/viewers.py @@ -690,10 +690,19 @@ def _path_control(self, setting: Setting, value) -> QComboBox: control.addItem(name) control.setCurrentText("" if value in (None, "") else str(value)) + last = {"text": "" if value in (None, "") else str(value)} + 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()) @@ -718,7 +727,7 @@ def _choose_file(self, setting: Setting, control: QComboBox) -> None: 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.""" diff --git a/tests/test_flow_gui.py b/tests/test_flow_gui.py index 2cd5418..39695a9 100644 --- a/tests/test_flow_gui.py +++ b/tests/test_flow_gui.py @@ -568,3 +568,47 @@ 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") + 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())] == ["logs/orders.log.txt"] + 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" + page.document.dirty = False + page.close() From fc529d9c275f18ef4dd49ed7ec2c4bb99fa2909d Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:21:00 +0200 Subject: [PATCH 5/7] Combo lists are sized before they open; the file list shows names Widening a combo box's list once it was showing made macOS flicker and close it, so a list with long entries (the file setting, with the folder's paths) could not be used. The list is now sized when the box is pressed, before it opens; on macOS the open popup is left alone. The file list shows file names, with the subfolder only when two names clash, and holds the relative path behind each entry. Co-Authored-By: Claude Fable 5.1 --- openprocess/gui/flow/viewers.py | 18 +++++++++++++++--- openprocess/gui/studio/widgets.py | 27 ++++++++++++++++++++++++--- tests/test_flow_gui.py | 4 +++- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/openprocess/gui/flow/viewers.py b/openprocess/gui/flow/viewers.py index 8aa0c4a..c479757 100644 --- a/openprocess/gui/flow/viewers.py +++ b/openprocess/gui/flow/viewers.py @@ -686,12 +686,24 @@ def _path_control(self, setting: Setting, value) -> QComboBox: 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 @@ -705,7 +717,7 @@ def typed(s=setting, c=control): 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: diff --git a/openprocess/gui/studio/widgets.py b/openprocess/gui/studio/widgets.py index 6774090..3f9db39 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, ) @@ -554,21 +556,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_flow_gui.py b/tests/test_flow_gui.py index 39695a9..2ecebef 100644 --- a/tests/test_flow_gui.py +++ b/tests/test_flow_gui.py @@ -595,7 +595,8 @@ def test_picking_a_file_from_the_list_runs_the_box(app, tmp_path): 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())] == ["logs/orders.log.txt"] + assert [field.itemText(i) for i in range(field.count())] == ["orders.log.txt"] # 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 @@ -610,5 +611,6 @@ def test_picking_a_file_from_the_list_runs_the_box(app, tmp_path): _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() From d45489049824c63044d28e88d522545ccb44e501 Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:26:21 +0200 Subject: [PATCH 6/7] A file box lists only the files it can read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kinds come from what the box gives: a Log means event logs, a Petri net PNML, a coloured net .cpn, a transition system ts.txt. Nothing for a box author to declare. The Choose… dialog filters the same way. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 6 ++++++ openprocess/gui/flow/viewers.py | 37 ++++++++++++++++++++++++++++----- tests/test_flow_gui.py | 7 +++++-- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 418c673..6fb21a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,12 @@ moves on disk. 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 diff --git a/openprocess/gui/flow/viewers.py b/openprocess/gui/flow/viewers.py index c479757..8d3daaf 100644 --- a/openprocess/gui/flow/viewers.py +++ b/openprocess/gui/flow/viewers.py @@ -734,8 +734,7 @@ 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)) @@ -751,16 +750,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/tests/test_flow_gui.py b/tests/test_flow_gui.py index 2ecebef..1d44172 100644 --- a/tests/test_flow_gui.py +++ b/tests/test_flow_gui.py @@ -541,7 +541,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) @@ -584,6 +585,8 @@ def test_picking_a_file_from_the_list_runs_the_box(app, tmp_path): 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"))) @@ -595,7 +598,7 @@ def test_picking_a_file_from_the_list_runs_the_box(app, tmp_path): 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"] # short names + 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() From 38fa550d4a56e447b4097a835b75d090c5add1c2 Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:53:59 +0200 Subject: [PATCH 7/7] 0.9.1 to 0.9.4: files into the canvas, the handoffs, Summary and Connections, motion (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 0.9.1: files flow into the canvas An input box names its file on the canvas ("INPUT · boarding.xes"). The folder's logs, nets and transition systems are listed under Input in + Add box, one click each, and a file dropped on the canvas becomes its input box at that spot. Mine's Open log… and Log from notation… land as boxes on the current analysis (or a new one named after the file). A page opened from a box's result shows "‹ Back to " above its title. Also fixed: a box added while a run was in progress was queued as "run everything", which then counted as nothing queued, so it never ran. Co-Authored-By: Claude Fable 5.1 * 0.9.2: the handoffs between Mine and Model Open a copy in Model (was Edit a copy): the model is named "(copy)", its header links back ("A copy, from Inductive Miner in …") and the result in the workflow is never changed. A discovered net can be tidied in Mine by dragging its places and transitions; the layout is kept with the box and the net itself is untouched. Mine a simulated log (was Export / Generate event log) saves the log in the folder next to the model and opens Mine on a new analysis reading it. Check against a log on a Petri net builds an analysis with Open net, Open log and Check fit, connected and run. Co-Authored-By: Claude Fable 5.1 * 0.9.3: Summary, the process map's detail slider, the Model palette, Connections Canvas | Summary on every analysis: a tile per result in run order, built from the boxes' own results, and the first log's process map with a Detail slider and a legend. In Model, a net page's Select, Place, Transition and Arc sit in the sidebar as a palette (the page's own buttons return when the sidebar is hidden). Connections, at the bottom of the sidebar and in the View menu, draws what flows in, OpenProcess and its boxes, what flows out, and the optional tools with whether each is installed, all read from the box library. Co-Authored-By: Claude Fable 5.1 * 0.9.4: motion A space switch, the side panel and Canvas ↔ Summary lift a short veil so the change settles rather than snaps; a box pulses once when it finishes; + Add box fades in. OPENPROCESS_NO_MOTION=1 turns it all off. Co-Authored-By: Claude Fable 5.1 * Review pass: the picker's file rows get icons, the Summary's map info fits, the README covers 0.9.1 to 0.9.4 Co-Authored-By: Claude Fable 5.1 * Over a node, the arc arrow always gives way, whatever the zoom Zoomed out, the arrow's hit zone (14 screen pixels, in scene units) could reach a neighbouring node, so moving the mouse onto that node kept the arrow hot instead of hiding it. Seen on CI, where the view fits a smaller screen; the node now wins before the arrow's hit test. Co-Authored-By: Claude Fable 5.1 * 0.9.5: nothing in the app is a black box The studio's log page no longer discovers models on the spot: Discover hands the log to an analysis in Mine, with the miner connected and run, its How and Code a click away (Learn keeps the inline miners for its exercises). The model page's conformance is the same handoff. Every card a page computes on its own (process map, footprint, soundness, invariants, state space, regions) has a { } code link to the function. The Summary says what it is and shows only the boxes' results: its process map is the Directly-follows graph box's, offered in one click when missing. A tool that is not installed can be installed from Connections. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- CHANGELOG.md | 117 +++++++++ README.md | 96 ++++--- openprocess/__init__.py | 2 +- openprocess/flow/workflow.py | 14 +- openprocess/gui/canvas.py | 9 +- openprocess/gui/flow/canvas.py | 78 +++++- openprocess/gui/flow/page.py | 166 ++++++++++-- openprocess/gui/flow/picker.py | 107 +++++++- openprocess/gui/flow/summary.py | 234 +++++++++++++++++ openprocess/gui/flow/viewers.py | 31 ++- openprocess/gui/studio/app.py | 346 +++++++++++++++++++++++-- openprocess/gui/studio/connections.py | 185 +++++++++++++ openprocess/gui/studio/cpn_page.py | 11 +- openprocess/gui/studio/documents.py | 10 + openprocess/gui/studio/graph_view.py | 39 ++- openprocess/gui/studio/log_page.py | 51 +++- openprocess/gui/studio/model_page.py | 33 ++- openprocess/gui/studio/motion.py | 95 +++++++ openprocess/gui/studio/petri_page.py | 33 ++- openprocess/gui/studio/provenance.py | 66 +++++ openprocess/gui/studio/regions_view.py | 5 +- openprocess/gui/studio/style.py | 12 + openprocess/gui/studio/widgets.py | 14 + tests/test_exercises_gui.py | 10 +- tests/test_flow.py | 14 + tests/test_flow_gui.py | 321 ++++++++++++++++++++++- tests/test_studio_gui.py | 191 +++++++++++++- 27 files changed, 2160 insertions(+), 130 deletions(-) create mode 100644 openprocess/gui/flow/summary.py create mode 100644 openprocess/gui/studio/connections.py create mode 100644 openprocess/gui/studio/motion.py create mode 100644 openprocess/gui/studio/provenance.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fb21a0..e97d9b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,123 @@ 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 diff --git a/README.md b/README.md index ce0c7f7..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** @@ -384,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 | |---|---| @@ -396,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 @@ -406,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) @@ -443,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 @@ -722,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 @@ -761,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 bd93be6..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.9.0" +__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 273e302..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: @@ -460,6 +568,9 @@ def _apply_pending_settings(self) -> None: continue 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) @@ -693,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 " @@ -725,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 8d3daaf..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) diff --git a/openprocess/gui/studio/app.py b/openprocess/gui/studio/app.py index 15635ba..c91cd29 100644 --- a/openprocess/gui/studio/app.py +++ b/openprocess/gui/studio/app.py @@ -62,6 +62,7 @@ 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, @@ -73,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 @@ -113,6 +115,20 @@ #: 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: @@ -535,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() @@ -726,14 +746,41 @@ def _build_sidebar(self) -> QWidget: self.tree.itemCollapsed.connect(lambda item: self._folder_toggled(item, False)) layout.addWidget(self.tree, 1) + # 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), - button("✎ Log from notation…", self.action_notation), + 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() @@ -802,6 +849,7 @@ def set_space(self, space: str, remember: bool = True) -> bool: 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: @@ -810,6 +858,74 @@ def _set_space_quietly(self, space: str) -> None: 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 @@ -874,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) @@ -1054,8 +1174,8 @@ def _build_welcome(self) -> QWidget: "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 from notation…", self.action_notation)]), + ("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.", @@ -1163,6 +1283,7 @@ def _build_menus(self) -> None: 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(), @@ -1300,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)) @@ -1317,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: @@ -1330,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) @@ -1344,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)) @@ -1360,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 @@ -1374,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) @@ -1453,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.""" @@ -1607,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 @@ -2023,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). @@ -2248,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).""" @@ -3902,6 +4210,7 @@ def open_exercise(self, folder: str | Path) -> bool: 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) @@ -3919,6 +4228,7 @@ def leave_learn(self, to: str | None = None) -> None: 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/style.py b/openprocess/gui/studio/style.py index 2c9d025..b21bc2f 100644 --- a/openprocess/gui/studio/style.py +++ b/openprocess/gui/studio/style.py @@ -348,6 +348,18 @@ 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; }} diff --git a/openprocess/gui/studio/widgets.py b/openprocess/gui/studio/widgets.py index 3f9db39..81a510b 100644 --- a/openprocess/gui/studio/widgets.py +++ b/openprocess/gui/studio/widgets.py @@ -530,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) diff --git a/tests/test_exercises_gui.py b/tests/test_exercises_gui.py index 70f5e55..d8bf6e7 100644 --- a/tests/test_exercises_gui.py +++ b/tests/test_exercises_gui.py @@ -374,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) @@ -392,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 1d44172..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() @@ -617,3 +621,314 @@ def test_picking_a_file_from_the_list_runs_the_box(app, tmp_path): 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 6d768db..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. @@ -2258,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() @@ -2323,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()