pycforge 0.15.2__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.
- pycforge/__init__.py +8 -0
- pycforge/__main__.py +2 -0
- pycforge/_version.py +1 -0
- pycforge/converter/__init__.py +0 -0
- pycforge/converter/analysis/__init__.py +5 -0
- pycforge/converter/analysis/functions.py +723 -0
- pycforge/converter/analysis/model.py +158 -0
- pycforge/converter/analysis/planning.py +464 -0
- pycforge/converter/analysis/stage.py +1976 -0
- pycforge/converter/analysis/symbols.py +237 -0
- pycforge/converter/analysis/validation.py +2166 -0
- pycforge/converter/c_output/__init__.py +2 -0
- pycforge/converter/c_output/conformance.py +297 -0
- pycforge/converter/c_output/renderer.py +220 -0
- pycforge/converter/conditional_regions/__init__.py +71 -0
- pycforge/converter/conditional_regions/analysis.py +481 -0
- pycforge/converter/conditional_regions/lowering.py +579 -0
- pycforge/converter/conditional_regions/model.py +243 -0
- pycforge/converter/conditional_regions/validation.py +690 -0
- pycforge/converter/containers/__init__.py +19 -0
- pycforge/converter/containers/analysis.py +542 -0
- pycforge/converter/containers/lowering.py +290 -0
- pycforge/converter/containers/model.py +146 -0
- pycforge/converter/contracts/__init__.py +0 -0
- pycforge/converter/contracts/configuration.py +178 -0
- pycforge/converter/contracts/identifiers.py +68 -0
- pycforge/converter/contracts/versions.py +71 -0
- pycforge/converter/core/__init__.py +0 -0
- pycforge/converter/core/artifact_io.py +87 -0
- pycforge/converter/core/cancellation.py +7 -0
- pycforge/converter/core/canonicalization.py +254 -0
- pycforge/converter/core/context.py +13 -0
- pycforge/converter/core/diagnostics.py +95 -0
- pycforge/converter/core/enums.py +23 -0
- pycforge/converter/core/fingerprint.py +22 -0
- pycforge/converter/core/pipeline.py +8 -0
- pycforge/converter/core/progress.py +25 -0
- pycforge/converter/core/request.py +50 -0
- pycforge/converter/core/resource_policy.py +31 -0
- pycforge/converter/core/result.py +23 -0
- pycforge/converter/core/serialization.py +36 -0
- pycforge/converter/core/stage.py +10 -0
- pycforge/converter/core/stage_artifact.py +80 -0
- pycforge/converter/core/stage_outcome.py +15 -0
- pycforge/converter/decision_trace/__init__.py +0 -0
- pycforge/converter/decision_trace/recorder.py +52 -0
- pycforge/converter/facade.py +459 -0
- pycforge/converter/frontend/__init__.py +4 -0
- pycforge/converter/frontend/normalizer.py +59 -0
- pycforge/converter/frontend/parser.py +20 -0
- pycforge/converter/frontend/source_document.py +93 -0
- pycforge/converter/frontend/stages.py +518 -0
- pycforge/converter/frontend/validation.py +76 -0
- pycforge/converter/io/__init__.py +0 -0
- pycforge/converter/io/atomic_writer.py +33 -0
- pycforge/converter/ir/__init__.py +1 -0
- pycforge/converter/ir/c_ir/__init__.py +4 -0
- pycforge/converter/ir/c_ir/builder.py +26 -0
- pycforge/converter/ir/c_ir/model.py +318 -0
- pycforge/converter/ir/c_ir/serialization.py +88 -0
- pycforge/converter/ir/c_ir/validation.py +834 -0
- pycforge/converter/ir/python_ir/__init__.py +17 -0
- pycforge/converter/ir/python_ir/nodes.py +86 -0
- pycforge/converter/keyword_calls/__init__.py +68 -0
- pycforge/converter/keyword_calls/analysis.py +458 -0
- pycforge/converter/keyword_calls/lowering.py +271 -0
- pycforge/converter/keyword_calls/model.py +304 -0
- pycforge/converter/keyword_calls/validation.py +802 -0
- pycforge/converter/keyword_only_calls/__init__.py +68 -0
- pycforge/converter/keyword_only_calls/analysis.py +640 -0
- pycforge/converter/keyword_only_calls/lowering.py +376 -0
- pycforge/converter/keyword_only_calls/model.py +328 -0
- pycforge/converter/keyword_only_calls/validation.py +1357 -0
- pycforge/converter/lowering.py +1000 -0
- pycforge/converter/modules/__init__.py +13 -0
- pycforge/converter/modules/analysis.py +1250 -0
- pycforge/converter/modules/lowering.py +200 -0
- pycforge/converter/modules/model.py +73 -0
- pycforge/converter/modules/stage.py +192 -0
- pycforge/converter/numeric_semantics/__init__.py +23 -0
- pycforge/converter/numeric_semantics/analysis.py +420 -0
- pycforge/converter/numeric_semantics/lowering.py +153 -0
- pycforge/converter/numeric_semantics/model.py +123 -0
- pycforge/converter/records/__init__.py +34 -0
- pycforge/converter/records/analysis.py +1394 -0
- pycforge/converter/records/lowering.py +393 -0
- pycforge/converter/records/model.py +319 -0
- pycforge/converter/rules/__init__.py +3 -0
- pycforge/converter/support_templates/__init__.py +49 -0
- pycforge/converter/support_templates/assembly.py +106 -0
- pycforge/converter/support_templates/factories.py +348 -0
- pycforge/converter/support_templates/model.py +159 -0
- pycforge/converter/support_templates/registry.py +388 -0
- pycforge/converter/telemetry/__init__.py +0 -0
- pycforge/converter/telemetry/sink.py +39 -0
- pycforge/ide/__init__.py +19 -0
- pycforge/ide/__main__.py +5 -0
- pycforge/ide/_worker_event_codec.py +276 -0
- pycforge/ide/_worker_protocol_json.py +197 -0
- pycforge/ide/_worker_protocol_types.py +90 -0
- pycforge/ide/_worker_request_codec.py +200 -0
- pycforge/ide/_worker_request_data.py +260 -0
- pycforge/ide/_worker_result_data.py +343 -0
- pycforge/ide/action_contract.py +469 -0
- pycforge/ide/action_validation.py +229 -0
- pycforge/ide/command_palette.py +223 -0
- pycforge/ide/controller.py +904 -0
- pycforge/ide/controller_conversion.py +546 -0
- pycforge/ide/controller_io.py +205 -0
- pycforge/ide/editor.py +951 -0
- pycforge/ide/editor_commands_qt.py +274 -0
- pycforge/ide/editor_lexical.py +43 -0
- pycforge/ide/editor_sidebars.py +236 -0
- pycforge/ide/editor_syntax.py +243 -0
- pycforge/ide/find_replace.py +535 -0
- pycforge/ide/icons.py +91 -0
- pycforge/ide/io_service.py +196 -0
- pycforge/ide/model.py +164 -0
- pycforge/ide/panels.py +903 -0
- pycforge/ide/positions.py +220 -0
- pycforge/ide/process_worker.py +130 -0
- pycforge/ide/qt.py +230 -0
- pycforge/ide/qt_actions.py +418 -0
- pycforge/ide/qt_close.py +169 -0
- pycforge/ide/qt_command_palette.py +297 -0
- pycforge/ide/qt_contract.py +220 -0
- pycforge/ide/qt_documents.py +590 -0
- pycforge/ide/qt_editor_buffers.py +113 -0
- pycforge/ide/qt_editor_surfaces.py +112 -0
- pycforge/ide/qt_menus.py +324 -0
- pycforge/ide/qt_projection.py +500 -0
- pycforge/ide/qt_shell.py +348 -0
- pycforge/ide/qt_shell_interactions.py +320 -0
- pycforge/ide/qt_state.py +393 -0
- pycforge/ide/qt_workspace_features.py +398 -0
- pycforge/ide/qt_workspace_navigation.py +208 -0
- pycforge/ide/qt_workspace_observers.py +173 -0
- pycforge/ide/qt_workspace_panels.py +545 -0
- pycforge/ide/qt_workspace_widgets.py +228 -0
- pycforge/ide/resources/icons/about.svg +1 -0
- pycforge/ide/resources/icons/add-document.svg +1 -0
- pycforge/ide/resources/icons/brand-mark.svg +1 -0
- pycforge/ide/resources/icons/bundle-search.svg +1 -0
- pycforge/ide/resources/icons/cancel.svg +1 -0
- pycforge/ide/resources/icons/check.svg +1 -0
- pycforge/ide/resources/icons/chevron-right.svg +1 -0
- pycforge/ide/resources/icons/close.svg +1 -0
- pycforge/ide/resources/icons/collapse-all.svg +1 -0
- pycforge/ide/resources/icons/command-palette.svg +1 -0
- pycforge/ide/resources/icons/convert.svg +1 -0
- pycforge/ide/resources/icons/copy.svg +1 -0
- pycforge/ide/resources/icons/cut.svg +1 -0
- pycforge/ide/resources/icons/decision-trace.svg +1 -0
- pycforge/ide/resources/icons/details.svg +1 -0
- pycforge/ide/resources/icons/diagnostics.svg +1 -0
- pycforge/ide/resources/icons/duplicate-line.svg +1 -0
- pycforge/ide/resources/icons/exit.svg +1 -0
- pycforge/ide/resources/icons/expand-all.svg +1 -0
- pycforge/ide/resources/icons/export.svg +1 -0
- pycforge/ide/resources/icons/find.svg +1 -0
- pycforge/ide/resources/icons/go-to-line.svg +1 -0
- pycforge/ide/resources/icons/go-to-output.svg +1 -0
- pycforge/ide/resources/icons/go-to-source.svg +1 -0
- pycforge/ide/resources/icons/history.svg +1 -0
- pycforge/ide/resources/icons/indent.svg +1 -0
- pycforge/ide/resources/icons/link-c.svg +1 -0
- pycforge/ide/resources/icons/mappings.svg +1 -0
- pycforge/ide/resources/icons/module.svg +1 -0
- pycforge/ide/resources/icons/move-down.svg +1 -0
- pycforge/ide/resources/icons/move-line-down.svg +1 -0
- pycforge/ide/resources/icons/move-line-up.svg +1 -0
- pycforge/ide/resources/icons/move-up.svg +1 -0
- pycforge/ide/resources/icons/next.svg +1 -0
- pycforge/ide/resources/icons/open.svg +1 -0
- pycforge/ide/resources/icons/outdent.svg +1 -0
- pycforge/ide/resources/icons/outline.svg +1 -0
- pycforge/ide/resources/icons/paste.svg +1 -0
- pycforge/ide/resources/icons/previous.svg +1 -0
- pycforge/ide/resources/icons/primary-module.svg +1 -0
- pycforge/ide/resources/icons/redo.svg +1 -0
- pycforge/ide/resources/icons/remove-document.svg +1 -0
- pycforge/ide/resources/icons/replace.svg +1 -0
- pycforge/ide/resources/icons/save-as.svg +1 -0
- pycforge/ide/resources/icons/save.svg +1 -0
- pycforge/ide/resources/icons/select-all.svg +1 -0
- pycforge/ide/resources/icons/settings.svg +1 -0
- pycforge/ide/resources/icons/show-c.svg +1 -0
- pycforge/ide/resources/icons/split-view.svg +1 -0
- pycforge/ide/resources/icons/summary.svg +1 -0
- pycforge/ide/resources/icons/telemetry.svg +1 -0
- pycforge/ide/resources/icons/toggle-comment.svg +1 -0
- pycforge/ide/resources/icons/toggle-fold.svg +1 -0
- pycforge/ide/resources/icons/undo.svg +1 -0
- pycforge/ide/resources/icons/whitespace.svg +1 -0
- pycforge/ide/revisions.py +400 -0
- pycforge/ide/search_service.py +280 -0
- pycforge/ide/session_history.py +150 -0
- pycforge/ide/source_editing.py +417 -0
- pycforge/ide/source_structure.py +558 -0
- pycforge/ide/source_structure_async.py +191 -0
- pycforge/ide/supervisor.py +503 -0
- pycforge/ide/theme.py +119 -0
- pycforge/ide/theme_stylesheet.py +598 -0
- pycforge/ide/theme_workspace_stylesheet.py +155 -0
- pycforge/ide/visual_tokens.py +142 -0
- pycforge/ide/worker_protocol.py +80 -0
- pycforge/ide/workspace_search.py +462 -0
- pycforge/ide/workspace_session.py +256 -0
- pycforge/laboratory/__init__.py +6 -0
- pycforge/laboratory/audits.py +2244 -0
- pycforge/laboratory/checkpoint_e.py +2685 -0
- pycforge/laboratory/cli.py +119 -0
- pycforge/laboratory/exit_codes.py +11 -0
- pycforge/laboratory/keyword_audit.py +661 -0
- pycforge/laboratory/keyword_only_audit.py +481 -0
- pycforge-0.15.2.dist-info/METADATA +209 -0
- pycforge-0.15.2.dist-info/RECORD +221 -0
- pycforge-0.15.2.dist-info/WHEEL +5 -0
- pycforge-0.15.2.dist-info/entry_points.txt +5 -0
- pycforge-0.15.2.dist-info/licenses/LICENSE +674 -0
- pycforge-0.15.2.dist-info/top_level.txt +1 -0
pycforge/ide/panels.py
ADDED
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
"""PyCForge workspace navigation and inspection panels.
|
|
2
|
+
|
|
3
|
+
The module remains import-safe if the required PyQt5 dependency is missing from
|
|
4
|
+
a damaged installation. The headless controller remains the product boundary;
|
|
5
|
+
these widgets only project immutable workspace snapshots.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from itertools import islice
|
|
12
|
+
from typing import Any, Iterable
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from PyQt5.QtCore import Qt, pyqtSignal
|
|
16
|
+
from PyQt5.QtGui import QBrush, QColor, QIcon
|
|
17
|
+
from PyQt5.QtWidgets import (
|
|
18
|
+
QAbstractItemView,
|
|
19
|
+
QCheckBox,
|
|
20
|
+
QFrame,
|
|
21
|
+
QHBoxLayout,
|
|
22
|
+
QLabel,
|
|
23
|
+
QLineEdit,
|
|
24
|
+
QListWidget,
|
|
25
|
+
QListWidgetItem,
|
|
26
|
+
QSizePolicy,
|
|
27
|
+
QTextBrowser,
|
|
28
|
+
QToolButton,
|
|
29
|
+
QTreeWidget,
|
|
30
|
+
QTreeWidgetItem,
|
|
31
|
+
QVBoxLayout,
|
|
32
|
+
QWidget,
|
|
33
|
+
)
|
|
34
|
+
except (ImportError, ModuleNotFoundError) as exc:
|
|
35
|
+
QT_AVAILABLE = False
|
|
36
|
+
_QT_ERROR = exc
|
|
37
|
+
else:
|
|
38
|
+
QT_AVAILABLE = True
|
|
39
|
+
_QT_ERROR = None
|
|
40
|
+
|
|
41
|
+
from .theme import pycforge_icon_path
|
|
42
|
+
from .visual_tokens import PYCFORGE_COLORS
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
INSPECTOR_TREE_MAX_NODES = 1024
|
|
46
|
+
INSPECTOR_TREE_MAX_DEPTH = 16
|
|
47
|
+
INSPECTOR_TREE_MAX_CHILDREN = 256
|
|
48
|
+
INSPECTOR_TREE_MAX_TEXT_CHARS = 2048
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class InspectorNode:
|
|
53
|
+
"""One bounded, parent-indexed row in a structured inspector projection."""
|
|
54
|
+
|
|
55
|
+
parent_index: int | None
|
|
56
|
+
key: str
|
|
57
|
+
value: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _bounded_inspector_text(value: Any, limit: int) -> str:
|
|
61
|
+
if value is None:
|
|
62
|
+
return "null"
|
|
63
|
+
rendered = value if isinstance(value, str) else str(value)
|
|
64
|
+
if len(rendered) <= limit:
|
|
65
|
+
return rendered
|
|
66
|
+
return rendered[: limit - 1] + "…"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def project_inspector_tree(
|
|
70
|
+
value: Any,
|
|
71
|
+
*,
|
|
72
|
+
max_nodes: int = INSPECTOR_TREE_MAX_NODES,
|
|
73
|
+
max_depth: int = INSPECTOR_TREE_MAX_DEPTH,
|
|
74
|
+
max_children: int = INSPECTOR_TREE_MAX_CHILDREN,
|
|
75
|
+
max_text_chars: int = INSPECTOR_TREE_MAX_TEXT_CHARS,
|
|
76
|
+
) -> tuple[InspectorNode, ...]:
|
|
77
|
+
"""Return a deterministic structured projection under absolute budgets."""
|
|
78
|
+
|
|
79
|
+
if max_nodes < 2:
|
|
80
|
+
raise ValueError("max_nodes must be at least 2")
|
|
81
|
+
if max_depth < 0:
|
|
82
|
+
raise ValueError("max_depth cannot be negative")
|
|
83
|
+
if max_children < 1:
|
|
84
|
+
raise ValueError("max_children must be positive")
|
|
85
|
+
if max_text_chars < 8:
|
|
86
|
+
raise ValueError("max_text_chars must be at least 8")
|
|
87
|
+
|
|
88
|
+
# Frames are parent, key, value, depth, and an optional pre-rendered value.
|
|
89
|
+
stack: list[tuple[int | None, str, Any, int, str | None]] = [
|
|
90
|
+
(None, "root", value, 0, None)
|
|
91
|
+
]
|
|
92
|
+
nodes: list[InspectorNode] = []
|
|
93
|
+
seen_containers: dict[int, int] = {}
|
|
94
|
+
data_node_limit = max_nodes - 1
|
|
95
|
+
|
|
96
|
+
while stack and len(nodes) < data_node_limit:
|
|
97
|
+
parent_index, key, current, depth, notice = stack.pop()
|
|
98
|
+
bounded_key = _bounded_inspector_text(key, max_text_chars)
|
|
99
|
+
if notice is not None:
|
|
100
|
+
nodes.append(
|
|
101
|
+
InspectorNode(
|
|
102
|
+
parent_index,
|
|
103
|
+
bounded_key,
|
|
104
|
+
_bounded_inspector_text(notice, max_text_chars),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
is_mapping = isinstance(current, dict)
|
|
110
|
+
is_sequence = isinstance(current, (list, tuple))
|
|
111
|
+
if not (is_mapping or is_sequence):
|
|
112
|
+
nodes.append(
|
|
113
|
+
InspectorNode(
|
|
114
|
+
parent_index,
|
|
115
|
+
bounded_key,
|
|
116
|
+
_bounded_inspector_text(current, max_text_chars),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
prior_index = seen_containers.get(id(current))
|
|
122
|
+
if prior_index is not None:
|
|
123
|
+
nodes.append(
|
|
124
|
+
InspectorNode(
|
|
125
|
+
parent_index,
|
|
126
|
+
bounded_key,
|
|
127
|
+
_bounded_inspector_text(
|
|
128
|
+
f"reference to node {prior_index}",
|
|
129
|
+
max_text_chars,
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
node_index = len(nodes)
|
|
136
|
+
seen_containers[id(current)] = node_index
|
|
137
|
+
total = len(current)
|
|
138
|
+
noun = "fields" if is_mapping else "items"
|
|
139
|
+
depth_limited = depth >= max_depth and total > 0
|
|
140
|
+
child_limited = total > max_children
|
|
141
|
+
detail = f"{total} {noun}"
|
|
142
|
+
if depth_limited:
|
|
143
|
+
detail += " · depth limit reached"
|
|
144
|
+
elif child_limited:
|
|
145
|
+
detail += f" · first {max_children} shown"
|
|
146
|
+
nodes.append(
|
|
147
|
+
InspectorNode(
|
|
148
|
+
parent_index,
|
|
149
|
+
bounded_key,
|
|
150
|
+
_bounded_inspector_text(detail, max_text_chars),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
if depth_limited or not total:
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
if is_mapping:
|
|
157
|
+
selected = list(islice(current.items(), max_children))
|
|
158
|
+
children = [
|
|
159
|
+
(
|
|
160
|
+
node_index,
|
|
161
|
+
_bounded_inspector_text(child_key, max_text_chars),
|
|
162
|
+
child,
|
|
163
|
+
depth + 1,
|
|
164
|
+
None,
|
|
165
|
+
)
|
|
166
|
+
for child_key, child in selected
|
|
167
|
+
]
|
|
168
|
+
else:
|
|
169
|
+
selected = current[:max_children]
|
|
170
|
+
children = [
|
|
171
|
+
(node_index, f"[{index}]", child, depth + 1, None)
|
|
172
|
+
for index, child in enumerate(selected)
|
|
173
|
+
]
|
|
174
|
+
if child_limited:
|
|
175
|
+
stack.append(
|
|
176
|
+
(
|
|
177
|
+
node_index,
|
|
178
|
+
"…",
|
|
179
|
+
None,
|
|
180
|
+
depth + 1,
|
|
181
|
+
f"{total - max_children} additional {noun} not shown",
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
stack.extend(reversed(children))
|
|
185
|
+
|
|
186
|
+
if stack:
|
|
187
|
+
root_index = 0 if nodes else None
|
|
188
|
+
nodes.append(
|
|
189
|
+
InspectorNode(
|
|
190
|
+
root_index,
|
|
191
|
+
"…",
|
|
192
|
+
_bounded_inspector_text(
|
|
193
|
+
f"node limit reached · first {data_node_limit} shown",
|
|
194
|
+
max_text_chars,
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
return tuple(nodes)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if QT_AVAILABLE:
|
|
202
|
+
class DocumentNavigator(QFrame):
|
|
203
|
+
"""Bundle document list with exact module/source identity controls."""
|
|
204
|
+
|
|
205
|
+
document_selected = pyqtSignal(str)
|
|
206
|
+
add_requested = pyqtSignal()
|
|
207
|
+
remove_requested = pyqtSignal(str)
|
|
208
|
+
move_up_requested = pyqtSignal(str)
|
|
209
|
+
move_down_requested = pyqtSignal(str)
|
|
210
|
+
identity_changed = pyqtSignal(str, str, str)
|
|
211
|
+
identity_pending_changed = pyqtSignal(bool)
|
|
212
|
+
primary_requested = pyqtSignal(str)
|
|
213
|
+
|
|
214
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
215
|
+
super().__init__(parent)
|
|
216
|
+
self.setObjectName("DocumentNavigator")
|
|
217
|
+
self.setMinimumWidth(230)
|
|
218
|
+
self.setMaximumWidth(360)
|
|
219
|
+
self._document_id: str | None = None
|
|
220
|
+
self._updating = False
|
|
221
|
+
self._identity_baseline: tuple[str, str] | None = None
|
|
222
|
+
self._identity_pending = False
|
|
223
|
+
|
|
224
|
+
heading = QLabel("SOURCE BUNDLE")
|
|
225
|
+
heading.setObjectName("PanelEyebrow")
|
|
226
|
+
self.count_label = QLabel("1 / 64")
|
|
227
|
+
self.count_label.setObjectName("MutedLabel")
|
|
228
|
+
|
|
229
|
+
self.add_button = QToolButton()
|
|
230
|
+
self.add_button.setObjectName("IconButton")
|
|
231
|
+
self.add_button.setIcon(QIcon(str(pycforge_icon_path("add-document"))))
|
|
232
|
+
self.add_button.setText("")
|
|
233
|
+
self.add_button.setToolTip("Add module document")
|
|
234
|
+
self.add_button.setAccessibleName("Add module document")
|
|
235
|
+
self.remove_button = QToolButton()
|
|
236
|
+
self.remove_button.setObjectName("IconButton")
|
|
237
|
+
self.remove_button.setIcon(
|
|
238
|
+
QIcon(str(pycforge_icon_path("remove-document")))
|
|
239
|
+
)
|
|
240
|
+
self.remove_button.setText("")
|
|
241
|
+
self.remove_button.setToolTip("Remove selected module document")
|
|
242
|
+
self.remove_button.setAccessibleName("Remove selected module document")
|
|
243
|
+
self.move_up_button = QToolButton()
|
|
244
|
+
self.move_up_button.setObjectName("IconButton")
|
|
245
|
+
self.move_up_button.setIcon(QIcon(str(pycforge_icon_path("move-up"))))
|
|
246
|
+
self.move_up_button.setText("")
|
|
247
|
+
self.move_up_button.setToolTip("Move selected module up (Alt+Up)")
|
|
248
|
+
self.move_up_button.setAccessibleName("Move selected module up")
|
|
249
|
+
self.move_up_button.setAccessibleDescription(
|
|
250
|
+
"Moves the selected document one position earlier in the source bundle."
|
|
251
|
+
)
|
|
252
|
+
self.move_up_button.setFocusPolicy(Qt.StrongFocus)
|
|
253
|
+
self.move_down_button = QToolButton()
|
|
254
|
+
self.move_down_button.setObjectName("IconButton")
|
|
255
|
+
self.move_down_button.setIcon(QIcon(str(pycforge_icon_path("move-down"))))
|
|
256
|
+
self.move_down_button.setText("")
|
|
257
|
+
self.move_down_button.setToolTip("Move selected module down (Alt+Down)")
|
|
258
|
+
self.move_down_button.setAccessibleName("Move selected module down")
|
|
259
|
+
self.move_down_button.setAccessibleDescription(
|
|
260
|
+
"Moves the selected document one position later in the source bundle."
|
|
261
|
+
)
|
|
262
|
+
self.move_down_button.setFocusPolicy(Qt.StrongFocus)
|
|
263
|
+
|
|
264
|
+
title_row = QHBoxLayout()
|
|
265
|
+
title_row.addWidget(heading)
|
|
266
|
+
title_row.addStretch(1)
|
|
267
|
+
title_row.addWidget(self.count_label)
|
|
268
|
+
title_row.addWidget(self.add_button)
|
|
269
|
+
title_row.addWidget(self.remove_button)
|
|
270
|
+
|
|
271
|
+
self.filter_edit = QLineEdit()
|
|
272
|
+
self.filter_edit.setObjectName("NavigatorFilter")
|
|
273
|
+
self.filter_edit.setPlaceholderText("Filter modules…")
|
|
274
|
+
self.filter_edit.setClearButtonEnabled(True)
|
|
275
|
+
self.filter_edit.setAccessibleName("Filter source bundle documents")
|
|
276
|
+
|
|
277
|
+
self.documents = QListWidget()
|
|
278
|
+
self.documents.setObjectName("DocumentList")
|
|
279
|
+
self.documents.setSelectionMode(QAbstractItemView.SingleSelection)
|
|
280
|
+
self.documents.setAlternatingRowColors(False)
|
|
281
|
+
self.documents.setMinimumHeight(68)
|
|
282
|
+
self.documents.setAccessibleName("Source bundle documents")
|
|
283
|
+
self.documents.setAccessibleDescription(
|
|
284
|
+
"Select a module document. Use Alt+Up or Alt+Down to change bundle order."
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
self.module_edit = QLineEdit()
|
|
288
|
+
self.module_edit.setPlaceholderText("module.id")
|
|
289
|
+
self.module_edit.setAccessibleName("Logical module ID")
|
|
290
|
+
self.logical_edit = QLineEdit()
|
|
291
|
+
self.logical_edit.setPlaceholderText("relative/source.py")
|
|
292
|
+
self.logical_edit.setAccessibleName("Logical source name")
|
|
293
|
+
self.primary_check = QCheckBox("Primary document")
|
|
294
|
+
self.primary_check.setAccessibleName("Make selected document primary")
|
|
295
|
+
self.path_label = QLabel("Unsaved document")
|
|
296
|
+
self.path_label.setObjectName("PathLabel")
|
|
297
|
+
self.path_label.setMinimumWidth(0)
|
|
298
|
+
self.path_label.setSizePolicy(
|
|
299
|
+
QSizePolicy.Ignored, QSizePolicy.Preferred
|
|
300
|
+
)
|
|
301
|
+
self.path_label.setToolTip("Unsaved document")
|
|
302
|
+
|
|
303
|
+
navigation_row = QHBoxLayout()
|
|
304
|
+
navigation_row.addWidget(self.filter_edit, 1)
|
|
305
|
+
navigation_row.addWidget(self.move_up_button)
|
|
306
|
+
navigation_row.addWidget(self.move_down_button)
|
|
307
|
+
|
|
308
|
+
identity_row = QHBoxLayout()
|
|
309
|
+
identity_row.setSpacing(5)
|
|
310
|
+
id_label = QLabel("ID")
|
|
311
|
+
id_label.setObjectName("MutedLabel")
|
|
312
|
+
source_label = QLabel("FILE")
|
|
313
|
+
source_label.setObjectName("MutedLabel")
|
|
314
|
+
identity_row.addWidget(id_label)
|
|
315
|
+
identity_row.addWidget(self.module_edit, 3)
|
|
316
|
+
identity_row.addWidget(source_label)
|
|
317
|
+
identity_row.addWidget(self.logical_edit, 2)
|
|
318
|
+
|
|
319
|
+
primary_row = QHBoxLayout()
|
|
320
|
+
primary_row.addWidget(self.primary_check)
|
|
321
|
+
primary_row.addWidget(self.path_label, 1)
|
|
322
|
+
|
|
323
|
+
layout = QVBoxLayout(self)
|
|
324
|
+
layout.setContentsMargins(10, 10, 10, 9)
|
|
325
|
+
layout.setSpacing(6)
|
|
326
|
+
layout.addLayout(title_row)
|
|
327
|
+
layout.addLayout(navigation_row)
|
|
328
|
+
layout.addWidget(self.documents, 1)
|
|
329
|
+
layout.addLayout(identity_row)
|
|
330
|
+
layout.addLayout(primary_row)
|
|
331
|
+
|
|
332
|
+
self.add_button.clicked.connect(self._request_add)
|
|
333
|
+
self.remove_button.clicked.connect(self._request_remove)
|
|
334
|
+
self.move_up_button.clicked.connect(self._request_move_up)
|
|
335
|
+
self.move_down_button.clicked.connect(self._request_move_down)
|
|
336
|
+
self.documents.currentItemChanged.connect(self._select_item)
|
|
337
|
+
self.filter_edit.textChanged.connect(self._filter)
|
|
338
|
+
self.module_edit.textChanged.connect(
|
|
339
|
+
self._update_identity_pending
|
|
340
|
+
)
|
|
341
|
+
self.logical_edit.textChanged.connect(
|
|
342
|
+
self._update_identity_pending
|
|
343
|
+
)
|
|
344
|
+
self.module_edit.editingFinished.connect(self._emit_identity)
|
|
345
|
+
self.logical_edit.editingFinished.connect(self._emit_identity)
|
|
346
|
+
self.primary_check.clicked.connect(self._request_primary)
|
|
347
|
+
|
|
348
|
+
def set_documents(self, documents: Iterable[Any], active_document_id: str | None) -> None:
|
|
349
|
+
records = tuple(documents)
|
|
350
|
+
self._updating = True
|
|
351
|
+
self.documents.clear()
|
|
352
|
+
selected: QListWidgetItem | None = None
|
|
353
|
+
for document in records:
|
|
354
|
+
states = []
|
|
355
|
+
if document.is_primary:
|
|
356
|
+
states.append("primary")
|
|
357
|
+
if document.dirty:
|
|
358
|
+
states.append("modified")
|
|
359
|
+
state_text = (
|
|
360
|
+
" — " + ", ".join(states) if states else ""
|
|
361
|
+
)
|
|
362
|
+
item = QListWidgetItem(
|
|
363
|
+
f"{document.module_id}{state_text}\n"
|
|
364
|
+
f" {document.logical_name}"
|
|
365
|
+
)
|
|
366
|
+
item.setIcon(
|
|
367
|
+
QIcon(
|
|
368
|
+
str(
|
|
369
|
+
pycforge_icon_path(
|
|
370
|
+
"primary-module"
|
|
371
|
+
if document.is_primary
|
|
372
|
+
else "module"
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
item.setData(Qt.UserRole, document.document_id)
|
|
378
|
+
item.setData(Qt.UserRole + 1, document)
|
|
379
|
+
item.setData(
|
|
380
|
+
Qt.AccessibleTextRole,
|
|
381
|
+
(
|
|
382
|
+
f"{document.module_id}, {document.logical_name}"
|
|
383
|
+
+ (
|
|
384
|
+
", " + ", ".join(states)
|
|
385
|
+
if states else ""
|
|
386
|
+
)
|
|
387
|
+
),
|
|
388
|
+
)
|
|
389
|
+
item.setToolTip(
|
|
390
|
+
f"{document.module_id}\n{document.logical_name}\n"
|
|
391
|
+
f"{document.path or 'Unsaved document'}"
|
|
392
|
+
)
|
|
393
|
+
self.documents.addItem(item)
|
|
394
|
+
if document.document_id == active_document_id:
|
|
395
|
+
selected = item
|
|
396
|
+
self.count_label.setText(f"{len(records)} / 64")
|
|
397
|
+
self.count_label.setAccessibleName(
|
|
398
|
+
f"{len(records)} source bundle document"
|
|
399
|
+
+ ("" if len(records) == 1 else "s")
|
|
400
|
+
)
|
|
401
|
+
self.remove_button.setEnabled(len(records) > 1)
|
|
402
|
+
if selected is not None:
|
|
403
|
+
self.documents.setCurrentItem(selected)
|
|
404
|
+
self._show_document(selected.data(Qt.UserRole + 1))
|
|
405
|
+
self._updating = False
|
|
406
|
+
self._filter(self.filter_edit.text())
|
|
407
|
+
self._update_move_buttons()
|
|
408
|
+
|
|
409
|
+
def _show_document(self, document: Any) -> None:
|
|
410
|
+
was_updating = self._updating
|
|
411
|
+
self._updating = True
|
|
412
|
+
self._document_id = document.document_id
|
|
413
|
+
try:
|
|
414
|
+
self.module_edit.setText(document.module_id)
|
|
415
|
+
self.logical_edit.setText(document.logical_name)
|
|
416
|
+
self.primary_check.setChecked(bool(document.is_primary))
|
|
417
|
+
self.primary_check.setEnabled(not document.is_primary)
|
|
418
|
+
self.path_label.setText(document.path or "Unsaved")
|
|
419
|
+
self.path_label.setToolTip(
|
|
420
|
+
document.path or "Unsaved document"
|
|
421
|
+
)
|
|
422
|
+
finally:
|
|
423
|
+
self._updating = was_updating
|
|
424
|
+
self._identity_baseline = (
|
|
425
|
+
document.module_id,
|
|
426
|
+
document.logical_name,
|
|
427
|
+
)
|
|
428
|
+
self._set_identity_pending(False)
|
|
429
|
+
|
|
430
|
+
@property
|
|
431
|
+
def current_document_id(self) -> str | None:
|
|
432
|
+
return self._document_id
|
|
433
|
+
|
|
434
|
+
def pending_identity(self) -> tuple[str, str]:
|
|
435
|
+
return (
|
|
436
|
+
self.module_edit.text().strip(),
|
|
437
|
+
self.logical_edit.text().strip(),
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
@property
|
|
441
|
+
def identity_pending(self) -> bool:
|
|
442
|
+
return self._identity_pending
|
|
443
|
+
|
|
444
|
+
def bind_action_registry(self, registry: Any) -> None:
|
|
445
|
+
"""Move button commands and widget shortcuts into *registry*."""
|
|
446
|
+
|
|
447
|
+
bindings = (
|
|
448
|
+
(
|
|
449
|
+
"bundle.new_module",
|
|
450
|
+
self.add_button,
|
|
451
|
+
self._request_add,
|
|
452
|
+
),
|
|
453
|
+
(
|
|
454
|
+
"bundle.remove_module",
|
|
455
|
+
self.remove_button,
|
|
456
|
+
self._request_remove,
|
|
457
|
+
),
|
|
458
|
+
(
|
|
459
|
+
"bundle.move_up",
|
|
460
|
+
self.move_up_button,
|
|
461
|
+
self._request_move_up,
|
|
462
|
+
),
|
|
463
|
+
(
|
|
464
|
+
"bundle.move_down",
|
|
465
|
+
self.move_down_button,
|
|
466
|
+
self._request_move_down,
|
|
467
|
+
),
|
|
468
|
+
)
|
|
469
|
+
for action_id, button, handler in bindings:
|
|
470
|
+
try:
|
|
471
|
+
button.clicked.disconnect(handler)
|
|
472
|
+
except (RuntimeError, TypeError):
|
|
473
|
+
pass
|
|
474
|
+
registry.register_handler(action_id, handler)
|
|
475
|
+
registry.bind_tool_button(action_id, button)
|
|
476
|
+
registry.attach_to_widget("bundle.move_up", self)
|
|
477
|
+
registry.attach_to_widget("bundle.move_down", self)
|
|
478
|
+
|
|
479
|
+
def _select_item(
|
|
480
|
+
self,
|
|
481
|
+
current: QListWidgetItem | None,
|
|
482
|
+
previous: QListWidgetItem | None,
|
|
483
|
+
) -> None:
|
|
484
|
+
del previous
|
|
485
|
+
if current is None:
|
|
486
|
+
self._document_id = None
|
|
487
|
+
self._update_move_buttons()
|
|
488
|
+
return
|
|
489
|
+
self._show_document(current.data(Qt.UserRole + 1))
|
|
490
|
+
self._update_move_buttons()
|
|
491
|
+
if not self._updating:
|
|
492
|
+
self.document_selected.emit(str(current.data(Qt.UserRole)))
|
|
493
|
+
|
|
494
|
+
def _emit_identity(self) -> None:
|
|
495
|
+
if self._updating or self._document_id is None:
|
|
496
|
+
return
|
|
497
|
+
self.identity_changed.emit(
|
|
498
|
+
self._document_id,
|
|
499
|
+
self.module_edit.text().strip(),
|
|
500
|
+
self.logical_edit.text().strip(),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
def _update_identity_pending(self, _value: str) -> None:
|
|
504
|
+
if self._updating:
|
|
505
|
+
return
|
|
506
|
+
self._set_identity_pending(
|
|
507
|
+
self._identity_baseline is not None
|
|
508
|
+
and self.pending_identity() != self._identity_baseline
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def _set_identity_pending(self, pending: bool) -> None:
|
|
512
|
+
if pending == self._identity_pending:
|
|
513
|
+
return
|
|
514
|
+
self._identity_pending = pending
|
|
515
|
+
self.identity_pending_changed.emit(pending)
|
|
516
|
+
|
|
517
|
+
def _request_primary(self) -> None:
|
|
518
|
+
if not self._updating and self._document_id is not None:
|
|
519
|
+
self.primary_requested.emit(self._document_id)
|
|
520
|
+
|
|
521
|
+
def _request_add(self) -> None:
|
|
522
|
+
self.add_requested.emit()
|
|
523
|
+
|
|
524
|
+
def _request_remove(self) -> None:
|
|
525
|
+
if self._document_id is not None:
|
|
526
|
+
self.remove_requested.emit(self._document_id)
|
|
527
|
+
|
|
528
|
+
def _request_move_up(self) -> None:
|
|
529
|
+
if self._document_id is not None and self.move_up_button.isEnabled():
|
|
530
|
+
self.move_up_requested.emit(self._document_id)
|
|
531
|
+
|
|
532
|
+
def _request_move_down(self) -> None:
|
|
533
|
+
if self._document_id is not None and self.move_down_button.isEnabled():
|
|
534
|
+
self.move_down_requested.emit(self._document_id)
|
|
535
|
+
|
|
536
|
+
def _update_move_buttons(self) -> None:
|
|
537
|
+
row = self.documents.currentRow()
|
|
538
|
+
count = self.documents.count()
|
|
539
|
+
self.move_up_button.setEnabled(row > 0)
|
|
540
|
+
self.move_down_button.setEnabled(0 <= row < count - 1)
|
|
541
|
+
|
|
542
|
+
def _filter(self, value: str) -> None:
|
|
543
|
+
needle = value.casefold().strip()
|
|
544
|
+
for row in range(self.documents.count()):
|
|
545
|
+
item = self.documents.item(row)
|
|
546
|
+
item.setHidden(bool(needle and needle not in item.text().casefold()))
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
class DiagnosticsView(QWidget):
|
|
550
|
+
"""Filterable, navigable diagnostic list with remediation details."""
|
|
551
|
+
|
|
552
|
+
diagnostic_activated = pyqtSignal(object)
|
|
553
|
+
|
|
554
|
+
_SEVERITY_COLORS = {
|
|
555
|
+
"InternalError": PYCFORGE_COLORS.error_bright,
|
|
556
|
+
"Error": PYCFORGE_COLORS.error,
|
|
557
|
+
"Warning": PYCFORGE_COLORS.warning,
|
|
558
|
+
"Approximation": PYCFORGE_COLORS.violet_bright,
|
|
559
|
+
"Information": PYCFORGE_COLORS.blue_bright,
|
|
560
|
+
"Info": PYCFORGE_COLORS.blue_bright,
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
564
|
+
super().__init__(parent)
|
|
565
|
+
self.setObjectName("DiagnosticsView")
|
|
566
|
+
self.filter_edit = QLineEdit()
|
|
567
|
+
self.filter_edit.setPlaceholderText("Filter diagnostics…")
|
|
568
|
+
self.filter_edit.setClearButtonEnabled(True)
|
|
569
|
+
self.filter_edit.setAccessibleName("Filter diagnostics")
|
|
570
|
+
self.count_label = QLabel("No diagnostics")
|
|
571
|
+
self.count_label.setObjectName("MutedLabel")
|
|
572
|
+
self.tree = QTreeWidget()
|
|
573
|
+
self.tree.setObjectName("DiagnosticsTree")
|
|
574
|
+
self.tree.setColumnCount(5)
|
|
575
|
+
self.tree.setHeaderLabels(("Severity", "Code", "Message", "Module", "Line"))
|
|
576
|
+
self.tree.setRootIsDecorated(False)
|
|
577
|
+
self.tree.setAlternatingRowColors(True)
|
|
578
|
+
self.tree.setUniformRowHeights(True)
|
|
579
|
+
self.tree.setAccessibleName("Conversion diagnostics")
|
|
580
|
+
self.tree.header().setStretchLastSection(False)
|
|
581
|
+
self.tree.header().resizeSection(0, 105)
|
|
582
|
+
self.tree.header().resizeSection(1, 90)
|
|
583
|
+
self.tree.header().resizeSection(2, 520)
|
|
584
|
+
self.tree.header().resizeSection(3, 160)
|
|
585
|
+
self.tree.header().resizeSection(4, 55)
|
|
586
|
+
self.details = QTextBrowser()
|
|
587
|
+
self.details.setObjectName("DiagnosticDetails")
|
|
588
|
+
self.details.setOpenExternalLinks(False)
|
|
589
|
+
self.details.setMaximumHeight(130)
|
|
590
|
+
self.details.setPlaceholderText("Select a diagnostic for explanation and remediation.")
|
|
591
|
+
|
|
592
|
+
top = QHBoxLayout()
|
|
593
|
+
top.addWidget(self.filter_edit, 1)
|
|
594
|
+
top.addWidget(self.count_label)
|
|
595
|
+
layout = QVBoxLayout(self)
|
|
596
|
+
layout.setContentsMargins(8, 8, 8, 8)
|
|
597
|
+
layout.setSpacing(7)
|
|
598
|
+
layout.addLayout(top)
|
|
599
|
+
layout.addWidget(self.tree, 1)
|
|
600
|
+
layout.addWidget(self.details)
|
|
601
|
+
|
|
602
|
+
self.filter_edit.textChanged.connect(self._filter)
|
|
603
|
+
self.tree.currentItemChanged.connect(self._show_details)
|
|
604
|
+
self.tree.itemActivated.connect(self._activate)
|
|
605
|
+
|
|
606
|
+
def set_diagnostics(self, diagnostics: Iterable[dict[str, Any]]) -> None:
|
|
607
|
+
records = tuple(diagnostics)
|
|
608
|
+
self.tree.clear()
|
|
609
|
+
for diagnostic in records:
|
|
610
|
+
span = diagnostic.get("source_span") or {}
|
|
611
|
+
start = span.get("start") or {}
|
|
612
|
+
line = start.get("line")
|
|
613
|
+
module = diagnostic.get("source_module_id") or diagnostic.get(
|
|
614
|
+
"source_logical_name"
|
|
615
|
+
) or "—"
|
|
616
|
+
item = QTreeWidgetItem(
|
|
617
|
+
(
|
|
618
|
+
str(diagnostic.get("severity") or ""),
|
|
619
|
+
str(diagnostic.get("code") or ""),
|
|
620
|
+
str(diagnostic.get("message") or ""),
|
|
621
|
+
str(module),
|
|
622
|
+
str(line or "—"),
|
|
623
|
+
)
|
|
624
|
+
)
|
|
625
|
+
item.setData(0, Qt.UserRole, diagnostic)
|
|
626
|
+
color = self._SEVERITY_COLORS.get(
|
|
627
|
+
str(diagnostic.get("severity")),
|
|
628
|
+
PYCFORGE_COLORS.text_muted,
|
|
629
|
+
)
|
|
630
|
+
item.setForeground(0, QBrush(QColor(color)))
|
|
631
|
+
self.tree.addTopLevelItem(item)
|
|
632
|
+
self.count_label.setText(
|
|
633
|
+
"No diagnostics"
|
|
634
|
+
if not records
|
|
635
|
+
else f"{len(records)} diagnostic" + ("" if len(records) == 1 else "s")
|
|
636
|
+
)
|
|
637
|
+
self.details.clear()
|
|
638
|
+
self._filter(self.filter_edit.text())
|
|
639
|
+
|
|
640
|
+
def line_markers(self, logical_name: str) -> tuple[tuple[int, str], ...]:
|
|
641
|
+
result = []
|
|
642
|
+
for row in range(self.tree.topLevelItemCount()):
|
|
643
|
+
diagnostic = self.tree.topLevelItem(row).data(0, Qt.UserRole)
|
|
644
|
+
if diagnostic.get("source_logical_name") != logical_name:
|
|
645
|
+
continue
|
|
646
|
+
span = diagnostic.get("source_span") or {}
|
|
647
|
+
line = (span.get("start") or {}).get("line")
|
|
648
|
+
if isinstance(line, int):
|
|
649
|
+
result.append((line, str(diagnostic.get("severity") or "Error")))
|
|
650
|
+
return tuple(result)
|
|
651
|
+
|
|
652
|
+
def _show_details(
|
|
653
|
+
self,
|
|
654
|
+
current: QTreeWidgetItem | None,
|
|
655
|
+
previous: QTreeWidgetItem | None,
|
|
656
|
+
) -> None:
|
|
657
|
+
del previous
|
|
658
|
+
if current is None:
|
|
659
|
+
self.details.clear()
|
|
660
|
+
return
|
|
661
|
+
diagnostic = current.data(0, Qt.UserRole)
|
|
662
|
+
blocks = [
|
|
663
|
+
f"{diagnostic.get('code', '')} — {diagnostic.get('message', '')}",
|
|
664
|
+
str(diagnostic.get("explanation") or ""),
|
|
665
|
+
]
|
|
666
|
+
remediation = diagnostic.get("remediation")
|
|
667
|
+
if remediation:
|
|
668
|
+
blocks.append(f"Suggested action: {remediation}")
|
|
669
|
+
self.details.setPlainText("\n\n".join(item for item in blocks if item))
|
|
670
|
+
|
|
671
|
+
def _activate(self, item: QTreeWidgetItem, column: int) -> None:
|
|
672
|
+
del column
|
|
673
|
+
self.diagnostic_activated.emit(item.data(0, Qt.UserRole))
|
|
674
|
+
|
|
675
|
+
def _filter(self, value: str) -> None:
|
|
676
|
+
needle = value.casefold().strip()
|
|
677
|
+
for row in range(self.tree.topLevelItemCount()):
|
|
678
|
+
item = self.tree.topLevelItem(row)
|
|
679
|
+
haystack = " ".join(item.text(column) for column in range(5)).casefold()
|
|
680
|
+
item.setHidden(bool(needle and needle not in haystack))
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
class InspectorTree(QWidget):
|
|
684
|
+
"""Searchable structured projection for summaries, traces, and telemetry."""
|
|
685
|
+
|
|
686
|
+
def __init__(self, label: str, parent: QWidget | None = None) -> None:
|
|
687
|
+
super().__init__(parent)
|
|
688
|
+
self.setObjectName("InspectorTree")
|
|
689
|
+
self.filter_edit = QLineEdit()
|
|
690
|
+
self.filter_edit.setPlaceholderText(f"Filter {label.casefold()}…")
|
|
691
|
+
self.filter_edit.setClearButtonEnabled(True)
|
|
692
|
+
self.filter_edit.setAccessibleName(f"Filter {label.casefold()}")
|
|
693
|
+
self.tree = QTreeWidget()
|
|
694
|
+
self.tree.setColumnCount(2)
|
|
695
|
+
self.tree.setHeaderLabels(("Field", "Value"))
|
|
696
|
+
self.tree.setAlternatingRowColors(True)
|
|
697
|
+
self.tree.setAccessibleName(label)
|
|
698
|
+
self.tree.header().resizeSection(0, 310)
|
|
699
|
+
layout = QVBoxLayout(self)
|
|
700
|
+
layout.setContentsMargins(8, 8, 8, 8)
|
|
701
|
+
layout.setSpacing(7)
|
|
702
|
+
layout.addWidget(self.filter_edit)
|
|
703
|
+
layout.addWidget(self.tree, 1)
|
|
704
|
+
self.filter_edit.textChanged.connect(self._filter)
|
|
705
|
+
|
|
706
|
+
def set_data(self, value: Any) -> None:
|
|
707
|
+
self.tree.clear()
|
|
708
|
+
items: list[QTreeWidgetItem] = []
|
|
709
|
+
root = self.tree.invisibleRootItem()
|
|
710
|
+
for node in project_inspector_tree(value):
|
|
711
|
+
parent = (
|
|
712
|
+
root
|
|
713
|
+
if node.parent_index is None
|
|
714
|
+
else items[node.parent_index]
|
|
715
|
+
)
|
|
716
|
+
item = QTreeWidgetItem((node.key, node.value))
|
|
717
|
+
parent.addChild(item)
|
|
718
|
+
items.append(item)
|
|
719
|
+
self.tree.expandToDepth(1)
|
|
720
|
+
self._filter(self.filter_edit.text())
|
|
721
|
+
|
|
722
|
+
def _filter(self, value: str) -> None:
|
|
723
|
+
needle = value.casefold().strip()
|
|
724
|
+
|
|
725
|
+
def visit(item: QTreeWidgetItem) -> bool:
|
|
726
|
+
own = needle in (item.text(0) + " " + item.text(1)).casefold()
|
|
727
|
+
child_match = False
|
|
728
|
+
for index in range(item.childCount()):
|
|
729
|
+
child_match = visit(item.child(index)) or child_match
|
|
730
|
+
visible = not needle or own or child_match
|
|
731
|
+
item.setHidden(not visible)
|
|
732
|
+
if child_match and needle:
|
|
733
|
+
item.setExpanded(True)
|
|
734
|
+
return visible and (own or child_match)
|
|
735
|
+
|
|
736
|
+
for index in range(self.tree.topLevelItemCount()):
|
|
737
|
+
visit(self.tree.topLevelItem(index))
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
class MappingsView(QWidget):
|
|
741
|
+
"""Filterable source-to-output provenance with direct C navigation."""
|
|
742
|
+
|
|
743
|
+
mapping_activated = pyqtSignal(object)
|
|
744
|
+
DISPLAY_LIMIT = 5000
|
|
745
|
+
|
|
746
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
747
|
+
super().__init__(parent)
|
|
748
|
+
self.setObjectName("MappingsView")
|
|
749
|
+
self.filter_edit = QLineEdit()
|
|
750
|
+
self.filter_edit.setPlaceholderText("Filter source mappings…")
|
|
751
|
+
self.filter_edit.setClearButtonEnabled(True)
|
|
752
|
+
self.filter_edit.setAccessibleName("Filter source-to-output mappings")
|
|
753
|
+
self.count_label = QLabel("No mappings")
|
|
754
|
+
self.count_label.setObjectName("MutedLabel")
|
|
755
|
+
self.tree = QTreeWidget()
|
|
756
|
+
self.tree.setObjectName("MappingsTree")
|
|
757
|
+
self.tree.setColumnCount(5)
|
|
758
|
+
self.tree.setHeaderLabels(("Module", "Origin", "Rule", "C range", "C node"))
|
|
759
|
+
self.tree.setRootIsDecorated(False)
|
|
760
|
+
self.tree.setAlternatingRowColors(True)
|
|
761
|
+
self.tree.setUniformRowHeights(True)
|
|
762
|
+
self.tree.setAccessibleName("Source-to-output mappings")
|
|
763
|
+
self.tree.header().setStretchLastSection(True)
|
|
764
|
+
self.tree.header().resizeSection(0, 170)
|
|
765
|
+
self.tree.header().resizeSection(1, 190)
|
|
766
|
+
self.tree.header().resizeSection(2, 210)
|
|
767
|
+
self.tree.header().resizeSection(3, 130)
|
|
768
|
+
|
|
769
|
+
top = QHBoxLayout()
|
|
770
|
+
top.addWidget(self.filter_edit, 1)
|
|
771
|
+
top.addWidget(self.count_label)
|
|
772
|
+
layout = QVBoxLayout(self)
|
|
773
|
+
layout.setContentsMargins(8, 8, 8, 8)
|
|
774
|
+
layout.setSpacing(7)
|
|
775
|
+
layout.addLayout(top)
|
|
776
|
+
layout.addWidget(self.tree, 1)
|
|
777
|
+
|
|
778
|
+
self.filter_edit.textChanged.connect(self._filter)
|
|
779
|
+
self.tree.itemActivated.connect(self._activate)
|
|
780
|
+
|
|
781
|
+
def set_mappings(self, mappings: Iterable[dict[str, Any]]) -> None:
|
|
782
|
+
records = tuple(mappings)
|
|
783
|
+
self.tree.clear()
|
|
784
|
+
for mapping in records[: self.DISPLAY_LIMIT]:
|
|
785
|
+
positions = (
|
|
786
|
+
mapping.get("start_line"),
|
|
787
|
+
mapping.get("start_column"),
|
|
788
|
+
mapping.get("end_line"),
|
|
789
|
+
mapping.get("end_column"),
|
|
790
|
+
)
|
|
791
|
+
location = (
|
|
792
|
+
f"{positions[0]}:{positions[1]}–{positions[2]}:{positions[3]}"
|
|
793
|
+
if all(isinstance(value, int) for value in positions)
|
|
794
|
+
else "—"
|
|
795
|
+
)
|
|
796
|
+
module = mapping.get("module_id") or mapping.get(
|
|
797
|
+
"logical_source_name"
|
|
798
|
+
) or "synthetic"
|
|
799
|
+
item = QTreeWidgetItem(
|
|
800
|
+
(
|
|
801
|
+
str(module),
|
|
802
|
+
str(mapping.get("origin_kind") or ""),
|
|
803
|
+
str(mapping.get("rule_plan_id") or "—"),
|
|
804
|
+
location,
|
|
805
|
+
str(mapping.get("c_node_id") or ""),
|
|
806
|
+
)
|
|
807
|
+
)
|
|
808
|
+
item.setData(0, Qt.UserRole, mapping)
|
|
809
|
+
self.tree.addTopLevelItem(item)
|
|
810
|
+
if not records:
|
|
811
|
+
label = "No mappings"
|
|
812
|
+
elif len(records) > self.DISPLAY_LIMIT:
|
|
813
|
+
label = f"{self.DISPLAY_LIMIT} of {len(records)} mappings"
|
|
814
|
+
else:
|
|
815
|
+
label = f"{len(records)} mapping" + ("" if len(records) == 1 else "s")
|
|
816
|
+
self.count_label.setText(label)
|
|
817
|
+
self._filter(self.filter_edit.text())
|
|
818
|
+
|
|
819
|
+
def _activate(self, item: QTreeWidgetItem, column: int) -> None:
|
|
820
|
+
del column
|
|
821
|
+
self.mapping_activated.emit(item.data(0, Qt.UserRole))
|
|
822
|
+
|
|
823
|
+
def _filter(self, value: str) -> None:
|
|
824
|
+
needle = value.casefold().strip()
|
|
825
|
+
for row in range(self.tree.topLevelItemCount()):
|
|
826
|
+
item = self.tree.topLevelItem(row)
|
|
827
|
+
haystack = " ".join(item.text(column) for column in range(5)).casefold()
|
|
828
|
+
item.setHidden(bool(needle and needle not in haystack))
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
class ToastBanner(QFrame):
|
|
832
|
+
dismissed = pyqtSignal()
|
|
833
|
+
|
|
834
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
835
|
+
super().__init__(parent)
|
|
836
|
+
self.setObjectName("ToastBanner")
|
|
837
|
+
self.setProperty("tone", "info")
|
|
838
|
+
self.label = QLabel()
|
|
839
|
+
self.label.setWordWrap(True)
|
|
840
|
+
self.close_button = QToolButton()
|
|
841
|
+
self.close_button.setObjectName("IconButton")
|
|
842
|
+
self.close_button.setIcon(
|
|
843
|
+
QIcon(str(pycforge_icon_path("close")))
|
|
844
|
+
)
|
|
845
|
+
self.close_button.setText("")
|
|
846
|
+
self.close_button.setToolTip("Dismiss notification")
|
|
847
|
+
self.close_button.setAccessibleName("Dismiss notification")
|
|
848
|
+
layout = QHBoxLayout(self)
|
|
849
|
+
layout.setContentsMargins(12, 8, 8, 8)
|
|
850
|
+
layout.addWidget(self.label, 1)
|
|
851
|
+
layout.addWidget(self.close_button)
|
|
852
|
+
self.close_button.clicked.connect(self.hide)
|
|
853
|
+
self.close_button.clicked.connect(self.dismissed.emit)
|
|
854
|
+
self.hide()
|
|
855
|
+
|
|
856
|
+
def show_message(self, message: str, tone: str = "info") -> None:
|
|
857
|
+
self.setProperty("tone", tone)
|
|
858
|
+
self.style().unpolish(self)
|
|
859
|
+
self.style().polish(self)
|
|
860
|
+
self.label.setText(message)
|
|
861
|
+
self.show()
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
else:
|
|
865
|
+
class _QtRequired:
|
|
866
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
867
|
+
raise RuntimeError("PyQt5 is required for workspace panels") from _QT_ERROR
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
class DocumentNavigator(_QtRequired):
|
|
871
|
+
pass
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
class DiagnosticsView(_QtRequired):
|
|
875
|
+
pass
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
class InspectorTree(_QtRequired):
|
|
879
|
+
pass
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
class MappingsView(_QtRequired):
|
|
883
|
+
pass
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
class ToastBanner(_QtRequired):
|
|
887
|
+
pass
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
__all__ = [
|
|
891
|
+
"INSPECTOR_TREE_MAX_CHILDREN",
|
|
892
|
+
"INSPECTOR_TREE_MAX_DEPTH",
|
|
893
|
+
"INSPECTOR_TREE_MAX_NODES",
|
|
894
|
+
"INSPECTOR_TREE_MAX_TEXT_CHARS",
|
|
895
|
+
"QT_AVAILABLE",
|
|
896
|
+
"DocumentNavigator",
|
|
897
|
+
"DiagnosticsView",
|
|
898
|
+
"InspectorNode",
|
|
899
|
+
"InspectorTree",
|
|
900
|
+
"MappingsView",
|
|
901
|
+
"ToastBanner",
|
|
902
|
+
"project_inspector_tree",
|
|
903
|
+
]
|