diff --git a/CHANGELOG.md b/CHANGELOG.md index fec224b..dbd5d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,22 @@ notice, not how it was done. Versions before 0.7.0 were released as CPNpy. - A folder's own boxes keep working when the folder is moved, copied or imported: a box is found by its function name in `boxes/` when its old id no longer matches. +- **Plot's *x* and *y* draw what they say.** Set them and the figure shows + column *y* of the connected table against column *x*, bars when *x* holds + names (a model per bar) and a line when it holds numbers, whatever else is + connected. Before, scores plugged into the box always gave their bars and + the two settings changed nothing. A column a table lacks is named in the + error, with the columns it has. +- **The process map fits the window.** On the Summary page the map took the + drawing's own height, up to three quarters of the screen, and the page + scrolled; it now takes the room the window has below the tiles, and the + map fits itself to it. +- A formula in a worksheet that wraps over two lines is typeset as one + formula; before, the words between it and the next formula were taken for + maths ("andthenetN"). A property's ⓘ card no longer scrolls by a few + pixels. On Windows and Linux the menu bar follows dark mode. +- The README's screenshots show this version, made from the app itself by + `docs/make_screenshots.py`. ## 0.10.6 diff --git a/README.md b/README.md index cdf5f96..50a339c 100644 --- a/README.md +++ b/README.md @@ -489,17 +489,19 @@ links to the definitions it builds on; **Help ▸ Definitions** lists them all. for bounded nets; for unbounded ones, up to a trace length (and it says so). -| Process map | Discovering a model | +| Process map | Discover in Mine →: the miner is a box, its result beside the canvas | |---|---| |  |  | -| α-algorithm result | Conformance (alignments on the model) | +| α-algorithm result | *How*: the eight steps, from the footprint to α(L) | |---|---| -|  |  | +|  |  | -| Comparing two logs | Linked dotted charts of three boarding strategies | +| Check fit: every score, and how it was computed | Comparing two logs | |---|---| -|  |  | +|  |  | + + The app follows the system's light or dark appearance: @@ -516,6 +518,8 @@ sidebar, **Learn ▸ Open Exercise Pack…**, or **Learn ▸ Open Demo Exercises exercises): the window switches to a view made for working through a pack without distractions, and *Exit* brings your folder back exactly as it was. + + - **The worksheet** on the left is the question top to bottom, with an answer box wherever one is needed: yes/no, multiple choice, a set (`{a, b}`, sets of sets, or pairs like `({a}, {b,d})`), a number, a short @@ -703,7 +707,10 @@ generated from `openprocess/mining/definitions.py`; after editing a definition, Likewise [`docs/references.md`](docs/references.md) — every source OpenProcess's notation and algorithms follow, also in the app under **Help ▸ References** — is generated from `openprocess/references.py` with -`python -m openprocess.references > docs/references.md`. +`python -m openprocess.references > docs/references.md`. The screenshots in +this README come from the app itself: `python docs/make_screenshots.py` +renders every page offscreen into `docs/screenshots/`, so run it after a +change to the app's looks. The engines (`openprocess.mining`, `openprocess.ml`, `openprocess.sim`, `openprocess.analysis`) have **no dependencies**, so they work in a notebook or a script: @@ -730,6 +737,10 @@ 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 canvas: *Compare discovery*, three miners side by side | Summary: every box's key figure, and the process map | +|---|---| +|  |  | + - **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 diff --git a/docs/make_screenshots.py b/docs/make_screenshots.py new file mode 100644 index 0000000..12ebfca --- /dev/null +++ b/docs/make_screenshots.py @@ -0,0 +1,332 @@ +"""Make the README's screenshots from the app itself. + + python docs/make_screenshots.py # writes docs/screenshots/*.png + python docs/make_screenshots.py welcome compare # only these + +Nothing is shown on screen: Qt's "offscreen" platform renders the window into +memory at 1600 x 960, and the app is driven the way the GUI tests drive it. +The pictures come from a temporary folder, "Week 2", filled from the +repository's example files (tests/data, examples/petri, the demo exercises) +and two textbook logs written on the spot, so they never depend on a file of +your own. Run it again whenever the app changes its looks: a stale +screenshot misleads more than none. +""" + +from __future__ import annotations + +import os +import shutil +import sys +import tempfile +import time +from pathlib import Path + +os.environ["QT_QPA_PLATFORM"] = "offscreen" +os.environ["OPENPROCESS_NO_MOTION"] = "1" # no fades: every grab is a settled window +os.environ.setdefault("QT_SCALE_FACTOR", "1") + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +OUT = ROOT / "docs" / "screenshots" +SIZE = (1600, 960) + +L1 = "[^3, ^2, ]" +L2 = ("[^3, ^4, ^2, , " + "^2, ]") + + +def make_folder(where: Path) -> Path: + """A course folder, "Week 2": logs and nets in subfolders, as a student keeps them.""" + from openprocess.mining.log import EventLog, parse_simple_log + from openprocess.mining.xes import write_xes + folder = where / "Week 2" + (folder / "logs").mkdir(parents=True) + (folder / "models").mkdir() + (folder / "assignment 1").mkdir() + shutil.copy(ROOT / "tests/data/plane_wilma_10.xes", folder / "logs/Wilma 10 passengers.xes") + for name, text in (("Textbook L1", L1), ("Textbook L2", L2)): + write_xes(EventLog.from_simple_log(parse_simple_log(text), name=name), folder / "logs" / f"{name}.xes") + shutil.copy(ROOT / "examples/petri/order_handling_unsound.pnml", folder / "models/order handling.pnml") + shutil.copy(ROOT / "examples/petri/order_handling_sound.pnml", folder / "models/order handling (sound).pnml") + shutil.copy(ROOT / "examples/petri/loop_with_silent_step.pnml", folder / "models/loop with tau.pnml") + shutil.copy(ROOT / "examples/petri/textbook_l1.pnml", folder / "textbook L1.pnml") + shutil.copy(ROOT / "tests/data/plane_boarding.cpn", folder / "plane boarding.cpn") + shutil.copytree(ROOT / "openprocess/exercises", where / "Exercises") + return folder + + +class Shooter: + def __init__(self, only: set[str]) -> None: + from PySide6.QtWidgets import QApplication + + from openprocess.gui import theme + from openprocess.gui.studio import style + from openprocess.gui.studio.app import APPLICATION_NAME, app_icon + self.app = QApplication.instance() or QApplication([sys.argv[0]]) + self.app.setApplicationName(APPLICATION_NAME) + self.app.setWindowIcon(app_icon()) + self.app.setStyle("Fusion") + self.app.setFont(theme.ui_font(13)) + self.app.setStyleSheet(style.stylesheet()) + self.only = only + self.window = None + self.made: list[str] = [] + + # -- plumbing ---------------------------------------------------------------------------- + def pump(self, seconds: float) -> None: + end = time.time() + seconds + while time.time() < end: + self.app.processEvents() + time.sleep(0.01) + for _ in range(3): + self.app.processEvents() + + def wait(self, condition, seconds: float = 30.0, what: str = "") -> None: + end = time.time() + seconds + while time.time() < end: + self.app.processEvents() + if condition(): + self.pump(0.05) + return + time.sleep(0.02) + raise TimeoutError(f"gave up waiting for {what or condition}") + + def wait_run(self, page, seconds: float = 90.0) -> None: + self.wait(lambda: page.run is not None and not page._running, seconds, "the analysis to run") + failed = page.run.failed() + if failed: + raise RuntimeError(f"boxes failed: {[r.error for r in failed]}") + + def new_window(self): + from openprocess.gui.studio.app import StudioWindow + self.window = StudioWindow(persist=False) + self.window.resize(*SIZE) + self.window.show() + self.pump(0.3) + return self.window + + def wants(self, name: str) -> bool: + return not self.only or name in self.only + + def shot(self, name: str, widget=None) -> None: + if not self.wants(name): + return + self.pump(0.4) # deferred fits and layouts + (widget or self.window).grab().save(str(OUT / f"{name}.png")) + self.made.append(name) + print(f" {name}.png") + + def open_log(self, path: Path): + from openprocess.gui.studio.documents import LogDocument + before = {d.id for d in self.window.documents} + self.window.open_path(str(path)) + self.wait(lambda: any(isinstance(d, LogDocument) and d.id not in before for d in self.window.documents), + 60, f"{path.name} to load") + document = next(d for d in self.window.documents if isinstance(d, LogDocument) and d.id not in before) + self.window._select_document(document) + self.pump(0.3) + return document, self.window.current_page() + + def open_net(self, path: Path): + self.window.open_path(str(path)) + self.pump(0.5) + return self.window.current_page() + + # -- the pictures -------------------------------------------------------------------------- + def run(self, folder: Path, exercises: Path) -> None: + from openprocess.gui.flow.templates import TEMPLATES + w = self.new_window() + self.shot("studio-welcome") + + # Mine: a log's own pages, then the analyses built from it. + assert w.open_workspace(str(folder)) + self.pump(0.5) + w.set_space("mine") + w.material_section.setExpanded(True) + _wilma, page = self.open_log(folder / "logs/Wilma 10 passengers.xes") + page.tabs.set_index(0) + self.shot("studio-overview") + page.tabs.set_index(3) + self.shot("studio-dotted-chart") + page.tabs.set_index(4) + self.shot("studio-process-map") + + l1, _ = self.open_log(folder / "logs/Textbook L1.xes") + l2, l2_page = self.open_log(folder / "logs/Textbook L2.xes") + l2_page.tabs.set_index(6) # Discover: the hand-off to an analysis + self.pump(0.2) + w.discover_in_analysis(l2, "inductive_miner") + analysis = w.current_page() + self.wait_run(analysis) + miner = next(n for n in analysis.workflow.nodes.values() if n.box.endswith("inductive_miner")) + analysis.select(miner.id, 0) + self.shot("studio-discover") + + w.discover_in_analysis(l1, "alpha_miner") + alpha_page = w.current_page() + self.wait_run(alpha_page) + alpha = next(n for n in alpha_page.workflow.nodes.values() if n.box.endswith("alpha_miner")) + alpha_page.select(alpha.id, 0) + self.shot("studio-alpha") + alpha_page.select(alpha.id, 1) # How: the eight steps + self.shot("studio-alpha-how") + + w.action_new_workflow(TEMPLATES[0][1]) # Discover and check + check = w.current_page() + self.wait_run(check) + fit = next(n for n in check.workflow.nodes.values() if n.box.endswith("check_fit")) + check.select(fit.id, 0) + self.shot("studio-conformance") + check.close_panel() + check.show_view(1) # Summary, with the process map + self.pump(0.3) + if check.summary.add_map_button.isVisible(): + check.summary.add_map_button.click() + self.wait_run(check) + check.show_view(1) + self.pump(0.3) + self.shot("workflow-summary") + check.show_view(0) + + w.action_new_workflow(TEMPLATES[1][1]) # Compare discovery: three miners side by side + compare_page = w.current_page() + self.wait_run(compare_page) + compare = next(n for n in compare_page.workflow.nodes.values() if n.box.endswith("compare")) + compare_page.select(compare.id, 0) + self.shot("workflow-canvas") + + w.compare([l1, l2]) + comparison = w.current_page() + self.pump(0.5) + comparison.tabs.set_index(1) + self.shot("compare") + comparison.tabs.set_index(3) + self.shot("compare-dotted") + + # Model: WF-nets and their soundness. + w.set_space("model") + page = self.open_net(folder / "models/order handling.pnml") + page.inspector_tabs.set_index(2) + self.wait(lambda: page.soundness_card.findChildren(self._button_class()) and any( + b.text().startswith("Show") for b in page.soundness_card.findChildren(self._button_class())), + 60, "the soundness check") + self.shot("petri-analysis") + self.definition_shot(page, "option_to_complete") + page.start_rename(next(iter(page.net.all_transitions())).id) + self.pump(0.2) + self.shot("petri-editing") + page.view.close_editor() + show = next(b for b in page.soundness_card.findChildren(self._button_class()) if b.text().startswith("Show")) + show.click() + self.pump(0.5) + self.shot("petri-counterexample") + page.mode_switch.set_index(0) + + sound = self.open_net(folder / "models/order handling (sound).pnml") + sound.inspector_tabs.set_index(2) + self.wait(lambda: sound.footprint_card.isVisibleTo(sound), 60, "the sound net's analysis") + self.pump(0.3) + sound.inspector_stack.currentWidget().ensureWidgetVisible(sound.footprint_card) + self.shot("petri-footprint") + sound.inspector_tabs.set_index(0) + sound.mode_switch.set_index(1) # Step through, with the folder beside it + self.shot("workspace") + sound.mode_switch.set_index(0) + + # Coloured nets. + cpn = self.open_net(folder / "plane boarding.cpn") + cpn.mode_switch.set_index(1) + for _ in range(4): + cpn.step_button.click() + self.pump(0.1) + self.shot("cpn-step-through") + cpn.mode_switch.set_index(0) + cpn.tool_switch.set_index(0) + arc = max(cpn.scene.arc_items.values(), key=lambda a: len(a.arc.bendpoints)) + cpn.scene.clearSelection() + arc.setSelected(True) + cpn.inspector_tabs.set_index(1) + self.shot("cpn-arc-editing") + cpn.scene.clearSelection() + cpn.inspector_tabs.set_index(3) + cpn.space_button.click() + self.wait(lambda: not cpn.busy, 300, "the state space") + self.shot("cpn-state-space") + + # Learn: an exercise, its worksheet beside its materials. + assert w.open_exercise(exercises / "7 Conformance/Exercise 7.1 Replay, alignments and workflows") + self.pump(0.8) + self.shot("learn-exercise") + w.leave_learn() + self.pump(0.3) + + # Dark mode: a fresh window, since the colours are set when widgets are built. + self.dark_shot(folder) + for document in w.documents: + document.dirty = False + w.close() + + def _button_class(self): + from PySide6.QtWidgets import QPushButton + return QPushButton + + def definition_shot(self, page, key: str) -> None: + """The ⓘ card of one soundness property, grabbed on its own (it is a popup window).""" + if not self.wants("petri-definition"): + return + from PySide6.QtCore import Qt + from PySide6.QtTest import QTest + + from openprocess.gui.studio.widgets import Verdict + verdicts = {v.definition.key: v for v in page.findChildren(Verdict) if getattr(v, "definition", None)} + QTest.mouseClick(verdicts[key].title, Qt.LeftButton) + self.pump(0.3) + popup = verdicts[key].last_popup + self.shot("petri-definition", popup) + popup.close() + self.pump(0.1) + + def dark_shot(self, folder: Path) -> None: + if not self.wants("studio-alpha-dark"): + return + from openprocess.gui import theme + from openprocess.gui.studio import style + from openprocess.gui.studio.documents import LogDocument + from openprocess.mining import read_xes + original = theme.is_dark + theme.is_dark = lambda: True + self.app.setStyleSheet(style.stylesheet()) + try: + w = self.new_window() + path = folder / "logs/Textbook L1.xes" + log = LogDocument(read_xes(path), path=str(path)) + w.add_document(log) + self.pump(0.3) + w.discover_in_analysis(log, "alpha_miner") + page = w.current_page() + self.wait_run(page) + alpha = next(n for n in page.workflow.nodes.values() if n.box.endswith("alpha_miner")) + page.select(alpha.id, 0) + self.shot("studio-alpha-dark") + for document in w.documents: + document.dirty = False + w.close() + finally: + theme.is_dark = original + self.app.setStyleSheet(style.stylesheet()) + + +def main(argv: list[str]) -> int: + only = set(argv) + OUT.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="openprocess-screenshots-") as temp: + folder = make_folder(Path(temp)) + shooter = Shooter(only) + print(f"Screenshots into {OUT}:") + shooter.run(folder, Path(temp) / "Exercises") + print(f"{len(shooter.made)} written.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/docs/screenshots/compare-dotted.png b/docs/screenshots/compare-dotted.png index 0cdf915..4c8a744 100644 Binary files a/docs/screenshots/compare-dotted.png and b/docs/screenshots/compare-dotted.png differ diff --git a/docs/screenshots/compare.png b/docs/screenshots/compare.png index 447b2bf..cac96a8 100644 Binary files a/docs/screenshots/compare.png and b/docs/screenshots/compare.png differ diff --git a/docs/screenshots/cpn-arc-editing.png b/docs/screenshots/cpn-arc-editing.png index 4c3f12e..f6873d3 100644 Binary files a/docs/screenshots/cpn-arc-editing.png and b/docs/screenshots/cpn-arc-editing.png differ diff --git a/docs/screenshots/cpn-state-space.png b/docs/screenshots/cpn-state-space.png index 4995f5c..e3be4d4 100644 Binary files a/docs/screenshots/cpn-state-space.png and b/docs/screenshots/cpn-state-space.png differ diff --git a/docs/screenshots/cpn-step-through.png b/docs/screenshots/cpn-step-through.png index c9bab80..34d7909 100644 Binary files a/docs/screenshots/cpn-step-through.png and b/docs/screenshots/cpn-step-through.png differ diff --git a/docs/screenshots/learn-exercise.png b/docs/screenshots/learn-exercise.png new file mode 100644 index 0000000..714732a Binary files /dev/null and b/docs/screenshots/learn-exercise.png differ diff --git a/docs/screenshots/petri-analysis.png b/docs/screenshots/petri-analysis.png index 3da2bec..8168a62 100644 Binary files a/docs/screenshots/petri-analysis.png and b/docs/screenshots/petri-analysis.png differ diff --git a/docs/screenshots/petri-counterexample.png b/docs/screenshots/petri-counterexample.png index c264a02..005f921 100644 Binary files a/docs/screenshots/petri-counterexample.png and b/docs/screenshots/petri-counterexample.png differ diff --git a/docs/screenshots/petri-definition.png b/docs/screenshots/petri-definition.png index 13acdef..1ea9c5c 100644 Binary files a/docs/screenshots/petri-definition.png and b/docs/screenshots/petri-definition.png differ diff --git a/docs/screenshots/petri-editing.png b/docs/screenshots/petri-editing.png index 436b371..95539e4 100644 Binary files a/docs/screenshots/petri-editing.png and b/docs/screenshots/petri-editing.png differ diff --git a/docs/screenshots/petri-footprint.png b/docs/screenshots/petri-footprint.png index ac6d692..fc33920 100644 Binary files a/docs/screenshots/petri-footprint.png and b/docs/screenshots/petri-footprint.png differ diff --git a/docs/screenshots/studio-alpha-dark.png b/docs/screenshots/studio-alpha-dark.png index db6ab38..19ebd16 100644 Binary files a/docs/screenshots/studio-alpha-dark.png and b/docs/screenshots/studio-alpha-dark.png differ diff --git a/docs/screenshots/studio-alpha-how.png b/docs/screenshots/studio-alpha-how.png new file mode 100644 index 0000000..1471017 Binary files /dev/null and b/docs/screenshots/studio-alpha-how.png differ diff --git a/docs/screenshots/studio-alpha.png b/docs/screenshots/studio-alpha.png index 267d881..c9026df 100644 Binary files a/docs/screenshots/studio-alpha.png and b/docs/screenshots/studio-alpha.png differ diff --git a/docs/screenshots/studio-conformance.png b/docs/screenshots/studio-conformance.png index 865de2f..e41feb5 100644 Binary files a/docs/screenshots/studio-conformance.png and b/docs/screenshots/studio-conformance.png differ diff --git a/docs/screenshots/studio-discover.png b/docs/screenshots/studio-discover.png index 536436d..1ceeb29 100644 Binary files a/docs/screenshots/studio-discover.png and b/docs/screenshots/studio-discover.png differ diff --git a/docs/screenshots/studio-dotted-chart.png b/docs/screenshots/studio-dotted-chart.png index 8dec902..af93b6e 100644 Binary files a/docs/screenshots/studio-dotted-chart.png and b/docs/screenshots/studio-dotted-chart.png differ diff --git a/docs/screenshots/studio-overview.png b/docs/screenshots/studio-overview.png index c6a5836..277b1c0 100644 Binary files a/docs/screenshots/studio-overview.png and b/docs/screenshots/studio-overview.png differ diff --git a/docs/screenshots/studio-process-map.png b/docs/screenshots/studio-process-map.png index e79aaaa..4f75c0c 100644 Binary files a/docs/screenshots/studio-process-map.png and b/docs/screenshots/studio-process-map.png differ diff --git a/docs/screenshots/studio-welcome.png b/docs/screenshots/studio-welcome.png index 6c9ac1f..65ff229 100644 Binary files a/docs/screenshots/studio-welcome.png and b/docs/screenshots/studio-welcome.png differ diff --git a/docs/screenshots/workflow-canvas.png b/docs/screenshots/workflow-canvas.png new file mode 100644 index 0000000..8e3b095 Binary files /dev/null and b/docs/screenshots/workflow-canvas.png differ diff --git a/docs/screenshots/workflow-summary.png b/docs/screenshots/workflow-summary.png new file mode 100644 index 0000000..56e8bf1 Binary files /dev/null and b/docs/screenshots/workflow-summary.png differ diff --git a/docs/screenshots/workspace.png b/docs/screenshots/workspace.png index b663899..660fb55 100644 Binary files a/docs/screenshots/workspace.png and b/docs/screenshots/workspace.png differ diff --git a/openprocess/flow/boxes/science.py b/openprocess/flow/boxes/science.py index bbaa0b3..686dbf6 100644 --- a/openprocess/flow/boxes/science.py +++ b/openprocess/flow/boxes/science.py @@ -110,11 +110,13 @@ def correlate(table: Table) -> Table: @box(name="Plot", group="Science", needs="matplotlib") def plot(scores: list[Scores] = (), tables: list[Table] = (), x: Column = "", y: Column = "") -> Figure: - """Bars per model for scores, histograms for bootstrap tables, or a line - of column *y* against column *x* of a table (a sweep). The figure is a + """Bars per model for scores, histograms for bootstrap tables, or column + *y* of a table against column *x*: bars when *x* holds names (a model per + bar), a line when it holds numbers (a sweep). Once *x* and *y* are set + they decide the figure, whatever else is connected. The figure is a matplotlib figure, converted to SVG; its code is this box's code. - x: a column of the table to put on the x axis (lines only) + x: a column of the table to put on the x axis y: the column to plot against it """ import matplotlib @@ -122,7 +124,24 @@ def plot(scores: list[Scores] = (), tables: list[Table] = (), import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(5.5, 3.2)) scores, tables = list(scores), list(tables) - if scores: + if tables and x and y: + for table in tables: + for name in (x, y): + if name not in table.columns: + raise ValueError(f"{table.name} has no column {name!r}; it has {', '.join(table.columns)}") + numeric = all(isinstance(v, (int, float)) and not isinstance(v, bool) for t in tables for v in t.column(x)) + for table in tables: + values = [float(v) for v in table.column(y)] + if numeric: + ax.plot(table.column(x), values, marker="o", label=table.name) + else: + ax.bar([str(v) for v in table.column(x)], values, label=table.name) + ax.set_xlabel(x) + ax.set_ylabel(y) + if len(tables) > 1: + ax.legend(fontsize=8) + caption = f"{y} against {x}." + elif scores: metrics = [m for m in dict.fromkeys(k for s in scores for k in s.metrics) if all(isinstance(s.metrics.get(m, 0.0), (int, float)) for s in scores)] width = 0.8 / max(1, len(metrics)) @@ -134,14 +153,6 @@ def plot(scores: list[Scores] = (), tables: list[Table] = (), ax.set_ylim(0, 1.05) ax.legend(fontsize=8) caption = f"{len(scores)} models, {len(metrics)} scores each." - elif tables and x and y: - for table in tables: - ax.plot(table.column(x), table.column(y), marker="o", label=table.name) - ax.set_xlabel(x) - ax.set_ylabel(y) - if len(tables) > 1: - ax.legend(fontsize=8) - caption = f"{y} against {x}." elif tables: for table in tables: if table.samples: @@ -150,7 +161,7 @@ def plot(scores: list[Scores] = (), tables: list[Table] = (), ax.legend(fontsize=8) caption = f"{len(tables)} bootstrap distribution{'s' if len(tables) != 1 else ''}." else: - raise ValueError("Connect scores, or tables with samples or an x and y column") + raise ValueError("Connect scores, or tables with samples, or a table and set both x and y") fig.tight_layout() figure = convert(fig, Figure) plt.close(fig) diff --git a/openprocess/gui/studio/definition_view.py b/openprocess/gui/studio/definition_view.py index cf860a1..dfb5192 100644 --- a/openprocess/gui/studio/definition_view.py +++ b/openprocess/gui/studio/definition_view.py @@ -138,10 +138,17 @@ def _show(self, definition: Definition, instance) -> None: self.browser.setHtml(definition_html(definition, instance)) self.back_button.setVisible(bool(self.history)) document = self.browser.document() - document.setTextWidth(WIDTH - 8) + # The frame (the stylesheet's padding, known once the widget is + # polished) takes its share of the width and the height; counted + # short, the card scrolled by a few pixels. The text wraps, so there + # is never anything to scroll sideways. + self.browser.ensurePolished() + frame = 2 * self.browser.frameWidth() + self.browser.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + document.setTextWidth(WIDTH - frame) screen = QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen() limit = int(screen.availableGeometry().height() * 0.7) if screen else 560 - self.browser.setFixedHeight(min(int(document.size().height()) + 12, limit)) + self.browser.setFixedHeight(min(int(document.size().height()) + frame, limit)) self.adjustSize() def _link(self, target: str) -> None: diff --git a/openprocess/gui/studio/graph_view.py b/openprocess/gui/studio/graph_view.py index 40d36de..2d5a017 100644 --- a/openprocess/gui/studio/graph_view.py +++ b/openprocess/gui/studio/graph_view.py @@ -607,6 +607,15 @@ def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802 if len(points) < 20000: painter.drawPoints(points) + def sizeHint(self) -> QSize: # noqa: N802 + # QGraphicsView asks for the drawing's own size (up to three quarters + # of the screen), and a page laid out by height for width (the + # Summary's row of tiles) grants it: a process map then towered over + # the window. A view is a window on the graph, which fits itself to + # whatever it gets, so ask for a modest one and let the page's layout + # give it the room the window has. + return QSize(480, 320) + def fit(self) -> None: self.auto_fit = True rect = self.graph.sceneRect() diff --git a/openprocess/gui/studio/markdown_view.py b/openprocess/gui/studio/markdown_view.py index 756c2aa..7a7b885 100644 --- a/openprocess/gui/studio/markdown_view.py +++ b/openprocess/gui/studio/markdown_view.py @@ -23,7 +23,9 @@ # Markdown with maths # --------------------------------------------------------------------------- _DISPLAY = re.compile(r"\$\$(.+?)\$\$", re.S) -_INLINE = re.compile(r"(? str: diff --git a/openprocess/gui/studio/style.py b/openprocess/gui/studio/style.py index 19e5327..d985da8 100644 --- a/openprocess/gui/studio/style.py +++ b/openprocess/gui/studio/style.py @@ -521,6 +521,8 @@ def stylesheet() -> str: margin: 6px 5px; border-radius: 2px; }} QGraphicsView {{ background: {t.canvas}; border: none; }} QStatusBar {{ background: {t.page}; color: {t.text_muted}; border-top: 1px solid {t.border}; }} + QMenuBar {{ background: {t.page}; color: {t.text}; }} + QMenuBar::item:selected {{ background: {t.accent_soft}; color: {t.text}; border-radius: 4px; }} /* Pop-up menus: rounded like the cards (see widgets.round_menus, which gives them the see-through window the corners need). */ QMenu {{ background: {t.surface}; border: 1px solid {t.border}; border-radius: 10px; diff --git a/packaging/openprocess_studio.py b/packaging/openprocess_studio.py index 6d0ae96..adc48db 100644 --- a/packaging/openprocess_studio.py +++ b/packaging/openprocess_studio.py @@ -83,7 +83,10 @@ def self_test(model_path: str | None = None) -> int: print(f"install: the app cannot run pip (exit {pip.returncode}): {pip.stderr.strip()[-400:]}", flush=True) return 1 print(f"install: {pip.stdout.strip()}", flush=True) + # Windows has no curses or readline, and it ships pty.py and tty.py, which + # want termios: those fail in any Python on Windows, not just in the app. tolerated = {"curses", "readline"} if sys.platform == "win32" else set() + posix_only = {"termios", "fcntl", "grp", "pwd", "posix", "resource", "syslog"} missing = [] checked = 0 for name in packages.stdlib_modules(): @@ -92,6 +95,10 @@ def self_test(model_path: str | None = None) -> int: checked += 1 try: importlib.import_module(name) + except ModuleNotFoundError as error: + if sys.platform == "win32" and error.name in posix_only: + continue + missing.append(f"{name} ({type(error).__name__}: {error})") except Exception as error: # noqa: BLE001 - whatever stops the import missing.append(f"{name} ({type(error).__name__}: {error})") if missing: diff --git a/tests/test_exercises_gui.py b/tests/test_exercises_gui.py index d8bf6e7..450367d 100644 --- a/tests/test_exercises_gui.py +++ b/tests/test_exercises_gui.py @@ -422,6 +422,12 @@ def test_question_markdown_has_maths_and_tables(): "$$\\bigcap_{R} R$$\n\nand $\\frobnicate$ stays as written.") assert "