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.
- cpnpy/__init__.py +46 -0
- openprocess/__init__.py +57 -0
- openprocess/analysis/__init__.py +0 -0
- openprocess/analysis/state_space.py +521 -0
- openprocess/analysis/state_space_process.py +251 -0
- openprocess/cli.py +742 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
- openprocess/exercises/pack.md +14 -0
- openprocess/flow/__init__.py +50 -0
- openprocess/flow/box.py +466 -0
- openprocess/flow/boxes/__init__.py +7 -0
- openprocess/flow/boxes/check.py +119 -0
- openprocess/flow/boxes/compare.py +16 -0
- openprocess/flow/boxes/cpn.py +53 -0
- openprocess/flow/boxes/discover.py +124 -0
- openprocess/flow/boxes/filter.py +80 -0
- openprocess/flow/boxes/input.py +124 -0
- openprocess/flow/boxes/output.py +52 -0
- openprocess/flow/boxes/predict.py +186 -0
- openprocess/flow/boxes/science.py +159 -0
- openprocess/flow/boxes/sweeps.py +18 -0
- openprocess/flow/convert.py +187 -0
- openprocess/flow/datasets.py +198 -0
- openprocess/flow/explain.py +115 -0
- openprocess/flow/library.py +222 -0
- openprocess/flow/record.py +385 -0
- openprocess/flow/runner.py +357 -0
- openprocess/flow/sweep.py +92 -0
- openprocess/flow/types.py +290 -0
- openprocess/flow/workflow.py +628 -0
- openprocess/gui/__init__.py +0 -0
- openprocess/gui/app.py +90 -0
- openprocess/gui/arc_editing.py +295 -0
- openprocess/gui/canvas.py +1414 -0
- openprocess/gui/flow/__init__.py +8 -0
- openprocess/gui/flow/canvas.py +854 -0
- openprocess/gui/flow/page.py +972 -0
- openprocess/gui/flow/templates.py +131 -0
- openprocess/gui/flow/viewers.py +665 -0
- openprocess/gui/items.py +1275 -0
- openprocess/gui/learn/answer_boxes.py +978 -0
- openprocess/gui/learn/concealment.py +91 -0
- openprocess/gui/learn/mode.py +1181 -0
- openprocess/gui/panning.py +241 -0
- openprocess/gui/resources/openprocess-icon.png +0 -0
- openprocess/gui/studio/__init__.py +1 -0
- openprocess/gui/studio/__main__.py +3 -0
- openprocess/gui/studio/app.py +4031 -0
- openprocess/gui/studio/charts.py +115 -0
- openprocess/gui/studio/compare_page.py +487 -0
- openprocess/gui/studio/cpn_page.py +1858 -0
- openprocess/gui/studio/definition_view.py +284 -0
- openprocess/gui/studio/derivation_view.py +421 -0
- openprocess/gui/studio/documents.py +152 -0
- openprocess/gui/studio/dotted_chart.py +1401 -0
- openprocess/gui/studio/file_dialogs.py +143 -0
- openprocess/gui/studio/filter_dialog.py +247 -0
- openprocess/gui/studio/graph_builders.py +176 -0
- openprocess/gui/studio/graph_view.py +682 -0
- openprocess/gui/studio/instances.py +413 -0
- openprocess/gui/studio/log_editor.py +675 -0
- openprocess/gui/studio/log_page.py +800 -0
- openprocess/gui/studio/markdown_view.py +127 -0
- openprocess/gui/studio/mathtext.py +260 -0
- openprocess/gui/studio/ml_highlighter.py +75 -0
- openprocess/gui/studio/model_page.py +760 -0
- openprocess/gui/studio/net_comparison.py +124 -0
- openprocess/gui/studio/notes_overlay.py +275 -0
- openprocess/gui/studio/petri_page.py +844 -0
- openprocess/gui/studio/regions_view.py +502 -0
- openprocess/gui/studio/sidebar.py +149 -0
- openprocess/gui/studio/style.py +503 -0
- openprocess/gui/studio/tool_icons.py +134 -0
- openprocess/gui/studio/updates.py +439 -0
- openprocess/gui/studio/widgets.py +899 -0
- openprocess/gui/studio/workers.py +60 -0
- openprocess/gui/studio/workspace.py +447 -0
- openprocess/gui/theme.py +394 -0
- openprocess/gui/tidy.py +86 -0
- openprocess/io/__init__.py +0 -0
- openprocess/io/cpn_reader.py +389 -0
- openprocess/io/cpn_writer.py +357 -0
- openprocess/learn/__init__.py +23 -0
- openprocess/learn/answers.py +188 -0
- openprocess/learn/checks.py +953 -0
- openprocess/learn/computed.py +1180 -0
- openprocess/learn/context.py +145 -0
- openprocess/learn/exam.py +169 -0
- openprocess/learn/exercise-packs.md +325 -0
- openprocess/learn/importer.py +216 -0
- openprocess/learn/notation.py +474 -0
- openprocess/learn/pack.py +511 -0
- openprocess/learn/sheet.py +296 -0
- openprocess/mining/__init__.py +73 -0
- openprocess/mining/analysis.py +689 -0
- openprocess/mining/columns.py +282 -0
- openprocess/mining/compare_nets.py +246 -0
- openprocess/mining/conformance/__init__.py +0 -0
- openprocess/mining/conformance/alignments.py +263 -0
- openprocess/mining/conformance/quality.py +145 -0
- openprocess/mining/conformance/token_replay.py +252 -0
- openprocess/mining/csv_import.py +222 -0
- openprocess/mining/definitions.py +584 -0
- openprocess/mining/dfg.py +187 -0
- openprocess/mining/discovery/__init__.py +0 -0
- openprocess/mining/discovery/alpha.py +168 -0
- openprocess/mining/discovery/heuristics.py +332 -0
- openprocess/mining/discovery/inductive.py +477 -0
- openprocess/mining/discovery/state_regions.py +62 -0
- openprocess/mining/filtering.py +237 -0
- openprocess/mining/footprint.py +183 -0
- openprocess/mining/invariants.py +191 -0
- openprocess/mining/layout.py +279 -0
- openprocess/mining/log.py +364 -0
- openprocess/mining/petrinet.py +354 -0
- openprocess/mining/playout.py +75 -0
- openprocess/mining/pm4py_bridge.py +82 -0
- openprocess/mining/pnml.py +223 -0
- openprocess/mining/processtree.py +216 -0
- openprocess/mining/regions.py +476 -0
- openprocess/mining/stats.py +160 -0
- openprocess/mining/structure.py +374 -0
- openprocess/mining/transition_system.py +409 -0
- openprocess/mining/xes.py +399 -0
- openprocess/ml/__init__.py +0 -0
- openprocess/ml/ast_nodes.py +332 -0
- openprocess/ml/builtins.py +364 -0
- openprocess/ml/colorsets.py +522 -0
- openprocess/ml/errors.py +60 -0
- openprocess/ml/evaluator.py +754 -0
- openprocess/ml/lexer.py +277 -0
- openprocess/ml/multiset.py +417 -0
- openprocess/ml/parser.py +737 -0
- openprocess/ml/values.py +319 -0
- openprocess/model/__init__.py +0 -0
- openprocess/model/declarations.py +617 -0
- openprocess/model/examples.py +98 -0
- openprocess/model/net.py +701 -0
- openprocess/model/plain.py +192 -0
- openprocess/references.py +280 -0
- openprocess/sim/__init__.py +0 -0
- openprocess/sim/binding.py +620 -0
- openprocess/sim/export.py +66 -0
- openprocess/sim/simulator.py +315 -0
- openprocess/teaching/__init__.py +4 -0
- openprocess/teaching/answers.py +4 -0
- openprocess/teaching/checks.py +5 -0
- openprocess/teaching/pack.py +4 -0
- openprocess/teaching/sheet.py +4 -0
- openprocess-0.7.0.dist-info/METADATA +927 -0
- openprocess-0.7.0.dist-info/RECORD +173 -0
- openprocess-0.7.0.dist-info/WHEEL +5 -0
- openprocess-0.7.0.dist-info/entry_points.txt +6 -0
- openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
- openprocess-0.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Transition systems and their regions on screen.
|
|
2
|
+
|
|
3
|
+
:class:`RegionsPanel` shows one transition system with everything region
|
|
4
|
+
theory says about it, in the order an exam question asks:
|
|
5
|
+
|
|
6
|
+
1. the **transition system** itself, drawn with the state-graph canvas
|
|
7
|
+
(states labelled by their abstraction, e.g. ``{a,d}``), with a menu to
|
|
8
|
+
highlight the states one trace of the log passes through;
|
|
9
|
+
2. **Is this a region?** -- type a set of states, or click states on the
|
|
10
|
+
drawing, and get yes or no; no names the event that breaks it and the two
|
|
11
|
+
transitions that cross the set differently;
|
|
12
|
+
3. **Elementary?** -- state separation and forward closure, with the states
|
|
13
|
+
or the event that break them;
|
|
14
|
+
4. **Events** -- for each event its GER, minimal pre-regions, the
|
|
15
|
+
intersection of its pre-regions and its minimal post-regions (the table
|
|
16
|
+
exam answers are written in);
|
|
17
|
+
5. the **regions**, the minimal ones numbered r1, r2, … as the places of
|
|
18
|
+
6. the **synthesised net**, and whether its reachability graph is
|
|
19
|
+
isomorphic to the transition system.
|
|
20
|
+
|
|
21
|
+
It is used in the Discover tab (*State-based regions*), in a discovered
|
|
22
|
+
model's Details, and on the page of a typed transition system
|
|
23
|
+
(:class:`TransitionSystemPage`). Every property has its definition on hover
|
|
24
|
+
(:mod:`.definition_view`), filled in for this transition system
|
|
25
|
+
(:func:`.instances.regions`).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from PySide6.QtCore import QTimer, Qt, Signal
|
|
33
|
+
from PySide6.QtWidgets import (
|
|
34
|
+
QAbstractItemView, QComboBox, QFileDialog, QHeaderView, QLineEdit, QPlainTextEdit,
|
|
35
|
+
QSizePolicy, QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from ...mining.discovery.state_regions import RegionResult, region_result
|
|
39
|
+
from ...mining.regions import check_region, format_states
|
|
40
|
+
from ...mining.transition_system import (
|
|
41
|
+
TransitionSystem, parse_states, parse_transition_system,
|
|
42
|
+
)
|
|
43
|
+
from . import instances, style
|
|
44
|
+
from ..learn.concealment import ConcealsResults
|
|
45
|
+
from .definition_view import attach_definition
|
|
46
|
+
from .graph_view import EdgeSpec, GraphView, NodeSpec
|
|
47
|
+
from .widgets import Card, PageHeader, Verdict, button, hbox, label, scroll, status_for, \
|
|
48
|
+
suggested_path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Drawing a transition system
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
def ts_specs(ts: TransitionSystem) -> tuple[list[NodeSpec], list[EdgeSpec], dict[str, str]]:
|
|
55
|
+
"""Nodes and edges for the canvas, and the node id of each state."""
|
|
56
|
+
t = style.tokens()
|
|
57
|
+
ids = {state: f"s{number}" for number, state in enumerate(ts.states)}
|
|
58
|
+
nodes = []
|
|
59
|
+
for state in ts.states:
|
|
60
|
+
roles = []
|
|
61
|
+
if state in ts.initial:
|
|
62
|
+
roles.append("initial")
|
|
63
|
+
if state in ts.final:
|
|
64
|
+
roles.append("final")
|
|
65
|
+
nodes.append(NodeSpec(ids[state], "state", text=state,
|
|
66
|
+
tooltip=f"State {state}" + (f" ({', '.join(roles)})" if roles
|
|
67
|
+
else "") + "\nClick to add it to "
|
|
68
|
+
"the set to check", fill=t.accent_soft if state in ts.initial
|
|
69
|
+
else None, emphasis=state in ts.final))
|
|
70
|
+
labels: dict[tuple[str, str], list[str]] = {}
|
|
71
|
+
for source, event, target in ts.transitions:
|
|
72
|
+
labels.setdefault((ids[source], ids[target]), []).append(event)
|
|
73
|
+
edges = [EdgeSpec(s, d, text=", ".join(events), width=1.3, tooltip=", ".join(events))
|
|
74
|
+
for (s, d), events in labels.items()]
|
|
75
|
+
return nodes, edges, ids
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TransitionSystemView(QWidget):
|
|
79
|
+
"""The drawing, a trace highlighter, and click-to-select states."""
|
|
80
|
+
|
|
81
|
+
#: The set of selected states changed (by clicking).
|
|
82
|
+
selection_changed = Signal(list)
|
|
83
|
+
|
|
84
|
+
def __init__(self, ts: TransitionSystem, parent=None) -> None:
|
|
85
|
+
super().__init__(parent)
|
|
86
|
+
self.ts = ts
|
|
87
|
+
layout = QVBoxLayout(self)
|
|
88
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
89
|
+
layout.setSpacing(6)
|
|
90
|
+
self.view = GraphView()
|
|
91
|
+
self.view.setMinimumHeight(260)
|
|
92
|
+
self.nodes, edges, self.ids = ts_specs(ts)
|
|
93
|
+
self.state_of = {node_id: state for state, node_id in self.ids.items()}
|
|
94
|
+
self.view.graph.populate(self.nodes, edges, layer_gap=90)
|
|
95
|
+
self.view.graph.node_clicked.connect(self._clicked)
|
|
96
|
+
self.selected: list[str] = []
|
|
97
|
+
self.trace_box = QComboBox()
|
|
98
|
+
self.trace_box.addItem("Highlight a trace…", None)
|
|
99
|
+
for sequence, count, walk in ts.traces:
|
|
100
|
+
text = "⟨" + ", ".join(sequence) + "⟩" + (f" ×{count}" if count > 1 else "")
|
|
101
|
+
self.trace_box.addItem(text, walk)
|
|
102
|
+
self.trace_box.currentIndexChanged.connect(lambda _i: self.repaint_states())
|
|
103
|
+
self.trace_box.setVisible(bool(ts.traces))
|
|
104
|
+
self.trace_box.setToolTip("Light up the states and transitions one trace passes "
|
|
105
|
+
"through")
|
|
106
|
+
self.trace_info = label("", "muted", wrap=True)
|
|
107
|
+
layout.addWidget(self.view, 1)
|
|
108
|
+
layout.addLayout(hbox(self.trace_box, self.trace_info, stretch_last=True))
|
|
109
|
+
QTimer.singleShot(0, self.view.fit)
|
|
110
|
+
|
|
111
|
+
def _clicked(self, node_id: str) -> None:
|
|
112
|
+
state = self.state_of.get(node_id)
|
|
113
|
+
if state is None:
|
|
114
|
+
return
|
|
115
|
+
if state in self.selected:
|
|
116
|
+
self.selected.remove(state)
|
|
117
|
+
else:
|
|
118
|
+
self.selected.append(state)
|
|
119
|
+
self.repaint_states()
|
|
120
|
+
self.selection_changed.emit(list(self.selected))
|
|
121
|
+
|
|
122
|
+
def set_selected(self, states: list[str]) -> None:
|
|
123
|
+
self.selected = [s for s in states if s in self.ids]
|
|
124
|
+
self.repaint_states()
|
|
125
|
+
|
|
126
|
+
def repaint_states(self) -> None:
|
|
127
|
+
t = style.tokens()
|
|
128
|
+
walk = self.trace_box.currentData() or []
|
|
129
|
+
on_path = set(walk)
|
|
130
|
+
steps = {(self.ids[a], self.ids[b]) for a, b in zip(walk, walk[1:])}
|
|
131
|
+
trace_colour = style.categorical(1)
|
|
132
|
+
selected_fill = style.categorical(0)
|
|
133
|
+
for spec in self.nodes:
|
|
134
|
+
state = self.state_of[spec.id]
|
|
135
|
+
fill = selected_fill if state in self.selected else (
|
|
136
|
+
t.accent_soft if state in self.ts.initial else None)
|
|
137
|
+
stroke = trace_colour if state in on_path else None
|
|
138
|
+
spec_copy = NodeSpec(**{**spec.__dict__, "fill": fill, "stroke": stroke,
|
|
139
|
+
"emphasis": spec.emphasis or state in on_path})
|
|
140
|
+
self.view.graph.update_node(spec_copy)
|
|
141
|
+
for edge in self.view.graph.edges:
|
|
142
|
+
on = (edge.spec.source, edge.spec.target) in steps
|
|
143
|
+
edge.spec.colour = trace_colour if on else None
|
|
144
|
+
edge.spec.width = 2.6 if on else 1.3
|
|
145
|
+
edge.prepareGeometryChange()
|
|
146
|
+
edge.update()
|
|
147
|
+
if walk:
|
|
148
|
+
self.trace_info.setText(" → ".join(walk))
|
|
149
|
+
else:
|
|
150
|
+
self.trace_info.setText("")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# The panel
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
def _table(rows: list[list[str]], headers: list[str]) -> QTableWidget:
|
|
157
|
+
table = QTableWidget(len(rows), len(headers))
|
|
158
|
+
table.setHorizontalHeaderLabels(headers)
|
|
159
|
+
table.verticalHeader().setVisible(False)
|
|
160
|
+
table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
161
|
+
table.setSelectionMode(QAbstractItemView.NoSelection)
|
|
162
|
+
table.setWordWrap(True)
|
|
163
|
+
for r, row in enumerate(rows):
|
|
164
|
+
for c, text in enumerate(row):
|
|
165
|
+
item = QTableWidgetItem(text)
|
|
166
|
+
item.setToolTip(text)
|
|
167
|
+
table.setItem(r, c, item)
|
|
168
|
+
table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
|
|
169
|
+
table.horizontalHeader().setStretchLastSection(True)
|
|
170
|
+
table.resizeRowsToContents()
|
|
171
|
+
height = table.horizontalHeader().sizeHint().height() + sum(
|
|
172
|
+
table.rowHeight(r) for r in range(len(rows))) + 2 * table.frameWidth() + 4
|
|
173
|
+
table.setMinimumHeight(min(height, 360))
|
|
174
|
+
table.setMaximumHeight(height + 20)
|
|
175
|
+
return table
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class RegionsPanel(ConcealsResults, QWidget):
|
|
179
|
+
"""Everything about one transition system's regions (see the module docstring)."""
|
|
180
|
+
|
|
181
|
+
#: "Open as model" on the synthesised net: the window opens it.
|
|
182
|
+
open_net = Signal(object)
|
|
183
|
+
|
|
184
|
+
def __init__(self, result: RegionResult, show_net: bool = True, flat: bool = False,
|
|
185
|
+
parent=None) -> None:
|
|
186
|
+
"""``flat``: inside a card already (a derivation), so the sections get
|
|
187
|
+
no frame of their own."""
|
|
188
|
+
super().__init__(parent)
|
|
189
|
+
self.result = result
|
|
190
|
+
ts, analysis = result.ts, result.analysis
|
|
191
|
+
self.ts = ts
|
|
192
|
+
layout = QVBoxLayout(self)
|
|
193
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
194
|
+
layout.setSpacing(12)
|
|
195
|
+
|
|
196
|
+
# 1. the transition system
|
|
197
|
+
caption = ts.summary() + (f" · {ts.abstraction}" if ts.abstraction else "") + \
|
|
198
|
+
". The initial state is shaded, final states have a thick outline."
|
|
199
|
+
system = Card("Transition system", caption)
|
|
200
|
+
attach_definition(system.caption_label, "transition_system",
|
|
201
|
+
instances.regions(analysis, "transition_system"))
|
|
202
|
+
self.system_view = TransitionSystemView(ts)
|
|
203
|
+
system.add(self.system_view, 1)
|
|
204
|
+
layout.addWidget(system, 1)
|
|
205
|
+
|
|
206
|
+
# 2. is this a region?
|
|
207
|
+
checker = Card("Is this a region?", "Type states separated by commas, or click "
|
|
208
|
+
"them on the drawing.")
|
|
209
|
+
self.region_edit = QLineEdit()
|
|
210
|
+
self.region_edit.setPlaceholderText(", ".join(ts.states[:3]) or "s0, s1")
|
|
211
|
+
self.region_edit.returnPressed.connect(self.check_region)
|
|
212
|
+
self.check_button = button("Check", self.check_region, kind="primary")
|
|
213
|
+
checker.add(hbox(self.region_edit, self.check_button))
|
|
214
|
+
self.check_host = QVBoxLayout()
|
|
215
|
+
checker.add(self.check_host)
|
|
216
|
+
self.system_view.selection_changed.connect(self._selection_changed)
|
|
217
|
+
layout.addWidget(checker)
|
|
218
|
+
|
|
219
|
+
if analysis.limit_message:
|
|
220
|
+
layout.addWidget(Verdict("Limit reached", "warning", analysis.limit_message))
|
|
221
|
+
|
|
222
|
+
# 3. elementary?
|
|
223
|
+
elementary = Card("Elementary?", "A Petri net can mimic the transition system "
|
|
224
|
+
"exactly when any two states are separated by a region and every "
|
|
225
|
+
"event's pre-regions meet exactly where it is enabled.")
|
|
226
|
+
self._fill_elementary(elementary)
|
|
227
|
+
self.conceal_card(elementary, "regions")
|
|
228
|
+
layout.addWidget(elementary)
|
|
229
|
+
|
|
230
|
+
# 4. events
|
|
231
|
+
events = Card("Events", "For each event: where it is enabled (GER), its minimal "
|
|
232
|
+
"pre-regions, the intersection of all its pre-regions, and its minimal "
|
|
233
|
+
"post-regions.")
|
|
234
|
+
attach_definition(events.caption_label, "pre_region",
|
|
235
|
+
instances.regions(analysis, "pre_region"))
|
|
236
|
+
order = ts.states
|
|
237
|
+
rows = []
|
|
238
|
+
for item in analysis.by_event.values():
|
|
239
|
+
rows.append([item.event, format_states(item.ger, order),
|
|
240
|
+
", ".join(format_states(r, order) for r in item.minimal_pre) or "–",
|
|
241
|
+
format_states(item.intersection, order)
|
|
242
|
+
+ (" ✓" if item.forward_closed else " ✗ ≠ GER"),
|
|
243
|
+
", ".join(format_states(r, order) for r in item.minimal_post) or "–"])
|
|
244
|
+
events.add(_table(rows, ["Event", "GER", "Minimal pre-regions", "⋂ pre-regions",
|
|
245
|
+
"Minimal post-regions"]))
|
|
246
|
+
self.conceal_card(events, "regions")
|
|
247
|
+
layout.addWidget(events)
|
|
248
|
+
|
|
249
|
+
# 5. regions
|
|
250
|
+
regions = Card("Regions", f"{len(analysis.regions)} non-trivial region(s), "
|
|
251
|
+
f"{len(analysis.minimal)} minimal. Each minimal region becomes a "
|
|
252
|
+
"place of the net.")
|
|
253
|
+
attach_definition(regions.caption_label, "minimal_region",
|
|
254
|
+
instances.regions(analysis, "minimal_region"))
|
|
255
|
+
lines = [f"r{number} = {format_states(region, order)}"
|
|
256
|
+
for number, region in enumerate(analysis.minimal, 1)]
|
|
257
|
+
regions.add(label("\n".join(lines) or "No non-trivial regions.", wrap=True,
|
|
258
|
+
selectable=True))
|
|
259
|
+
others = [r for r in analysis.regions if r not in set(analysis.minimal)]
|
|
260
|
+
if others:
|
|
261
|
+
shown = others[:60]
|
|
262
|
+
text = "\n".join(format_states(r, order) for r in shown)
|
|
263
|
+
if len(others) > len(shown):
|
|
264
|
+
text += f"\n… and {len(others) - len(shown)} more"
|
|
265
|
+
more = label("Other regions (unions of minimal ones and more):\n" + text, "muted",
|
|
266
|
+
wrap=True, selectable=True)
|
|
267
|
+
regions.add(more)
|
|
268
|
+
self.conceal_card(regions, "regions")
|
|
269
|
+
layout.addWidget(regions)
|
|
270
|
+
|
|
271
|
+
# 6. the net
|
|
272
|
+
synthesis = result.synthesis
|
|
273
|
+
net_card = Card("Synthesised net", "One place per minimal region, an arc from each "
|
|
274
|
+
"pre-region and to each post-region, a token in every minimal region "
|
|
275
|
+
"holding the initial state.")
|
|
276
|
+
attach_definition(net_card.caption_label, "region_synthesis",
|
|
277
|
+
instances.regions(analysis, "region_synthesis", synthesis))
|
|
278
|
+
for warning in result.warnings:
|
|
279
|
+
net_card.add(Verdict("Note", "warning", warning))
|
|
280
|
+
if synthesis.net is not None:
|
|
281
|
+
if synthesis.isomorphic is not None:
|
|
282
|
+
net_card.add(Verdict(
|
|
283
|
+
"Reachability graph ≅ transition system" if synthesis.isomorphic else
|
|
284
|
+
"Reachability graph ≇ transition system",
|
|
285
|
+
status_for(synthesis.isomorphic),
|
|
286
|
+
"the net behaves exactly like the transition system"
|
|
287
|
+
if synthesis.isomorphic else
|
|
288
|
+
"the net's reachability graph is not the transition system (it is not "
|
|
289
|
+
"elementary, or has self-loops)",
|
|
290
|
+
instance=instances.regions(analysis, "region_synthesis", synthesis)))
|
|
291
|
+
if show_net:
|
|
292
|
+
from .graph_builders import petri_net_specs
|
|
293
|
+
preview = GraphView()
|
|
294
|
+
preview.setMinimumHeight(220)
|
|
295
|
+
nodes, edges = petri_net_specs(synthesis.net)
|
|
296
|
+
preview.graph.populate(nodes, edges, layer_gap=48)
|
|
297
|
+
net_card.add(preview)
|
|
298
|
+
QTimer.singleShot(0, preview.fit)
|
|
299
|
+
places = "\n".join(f"{p} = {format_states(region, order)}"
|
|
300
|
+
for p, region in synthesis.places.items())
|
|
301
|
+
net_card.add(label(places, "muted", wrap=True, selectable=True))
|
|
302
|
+
net_card.add(hbox(button("Open as model →", lambda: self.open_net.emit(
|
|
303
|
+
synthesis.net), kind="primary",
|
|
304
|
+
tooltip="Open the net next to your logs (conformance, soundness, "
|
|
305
|
+
"token game)"), None))
|
|
306
|
+
self.conceal_card(net_card, "regions")
|
|
307
|
+
layout.addWidget(net_card)
|
|
308
|
+
layout.addStretch(0)
|
|
309
|
+
if flat:
|
|
310
|
+
layout.setContentsMargins(0, 0, 12, 0) # clear of the scroll bar
|
|
311
|
+
for card in self.findChildren(Card):
|
|
312
|
+
card.setObjectName("flatCard")
|
|
313
|
+
card.body.setContentsMargins(0, 4, 0, 4)
|
|
314
|
+
|
|
315
|
+
def _fill_elementary(self, card: Card) -> None:
|
|
316
|
+
analysis = self.result.analysis
|
|
317
|
+
card.add(Verdict("State separation", status_for(analysis.state_separation),
|
|
318
|
+
"every two states are told apart by some region"
|
|
319
|
+
if analysis.state_separation else
|
|
320
|
+
"undecided (search limit)" if analysis.state_separation is None else "",
|
|
321
|
+
instance=instances.regions(analysis, "state_separation")))
|
|
322
|
+
for pair in analysis.inseparable[:5]:
|
|
323
|
+
card.add(label("• " + pair.explanation(), "muted", wrap=True, selectable=True))
|
|
324
|
+
if len(analysis.inseparable) > 5:
|
|
325
|
+
card.add(label(f"… and {len(analysis.inseparable) - 5} more pairs", "muted"))
|
|
326
|
+
card.add(Verdict("Forward closure", status_for(analysis.forward_closure),
|
|
327
|
+
"for every event, its pre-regions meet exactly in GER(e)"
|
|
328
|
+
if analysis.forward_closure else
|
|
329
|
+
"undecided (search limit)" if analysis.forward_closure is None else "",
|
|
330
|
+
instance=instances.regions(analysis, "forward_closure")))
|
|
331
|
+
order = self.ts.states
|
|
332
|
+
for event, extra in analysis.closure_failures()[:5]:
|
|
333
|
+
item = analysis.by_event[event]
|
|
334
|
+
card.add(label(f"• {event}: ⋂ pre-regions = {format_states(item.intersection, order)}"
|
|
335
|
+
f" but GER({event}) = {format_states(item.ger, order)}; the net "
|
|
336
|
+
f"would also enable {event} in {format_states(extra, order)}.",
|
|
337
|
+
"muted", wrap=True, selectable=True))
|
|
338
|
+
verdict = analysis.elementary
|
|
339
|
+
card.add(Verdict("Elementary" if verdict or verdict is None else "Not elementary",
|
|
340
|
+
status_for(verdict),
|
|
341
|
+
"state separation and forward closure both hold" if verdict else "",
|
|
342
|
+
definition="elementary_ts",
|
|
343
|
+
instance=instances.regions(analysis, "elementary_ts")))
|
|
344
|
+
|
|
345
|
+
# -- is this a region? ----------------------------------------------------------------
|
|
346
|
+
def _selection_changed(self, states: list[str]) -> None:
|
|
347
|
+
self.region_edit.setText(", ".join(states))
|
|
348
|
+
if states:
|
|
349
|
+
self.check_region()
|
|
350
|
+
else:
|
|
351
|
+
self._clear_check()
|
|
352
|
+
|
|
353
|
+
def _clear_check(self) -> None:
|
|
354
|
+
while self.check_host.count():
|
|
355
|
+
item = self.check_host.takeAt(0)
|
|
356
|
+
if item.widget():
|
|
357
|
+
item.widget().hide()
|
|
358
|
+
item.widget().deleteLater()
|
|
359
|
+
|
|
360
|
+
def check_region(self) -> None:
|
|
361
|
+
states = parse_states(self.region_edit.text())
|
|
362
|
+
self.system_view.set_selected(states)
|
|
363
|
+
self._clear_check()
|
|
364
|
+
result = check_region(self.ts, states)
|
|
365
|
+
self.last_check = result
|
|
366
|
+
if result.unknown:
|
|
367
|
+
verdict = Verdict("Not a region", "critical", result.explanation(self.ts.states))
|
|
368
|
+
else:
|
|
369
|
+
verdict = Verdict("Region" if result.is_region else "Not a region",
|
|
370
|
+
status_for(result.is_region), result.explanation(self.ts.states),
|
|
371
|
+
instance=instances.region_check(result, self.ts))
|
|
372
|
+
self.check_host.addWidget(verdict)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
# A typed transition system as a page of its own
|
|
377
|
+
# ---------------------------------------------------------------------------
|
|
378
|
+
class TransitionSystemPage(ConcealsResults, QWidget):
|
|
379
|
+
"""Type a transition system (as in an exercise) and study its regions."""
|
|
380
|
+
|
|
381
|
+
status = Signal(str)
|
|
382
|
+
saved = Signal()
|
|
383
|
+
#: A model to open (the synthesised net).
|
|
384
|
+
open_model = Signal(object)
|
|
385
|
+
|
|
386
|
+
def __init__(self, document, parent=None) -> None:
|
|
387
|
+
super().__init__(parent)
|
|
388
|
+
self.document = document
|
|
389
|
+
self.panel: RegionsPanel | None = None
|
|
390
|
+
root = QVBoxLayout(self)
|
|
391
|
+
root.setContentsMargins(0, 0, 0, 0)
|
|
392
|
+
root.setSpacing(0)
|
|
393
|
+
self.header = PageHeader(document.name, "")
|
|
394
|
+
self.header.actions.addWidget(button("Export…", self.export,
|
|
395
|
+
tooltip="Save the transition system as a text "
|
|
396
|
+
"file"))
|
|
397
|
+
root.addWidget(self.header)
|
|
398
|
+
|
|
399
|
+
editor = Card("Transitions", "One per line, or separated by commas: s0 -a-> s1. "
|
|
400
|
+
"Optional lines: initial: s0, final: s7.")
|
|
401
|
+
self.editor = QPlainTextEdit(document.ts.to_text())
|
|
402
|
+
font = self.editor.font()
|
|
403
|
+
font.setFamily("Menlo, Consolas, monospace")
|
|
404
|
+
self.editor.setFont(font)
|
|
405
|
+
self.editor.setMinimumHeight(160)
|
|
406
|
+
editor.add(self.editor, 1)
|
|
407
|
+
self.apply_button = button("Apply", self.apply, kind="primary",
|
|
408
|
+
tooltip="Read the transitions again and recompute the "
|
|
409
|
+
"regions")
|
|
410
|
+
self.error = label("", "muted", wrap=True)
|
|
411
|
+
editor.add(hbox(self.apply_button, self.error, stretch_last=True))
|
|
412
|
+
editor.setMinimumWidth(240)
|
|
413
|
+
|
|
414
|
+
self.panel_host = QWidget()
|
|
415
|
+
self.panel_layout = QVBoxLayout(self.panel_host)
|
|
416
|
+
self.panel_layout.setContentsMargins(0, 0, 4, 0)
|
|
417
|
+
splitter = QSplitter()
|
|
418
|
+
splitter.setHandleWidth(14)
|
|
419
|
+
splitter.setStyleSheet("QSplitter::handle { background: transparent; }")
|
|
420
|
+
splitter.addWidget(editor)
|
|
421
|
+
splitter.addWidget(scroll(self.panel_host))
|
|
422
|
+
splitter.setStretchFactor(1, 1)
|
|
423
|
+
splitter.setSizes([300, 900])
|
|
424
|
+
body = QWidget()
|
|
425
|
+
body.setLayout(hbox(splitter, margins=(20, 0, 20, 16)))
|
|
426
|
+
root.addWidget(body, 1)
|
|
427
|
+
self.refresh_title()
|
|
428
|
+
self._compute()
|
|
429
|
+
|
|
430
|
+
def refresh_title(self) -> None:
|
|
431
|
+
where = Path(self.document.path).name if self.document.path else "not saved to a file"
|
|
432
|
+
self.header.set_text(self.document.name,
|
|
433
|
+
f"Transition system · {self.document.ts.summary()} · {where}")
|
|
434
|
+
|
|
435
|
+
def _compute(self) -> None:
|
|
436
|
+
from .workers import run_in_background
|
|
437
|
+
ts = self.document.ts
|
|
438
|
+
self._show_panel(None)
|
|
439
|
+
self.panel_layout.addWidget(label("Finding the regions…", "muted"))
|
|
440
|
+
run_in_background(lambda: region_result(ts), self._show_panel,
|
|
441
|
+
lambda message: self.error.setText(message))
|
|
442
|
+
|
|
443
|
+
def _show_panel(self, result) -> None:
|
|
444
|
+
while self.panel_layout.count():
|
|
445
|
+
item = self.panel_layout.takeAt(0)
|
|
446
|
+
if item.widget():
|
|
447
|
+
item.widget().hide()
|
|
448
|
+
item.widget().deleteLater()
|
|
449
|
+
self._concealed_cards = []
|
|
450
|
+
self.panel = None
|
|
451
|
+
if result is None:
|
|
452
|
+
return
|
|
453
|
+
self.panel = RegionsPanel(result)
|
|
454
|
+
self.panel.open_net.connect(self._open_net)
|
|
455
|
+
self.panel.set_concealment(getattr(self, "concealment", None))
|
|
456
|
+
self.panel_layout.addWidget(self.panel)
|
|
457
|
+
|
|
458
|
+
def set_concealment(self, concealment) -> None:
|
|
459
|
+
super().set_concealment(concealment)
|
|
460
|
+
if self.panel is not None:
|
|
461
|
+
self.panel.set_concealment(concealment)
|
|
462
|
+
|
|
463
|
+
def _open_net(self, net) -> None:
|
|
464
|
+
from .documents import ModelDocument
|
|
465
|
+
net.name = f"Regions · {self.document.name}"
|
|
466
|
+
self.open_model.emit(ModelDocument(net, origin=f"Regions of “{self.document.name}”",
|
|
467
|
+
derivation=self.panel.result if self.panel else
|
|
468
|
+
None))
|
|
469
|
+
|
|
470
|
+
def apply(self) -> None:
|
|
471
|
+
try:
|
|
472
|
+
ts = parse_transition_system(self.editor.toPlainText(), self.document.name)
|
|
473
|
+
except ValueError as error:
|
|
474
|
+
self.error.setText(str(error))
|
|
475
|
+
return
|
|
476
|
+
self.error.setText("")
|
|
477
|
+
self.document.ts = ts
|
|
478
|
+
self.refresh_title()
|
|
479
|
+
self._compute()
|
|
480
|
+
if self.document.path:
|
|
481
|
+
self.write_to(self.document.path, quiet=True)
|
|
482
|
+
|
|
483
|
+
def export(self) -> None:
|
|
484
|
+
path, _ = QFileDialog.getSaveFileName(
|
|
485
|
+
self, "Export transition system",
|
|
486
|
+
self.document.path or suggested_path(f"{self.document.name}.ts.txt"),
|
|
487
|
+
"Transition system (*.txt)")
|
|
488
|
+
if path:
|
|
489
|
+
if not path.lower().endswith(".txt"):
|
|
490
|
+
path += ".ts.txt"
|
|
491
|
+
self.write_to(path)
|
|
492
|
+
|
|
493
|
+
def write_to(self, path: str, quiet: bool = False) -> None:
|
|
494
|
+
from .workspace import atomic_write
|
|
495
|
+
text = self.document.ts.to_text() + "\n"
|
|
496
|
+
atomic_write(path, lambda temporary: Path(temporary).write_text(text, encoding="utf-8"))
|
|
497
|
+
self.document.path = path
|
|
498
|
+
self.document.missing = False
|
|
499
|
+
self.refresh_title()
|
|
500
|
+
if not quiet:
|
|
501
|
+
self.status.emit(f"Saved {Path(path).name}")
|
|
502
|
+
self.saved.emit()
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""The sidebar's tree: open documents, files not open yet, and subfolders.
|
|
2
|
+
|
|
3
|
+
Rows carry what they stand for in their item data:
|
|
4
|
+
|
|
5
|
+
* ``Qt.UserRole`` -- the id of an open document;
|
|
6
|
+
* :data:`FILE_ROLE` -- the path of a file in the folder that is not open yet;
|
|
7
|
+
* :data:`FOLDER_ROLE` -- the path of a subfolder (in the Folder view).
|
|
8
|
+
|
|
9
|
+
The tree also handles dragging: rows can be dragged onto a subfolder (to move
|
|
10
|
+
the files on disk) or out of the window (to Finder, say), and files dragged
|
|
11
|
+
in from outside land in the subfolder they are dropped on. What a drop means
|
|
12
|
+
is the window's business: the tree only reports it (:attr:`paths_dropped`).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Callable
|
|
18
|
+
|
|
19
|
+
from PySide6.QtCore import QMimeData, QPointF, Qt, QUrl, Signal
|
|
20
|
+
from PySide6.QtGui import QColor, QDrag, QPainter, QPainterPath, QPen
|
|
21
|
+
from PySide6.QtWidgets import QAbstractItemView, QTreeWidget, QTreeWidgetItem
|
|
22
|
+
|
|
23
|
+
from . import style
|
|
24
|
+
|
|
25
|
+
#: Sidebar item data: the path of a file in the folder that is not open yet.
|
|
26
|
+
FILE_ROLE = Qt.UserRole + 1
|
|
27
|
+
#: Sidebar item data: the path of a subfolder.
|
|
28
|
+
FOLDER_ROLE = Qt.UserRole + 2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SidebarTree(QTreeWidget):
|
|
32
|
+
"""A ``QTreeWidget`` whose rows can be dragged to subfolders, Finder or back in."""
|
|
33
|
+
|
|
34
|
+
#: Files were dropped: their paths, the folder they were dropped on (None:
|
|
35
|
+
#: not on a folder), and whether they were dragged from this sidebar.
|
|
36
|
+
paths_dropped = Signal(list, object, bool)
|
|
37
|
+
|
|
38
|
+
def __init__(self, parent=None) -> None:
|
|
39
|
+
super().__init__(parent)
|
|
40
|
+
#: Set by the window: the file or folder a row stands for (None: nothing on disk).
|
|
41
|
+
self.path_for: Callable[[QTreeWidgetItem], str | None] = lambda item: None
|
|
42
|
+
#: Set by the window: the folder a drop on this row (None: the empty
|
|
43
|
+
#: space below the rows) goes into, or None when drops do not go into folders.
|
|
44
|
+
self.drop_folder: Callable[[QTreeWidgetItem | None], str | None] = lambda item: None
|
|
45
|
+
self.setDragEnabled(True)
|
|
46
|
+
self.setAcceptDrops(True)
|
|
47
|
+
self.setDropIndicatorShown(False)
|
|
48
|
+
self.setDragDropMode(QAbstractItemView.DragDrop)
|
|
49
|
+
self._target: QTreeWidgetItem | None = None
|
|
50
|
+
|
|
51
|
+
# -- the ▸ / ▾ of a subfolder ----------------------------------------------------------
|
|
52
|
+
def drawBranches(self, painter: QPainter, rect, index) -> None: # noqa: N802
|
|
53
|
+
"""A quiet chevron, and nothing else: Qt's own branch area also painted
|
|
54
|
+
the selected row's highlight in the indentation, so that is covered up."""
|
|
55
|
+
painter.fillRect(rect, QColor(style.tokens().sidebar))
|
|
56
|
+
item = self.itemFromIndex(index)
|
|
57
|
+
if item is None or item.childCount() == 0 or not item.data(0, FOLDER_ROLE):
|
|
58
|
+
return # only subfolders get one, not section headings
|
|
59
|
+
size = 3.5
|
|
60
|
+
centre = QPointF(rect.right() - self.indentation() / 2 + 2, rect.center().y() + 1)
|
|
61
|
+
path = QPainterPath()
|
|
62
|
+
if self.isExpanded(index):
|
|
63
|
+
path.moveTo(centre.x() - size, centre.y() - size / 2)
|
|
64
|
+
path.lineTo(centre.x(), centre.y() + size / 2)
|
|
65
|
+
path.lineTo(centre.x() + size, centre.y() - size / 2)
|
|
66
|
+
else:
|
|
67
|
+
path.moveTo(centre.x() - size / 2, centre.y() - size)
|
|
68
|
+
path.lineTo(centre.x() + size / 2, centre.y())
|
|
69
|
+
path.lineTo(centre.x() - size / 2, centre.y() + size)
|
|
70
|
+
painter.save()
|
|
71
|
+
painter.setRenderHint(QPainter.Antialiasing)
|
|
72
|
+
pen = QPen(QColor(style.tokens().text_secondary), 1.6)
|
|
73
|
+
pen.setCapStyle(Qt.RoundCap)
|
|
74
|
+
pen.setJoinStyle(Qt.RoundJoin)
|
|
75
|
+
painter.setPen(pen)
|
|
76
|
+
painter.drawPath(path)
|
|
77
|
+
painter.restore()
|
|
78
|
+
|
|
79
|
+
# -- dragging out ------------------------------------------------------------------
|
|
80
|
+
def startDrag(self, _actions) -> None: # noqa: N802
|
|
81
|
+
"""Drag the selected rows' files.
|
|
82
|
+
|
|
83
|
+
Qt's own implementation removes the rows after a move; here nothing
|
|
84
|
+
changes until the window has moved the files, and the folder watcher
|
|
85
|
+
or the window then updates the rows. Copy is the default, so a drag
|
|
86
|
+
to Finder copies the file (⌘ in Finder makes it a move).
|
|
87
|
+
"""
|
|
88
|
+
paths = [self.path_for(item) for item in self.selectedItems()]
|
|
89
|
+
paths = [path for path in paths if path]
|
|
90
|
+
if not paths:
|
|
91
|
+
return
|
|
92
|
+
data = QMimeData()
|
|
93
|
+
data.setUrls([QUrl.fromLocalFile(path) for path in paths])
|
|
94
|
+
drag = QDrag(self)
|
|
95
|
+
drag.setMimeData(data)
|
|
96
|
+
drag.exec(Qt.CopyAction | Qt.MoveAction, Qt.CopyAction)
|
|
97
|
+
|
|
98
|
+
# -- dropping in -------------------------------------------------------------------
|
|
99
|
+
def dragEnterEvent(self, event) -> None: # noqa: N802
|
|
100
|
+
if event.mimeData().hasUrls():
|
|
101
|
+
event.acceptProposedAction()
|
|
102
|
+
else:
|
|
103
|
+
event.ignore()
|
|
104
|
+
|
|
105
|
+
def dragMoveEvent(self, event) -> None: # noqa: N802
|
|
106
|
+
if not event.mimeData().hasUrls():
|
|
107
|
+
event.ignore()
|
|
108
|
+
return
|
|
109
|
+
item = self.itemAt(event.position().toPoint())
|
|
110
|
+
folder = self.drop_folder(item)
|
|
111
|
+
self._highlight(self._row_of_folder(item, folder))
|
|
112
|
+
event.acceptProposedAction()
|
|
113
|
+
|
|
114
|
+
def dragLeaveEvent(self, event) -> None: # noqa: N802
|
|
115
|
+
self._highlight(None)
|
|
116
|
+
super().dragLeaveEvent(event)
|
|
117
|
+
|
|
118
|
+
def dropEvent(self, event) -> None: # noqa: N802
|
|
119
|
+
self._highlight(None)
|
|
120
|
+
paths = [url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile()]
|
|
121
|
+
if not paths:
|
|
122
|
+
event.ignore()
|
|
123
|
+
return
|
|
124
|
+
folder = self.drop_folder(self.itemAt(event.position().toPoint()))
|
|
125
|
+
internal = event.source() is self
|
|
126
|
+
if internal:
|
|
127
|
+
event.setDropAction(Qt.MoveAction)
|
|
128
|
+
event.accept()
|
|
129
|
+
self.paths_dropped.emit(paths, folder, internal)
|
|
130
|
+
|
|
131
|
+
def _row_of_folder(self, item, folder: str | None):
|
|
132
|
+
"""The row to highlight for a drop into ``folder``: the folder's own row."""
|
|
133
|
+
while item is not None and folder is not None:
|
|
134
|
+
if item.data(0, FOLDER_ROLE) == folder:
|
|
135
|
+
return item
|
|
136
|
+
item = item.parent()
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
def _highlight(self, item: QTreeWidgetItem | None) -> None:
|
|
140
|
+
if item is self._target:
|
|
141
|
+
return
|
|
142
|
+
if self._target is not None:
|
|
143
|
+
try:
|
|
144
|
+
self._target.setData(0, Qt.BackgroundRole, None)
|
|
145
|
+
except RuntimeError: # the row was rebuilt meanwhile
|
|
146
|
+
pass
|
|
147
|
+
self._target = item
|
|
148
|
+
if item is not None:
|
|
149
|
+
item.setBackground(0, style.qc(style.tokens().accent, 0.22))
|