openprocess 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,124 @@
1
+ """The result of comparing two nets on behaviour (Compare nets…, and Check in
2
+ an exercise)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from html import escape
7
+
8
+ from PySide6.QtCore import Qt
9
+ from PySide6.QtWidgets import QComboBox, QSizePolicy, QTableWidget, QVBoxLayout, QWidget
10
+
11
+ from .widgets import Verdict, button, hbox, label, status_for
12
+
13
+
14
+ def trace_text(trace) -> str:
15
+ return "⟨" + ", ".join(trace) + "⟩"
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # The result of comparing two nets
20
+ # ---------------------------------------------------------------------------
21
+ class NetComparisonView(QWidget):
22
+ """Same behaviour or not, the shortest differing traces, and a label mapping.
23
+
24
+ ``replay(trace, in_first)`` replays a trace on the first net (yours);
25
+ ``recompare(mapping)`` compares again with labels of the first net
26
+ renamed to labels of the second.
27
+ """
28
+
29
+ def __init__(self, comparison, first: str = "yours", second: str = "the answer",
30
+ replay=None, recompare=None, soundness=None, parent=None) -> None:
31
+ super().__init__(parent)
32
+ layout = QVBoxLayout(self)
33
+ layout.setContentsMargins(0, 0, 0, 0)
34
+ layout.setSpacing(8)
35
+ self.comparison = comparison
36
+ same = comparison.equivalent
37
+ scope = "" if comparison.exact else \
38
+ f" Traces of up to {comparison.max_length} steps were compared (the nets are " \
39
+ "unbounded or very large), so this is not a proof."
40
+ if same:
41
+ layout.addWidget(Verdict("Same behaviour", "good",
42
+ f"{first.capitalize()} and {second} allow exactly the "
43
+ "same complete traces." + scope))
44
+ else:
45
+ layout.addWidget(Verdict(f"Differs from {second}", "warning",
46
+ f"{first.capitalize()} and {second} do not allow the same "
47
+ "complete traces. A different model can still be a "
48
+ "correct reading of the question." + scope))
49
+ self.replay_buttons: list = []
50
+
51
+ def row(text: str, trace, in_first: bool) -> None:
52
+ line = hbox(spacing=6)
53
+ line.addWidget(label(f"<span>{text}</span>", "muted", wrap=True, selectable=True), 1)
54
+ if replay is not None:
55
+ show = button("Replay ▶", lambda _=False, t=trace, f=in_first: replay(t, f),
56
+ kind="ghost", tooltip="Fire this trace on "
57
+ f"{first} in the token game, as far as it goes")
58
+ show.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
59
+ self.replay_buttons.append(show)
60
+ line.addWidget(show, 0, Qt.AlignTop)
61
+ holder = QWidget()
62
+ holder.setLayout(line)
63
+ layout.addWidget(holder)
64
+
65
+ for trace in comparison.only_second:
66
+ row(f"{escape(second.capitalize())} allows <b>{escape(trace_text(trace))}</b>, "
67
+ f"{escape(first)} does not.", trace, False)
68
+ for trace in comparison.only_first:
69
+ row(f"{escape(first.capitalize())} allows <b>{escape(trace_text(trace))}</b>, "
70
+ f"{escape(second)} does not.", trace, True)
71
+ if comparison.labels_only_first or comparison.labels_only_second:
72
+ layout.addWidget(label(
73
+ "Labels only in " + first + ": " + (", ".join(comparison.labels_only_first)
74
+ or "–")
75
+ + ". Only in " + second + ": " + (", ".join(comparison.labels_only_second)
76
+ or "–") + ".", "muted", wrap=True))
77
+ if recompare is not None and comparison.labels_only_first and \
78
+ comparison.labels_only_second:
79
+ layout.addWidget(self._mapping_table(comparison, first, second, recompare))
80
+ for note in comparison.notes:
81
+ layout.addWidget(label(note, "muted", wrap=True))
82
+ if soundness is not None:
83
+ verdict = soundness.sound
84
+ if not soundness.workflow.is_workflow_net:
85
+ layout.addWidget(Verdict("Not a WF-net", "info", "; ".join(
86
+ soundness.workflow.problems[:2]), definition="wf_net"))
87
+ else:
88
+ layout.addWidget(Verdict(
89
+ f"{first.capitalize()}: " + ("sound" if verdict else "not sound"
90
+ if verdict is False else "soundness undecided"),
91
+ status_for(verdict), soundness.findings[0] if soundness.findings else
92
+ "every case can complete properly and every transition can fire",
93
+ definition="sound"))
94
+
95
+ def _mapping_table(self, comparison, first: str, second: str, recompare) -> QWidget:
96
+ """Match labels that differ (``register`` vs ``Register request``) by hand."""
97
+ host = QWidget()
98
+ layout = QVBoxLayout(host)
99
+ layout.setContentsMargins(0, 0, 0, 0)
100
+ layout.addWidget(label(f"Match the labels of {first} to those of {second}:", wrap=True))
101
+ table = QTableWidget(len(comparison.labels_only_first), 2)
102
+ table.setHorizontalHeaderLabels([first.capitalize(), second.capitalize()])
103
+ table.verticalHeader().setVisible(False)
104
+ boxes = []
105
+ for row, mine in enumerate(comparison.labels_only_first):
106
+ from PySide6.QtWidgets import QTableWidgetItem
107
+ item = QTableWidgetItem(mine)
108
+ table.setItem(row, 0, item)
109
+ box = QComboBox()
110
+ box.addItem("(no match)", None)
111
+ for theirs in comparison.labels_only_second:
112
+ box.addItem(theirs, theirs)
113
+ table.setCellWidget(row, 1, box)
114
+ boxes.append((mine, box))
115
+ table.horizontalHeader().setStretchLastSection(True)
116
+ table.setMinimumHeight(min(60 + 30 * len(boxes), 220))
117
+ layout.addWidget(table)
118
+ self.mapping_boxes = boxes
119
+ layout.addWidget(button("Compare again", lambda: recompare(
120
+ {mine: box.currentData() for mine, box in boxes if box.currentData()}),
121
+ kind="primary"))
122
+ return host
123
+
124
+
@@ -0,0 +1,275 @@
1
+ """Notes: scratch paper that floats over whatever page is open.
2
+
3
+ Learn has its own Notes pane (see :mod:`openprocess.gui.learn.mode`); this is the
4
+ same idea for the rest of the app::
5
+
6
+ +--------------------------------------------------------------+
7
+ | the page: a log, a net, the welcome page … |
8
+ | |
9
+ | +- NOTES ---------------- ⤢ – + |
10
+ | | Work things out here … | |
11
+ | | | |
12
+ | +-------------------------------+ [− 100% + Fit]|
13
+ +--------------------------------------------------------------+
14
+ | Ready [✎ Notes] |
15
+ +--------------------------------------------------------------+
16
+
17
+ * The **✎ Notes** button lives in the status bar (the window puts it there),
18
+ so nothing floats over the page while the notes are folded away.
19
+ * Open, it is a card with the notes, in the bottom-left corner of the page
20
+ (the canvases keep their zoom bar in the bottom-right one); **⤢** makes it
21
+ larger and **–** folds it away. Drag its header to move it out of the
22
+ way, and the grip in its bottom-right corner to resize it.
23
+
24
+ The window decides where the text is kept (with the folder that is open, or
25
+ in the app's settings): :class:`NotesOverlay` only reports edits, a moment
26
+ after the last keystroke.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from PySide6.QtCore import QEvent, QObject, QPoint, QRect, Qt, QTimer, Signal
32
+ from PySide6.QtGui import QColor, QPainter, QPen
33
+ from PySide6.QtWidgets import (
34
+ QFrame, QGraphicsDropShadowEffect, QHBoxLayout, QPlainTextEdit, QToolButton, QVBoxLayout,
35
+ QWidget,
36
+ )
37
+
38
+ from . import style
39
+ from .widgets import label
40
+
41
+ #: How long after the last keystroke the notes are saved (milliseconds).
42
+ SAVE_DELAY = 400
43
+ #: Space between the overlay and the edges of the page.
44
+ MARGIN = 14
45
+ #: The card's size, open and enlarged (enlarged: at most this much of the page).
46
+ OPEN_SIZE = (380, 300)
47
+ EXPANDED_FRACTION = (0.6, 0.75)
48
+ #: The smallest the card can be made.
49
+ MIN_SIZE = (220, 140)
50
+
51
+
52
+ def _tool(text: str, tooltip: str, slot) -> QToolButton:
53
+ tool = QToolButton()
54
+ tool.setObjectName("notesOverlayButton")
55
+ tool.setText(text)
56
+ tool.setToolTip(tooltip)
57
+ tool.setCursor(Qt.PointingHandCursor)
58
+ tool.setFocusPolicy(Qt.NoFocus)
59
+ tool.clicked.connect(slot)
60
+ return tool
61
+
62
+
63
+ class _Header(QFrame):
64
+ """The card's title row, which also moves the card when dragged."""
65
+
66
+ dragged = Signal(QPoint) # how far, since the last move
67
+
68
+ def __init__(self, cursor=Qt.OpenHandCursor) -> None:
69
+ super().__init__()
70
+ self._cursor = cursor
71
+ self.setCursor(cursor)
72
+ self._last: QPoint | None = None
73
+
74
+ def mousePressEvent(self, event) -> None: # noqa: N802
75
+ if event.button() == Qt.LeftButton:
76
+ self._last = event.globalPosition().toPoint()
77
+ if self._cursor == Qt.OpenHandCursor:
78
+ self.setCursor(Qt.ClosedHandCursor)
79
+ super().mousePressEvent(event)
80
+
81
+ def mouseMoveEvent(self, event) -> None: # noqa: N802
82
+ if self._last is not None:
83
+ now = event.globalPosition().toPoint()
84
+ self.dragged.emit(now - self._last)
85
+ self._last = now
86
+ super().mouseMoveEvent(event)
87
+
88
+ def mouseReleaseEvent(self, event) -> None: # noqa: N802
89
+ self._last = None
90
+ self.setCursor(self._cursor)
91
+ super().mouseReleaseEvent(event)
92
+
93
+
94
+ class _Grip(_Header):
95
+ """The corner that resizes the card, drawn as a few diagonal strokes."""
96
+
97
+ SIZE = 14
98
+
99
+ def __init__(self, card: QFrame) -> None:
100
+ super().__init__(Qt.SizeFDiagCursor)
101
+ self.setParent(card)
102
+ self.setObjectName("notesOverlayGrip")
103
+ self.setFixedSize(self.SIZE, self.SIZE)
104
+ self.setToolTip("Drag to resize")
105
+ card.installEventFilter(self)
106
+
107
+ def eventFilter(self, watched, event) -> bool: # noqa: N802
108
+ if event.type() == QEvent.Resize:
109
+ self.move(watched.width() - self.SIZE - 2, watched.height() - self.SIZE - 2)
110
+ self.raise_()
111
+ return False
112
+
113
+ def paintEvent(self, event) -> None: # noqa: N802
114
+ painter = QPainter(self)
115
+ painter.setRenderHint(QPainter.Antialiasing)
116
+ painter.setPen(QPen(QColor(style.tokens().text_muted), 1.2))
117
+ s = self.SIZE - 3
118
+ for offset in (4, 8):
119
+ painter.drawLine(s, s - offset, s - offset, s)
120
+
121
+
122
+ class NotesOverlay(QObject):
123
+ """The notes card, floating over ``host`` (see the module docstring)."""
124
+
125
+ #: The text, a moment after it was edited (see :meth:`flush`).
126
+ edited = Signal(str)
127
+ #: The card was opened or folded away (the ✎ Notes button follows it).
128
+ open_changed = Signal(bool)
129
+
130
+ def __init__(self, host: QWidget) -> None:
131
+ super().__init__(host)
132
+ self.host = host
133
+ self.expanded = False
134
+ #: Where the card was dragged to (its top-left corner); None: the corner.
135
+ self._moved_to: QPoint | None = None
136
+ #: The size the card was resized to with its grip; None: the usual size.
137
+ self._resized_to: tuple[int, int] | None = None
138
+
139
+ self.card = QFrame(host)
140
+ self.card.setObjectName("notesOverlay")
141
+ shadow = QGraphicsDropShadowEffect(self.card)
142
+ shadow.setBlurRadius(28)
143
+ shadow.setOffset(0, 6)
144
+ shadow.setColor(QColor(0, 0, 0, 70))
145
+ self.card.setGraphicsEffect(shadow)
146
+ column = QVBoxLayout(self.card)
147
+ column.setContentsMargins(12, 6, 8, 12)
148
+ column.setSpacing(4)
149
+ header = _Header()
150
+ header.dragged.connect(self._drag)
151
+ row = QHBoxLayout(header)
152
+ row.setContentsMargins(4, 0, 0, 0)
153
+ row.setSpacing(2)
154
+ row.addWidget(label("NOTES", "taskCaption"))
155
+ row.addStretch(1)
156
+ self.expand_button = _tool("⤢", "Larger", lambda: self.set_expanded(not self.expanded))
157
+ row.addWidget(self.expand_button)
158
+ row.addWidget(_tool("–", "Fold the notes away (they are kept)",
159
+ lambda: self.set_open(False)))
160
+ column.addWidget(header)
161
+ self.editor = QPlainTextEdit()
162
+ self.editor.setObjectName("notesEdit")
163
+ self.editor.setPlaceholderText("Jot things down while you work: markings, firing "
164
+ "sequences, traces to try, questions…")
165
+ column.addWidget(self.editor, 1)
166
+ grip = _Grip(self.card)
167
+ grip.dragged.connect(self._resize)
168
+ self.grip = grip
169
+ self._timer = QTimer(self)
170
+ self._timer.setSingleShot(True)
171
+ self._timer.setInterval(SAVE_DELAY)
172
+ self._timer.timeout.connect(self.flush)
173
+ self.editor.textChanged.connect(self._timer.start)
174
+ self.card.hide()
175
+
176
+ # Follow the page's size, and stay above pages added later.
177
+ host.installEventFilter(self)
178
+ self._place()
179
+
180
+ # -- the text ---------------------------------------------------------------------
181
+ def text(self) -> str:
182
+ return self.editor.toPlainText()
183
+
184
+ def set_text(self, text: str) -> None:
185
+ """Show other notes (another folder's): not an edit, so nothing is saved."""
186
+ self.flush()
187
+ self.editor.blockSignals(True)
188
+ self.editor.setPlainText(text)
189
+ self.editor.blockSignals(False)
190
+ self._timer.stop()
191
+
192
+ def flush(self) -> None:
193
+ """Report an edit not reported yet (before switching folders, or quitting)."""
194
+ if self._timer.isActive() or self.sender() is self._timer:
195
+ self._timer.stop()
196
+ self.edited.emit(self.text())
197
+
198
+ # -- open, folded, enlarged ---------------------------------------------------------------
199
+ @property
200
+ def is_open(self) -> bool:
201
+ return not self.card.isHidden()
202
+
203
+ def set_open(self, show: bool) -> None:
204
+ if show == self.is_open:
205
+ if show:
206
+ self.editor.setFocus()
207
+ return
208
+ self.card.setVisible(show)
209
+ self._place()
210
+ if show:
211
+ self.editor.setFocus()
212
+ else:
213
+ self.flush()
214
+ self.open_changed.emit(show)
215
+
216
+ def set_expanded(self, expanded: bool) -> None:
217
+ self.expanded = expanded
218
+ self._resized_to = None # ⤢ / ⤡ go back to a standard size
219
+ self.expand_button.setText("⤡" if expanded else "⤢")
220
+ self.expand_button.setToolTip("Smaller" if expanded else "Larger")
221
+ self._place()
222
+
223
+ # -- where it goes ---------------------------------------------------------------------------
224
+ def _card_size(self) -> tuple[int, int]:
225
+ width, height = self.host.width() - 2 * MARGIN, self.host.height() - 2 * MARGIN
226
+ if self._resized_to is not None:
227
+ wanted = self._resized_to
228
+ elif self.expanded:
229
+ wanted = (max(OPEN_SIZE[0], int(self.host.width() * EXPANDED_FRACTION[0])),
230
+ max(OPEN_SIZE[1], int(self.host.height() * EXPANDED_FRACTION[1])))
231
+ else:
232
+ wanted = OPEN_SIZE
233
+ return max(MIN_SIZE[0], min(wanted[0], width)), max(MIN_SIZE[1], min(wanted[1], height))
234
+
235
+ def _place(self) -> None:
236
+ host = self.host.rect()
237
+ if not self.is_open:
238
+ return
239
+ width, height = self._card_size()
240
+ if self._moved_to is None:
241
+ corner = QPoint(MARGIN, host.height() - height - MARGIN)
242
+ else:
243
+ corner = self._moved_to
244
+ self.card.setGeometry(self._inside(QRect(corner.x(), corner.y(), width, height)))
245
+ self.card.raise_()
246
+
247
+ def _inside(self, rect: QRect) -> QRect:
248
+ """``rect`` moved (not resized) so that it stays on the page."""
249
+ host = self.host.rect()
250
+ x = min(max(rect.x(), 0), max(0, host.width() - rect.width()))
251
+ y = min(max(rect.y(), 0), max(0, host.height() - rect.height()))
252
+ return QRect(x, y, rect.width(), rect.height())
253
+
254
+ def _drag(self, delta: QPoint) -> None:
255
+ rect = self._inside(self.card.geometry().translated(delta))
256
+ self._moved_to = rect.topLeft()
257
+ self.card.setGeometry(rect)
258
+
259
+ def _resize(self, delta: QPoint) -> None:
260
+ """The grip in the bottom-right corner: the top-left corner stays put."""
261
+ rect = self.card.geometry()
262
+ host = self.host.rect()
263
+ width = min(max(MIN_SIZE[0], rect.width() + delta.x()), host.width() - rect.x())
264
+ height = min(max(MIN_SIZE[1], rect.height() + delta.y()), host.height() - rect.y())
265
+ self._resized_to = (width, height)
266
+ self._moved_to = rect.topLeft()
267
+ self.card.setGeometry(rect.x(), rect.y(), width, height)
268
+
269
+ def eventFilter(self, watched, event) -> bool: # noqa: N802
270
+ if watched is self.host and event.type() in (QEvent.Resize, QEvent.ChildAdded):
271
+ QTimer.singleShot(0, self._place)
272
+ return False
273
+
274
+
275
+ __all__ = ["NotesOverlay"]