drawsvg-ui 0.4.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.
- app_info.py +61 -0
- canvas_view.py +2506 -0
- constants.py +49 -0
- drawsvg_ui-0.4.0.dist-info/METADATA +86 -0
- drawsvg_ui-0.4.0.dist-info/RECORD +28 -0
- drawsvg_ui-0.4.0.dist-info/WHEEL +5 -0
- drawsvg_ui-0.4.0.dist-info/entry_points.txt +2 -0
- drawsvg_ui-0.4.0.dist-info/top_level.txt +11 -0
- export_drawsvg.py +1700 -0
- import_drawsvg.py +807 -0
- items/__init__.py +66 -0
- items/base.py +606 -0
- items/labels.py +247 -0
- items/shapes/__init__.py +20 -0
- items/shapes/curves.py +139 -0
- items/shapes/lines.py +439 -0
- items/shapes/polygons.py +359 -0
- items/shapes/rects.py +310 -0
- items/text.py +331 -0
- items/widgets/__init__.py +5 -0
- items/widgets/folder_tree.py +415 -0
- main.py +23 -0
- main_window.py +254 -0
- palette.py +556 -0
- properties_panel.py +1406 -0
- ui/__init__.py +1 -0
- ui/main_window.ui +157 -0
- ui/properties_panel.ui +996 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""Interactive folder tree widget item."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
from PySide6 import QtCore, QtGui, QtWidgets
|
|
8
|
+
|
|
9
|
+
from constants import PEN_SELECTED, DEFAULT_FONT_FAMILY
|
|
10
|
+
from ..base import HandleAwareItemMixin, _should_draw_selection
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FolderTreeNode:
|
|
14
|
+
__slots__ = ("name", "is_folder", "children", "parent")
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
name: str,
|
|
19
|
+
is_folder: bool = True,
|
|
20
|
+
parent: "FolderTreeNode | None" = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
self.name = name
|
|
23
|
+
self.is_folder = is_folder
|
|
24
|
+
self.parent = parent
|
|
25
|
+
self.children: list["FolderTreeNode"] = []
|
|
26
|
+
|
|
27
|
+
def add_child(self, child: "FolderTreeNode") -> "FolderTreeNode":
|
|
28
|
+
child.parent = self
|
|
29
|
+
self.children.append(child)
|
|
30
|
+
return child
|
|
31
|
+
|
|
32
|
+
def remove_child(self, child: "FolderTreeNode") -> None:
|
|
33
|
+
try:
|
|
34
|
+
self.children.remove(child)
|
|
35
|
+
child.parent = None
|
|
36
|
+
except ValueError:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"name": self.name,
|
|
42
|
+
"folder": self.is_folder,
|
|
43
|
+
"children": [child.to_dict() for child in self.children],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def from_dict(
|
|
48
|
+
data: Mapping[str, Any],
|
|
49
|
+
parent: "FolderTreeNode | None" = None,
|
|
50
|
+
) -> "FolderTreeNode":
|
|
51
|
+
name = str(data.get("name", "node"))
|
|
52
|
+
is_folder = bool(data.get("folder", True))
|
|
53
|
+
node = FolderTreeNode(name, is_folder, parent)
|
|
54
|
+
for child_data in data.get("children", []):
|
|
55
|
+
if isinstance(child_data, Mapping):
|
|
56
|
+
node.add_child(FolderTreeNode.from_dict(child_data, node))
|
|
57
|
+
return node
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FolderTreeBranchDot(QtWidgets.QGraphicsEllipseItem):
|
|
61
|
+
def __init__(self, tree: "FolderTreeItem", node: FolderTreeNode, radius: float):
|
|
62
|
+
super().__init__(-radius, -radius, radius * 2.0, radius * 2.0, tree)
|
|
63
|
+
self._tree = tree
|
|
64
|
+
self._node = node
|
|
65
|
+
self.setBrush(QtGui.QColor("#f28c28"))
|
|
66
|
+
self.setPen(QtGui.QPen(QtCore.Qt.PenStyle.NoPen))
|
|
67
|
+
self.setAcceptedMouseButtons(
|
|
68
|
+
QtCore.Qt.MouseButton.LeftButton | QtCore.Qt.MouseButton.RightButton
|
|
69
|
+
)
|
|
70
|
+
self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
|
71
|
+
self.setFlag(
|
|
72
|
+
QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations,
|
|
73
|
+
True,
|
|
74
|
+
)
|
|
75
|
+
self.setZValue(1.0)
|
|
76
|
+
|
|
77
|
+
def node(self) -> FolderTreeNode:
|
|
78
|
+
return self._node
|
|
79
|
+
|
|
80
|
+
def mousePressEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent) -> None:
|
|
81
|
+
if event.button() in (
|
|
82
|
+
QtCore.Qt.MouseButton.LeftButton,
|
|
83
|
+
QtCore.Qt.MouseButton.RightButton,
|
|
84
|
+
):
|
|
85
|
+
self._tree.open_branch_menu(self._node, event.screenPos())
|
|
86
|
+
event.accept()
|
|
87
|
+
return
|
|
88
|
+
super().mousePressEvent(event)
|
|
89
|
+
|
|
90
|
+
def contextMenuEvent(self, event: QtWidgets.QGraphicsSceneContextMenuEvent) -> None:
|
|
91
|
+
self._tree.open_branch_menu(self._node, event.screenPos())
|
|
92
|
+
event.accept()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class FolderTreeItem(HandleAwareItemMixin, QtWidgets.QGraphicsItem):
|
|
96
|
+
LINE_COLOR = QtGui.QColor("#7a7a7a")
|
|
97
|
+
FOLDER_COLOR = QtGui.QColor("#000000")
|
|
98
|
+
FILE_COLOR = QtGui.QColor("#000000")
|
|
99
|
+
TEXT_COLOR = QtGui.QColor("#000000")
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
x: float,
|
|
104
|
+
y: float,
|
|
105
|
+
w: float,
|
|
106
|
+
h: float,
|
|
107
|
+
structure: Mapping[str, Any] | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
QtWidgets.QGraphicsItem.__init__(self)
|
|
110
|
+
self.setPos(x, y)
|
|
111
|
+
self.setFlags(
|
|
112
|
+
QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable
|
|
113
|
+
| QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
|
|
114
|
+
| QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
|
|
115
|
+
| QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsFocusable
|
|
116
|
+
)
|
|
117
|
+
self.setAcceptedMouseButtons(QtCore.Qt.MouseButton.LeftButton)
|
|
118
|
+
|
|
119
|
+
self._padding = 18.0
|
|
120
|
+
self._indent = 54.0
|
|
121
|
+
self._line_height = 28.0
|
|
122
|
+
self._dot_radius = 6.0
|
|
123
|
+
self._text_gap = 10.0
|
|
124
|
+
self._font = QtGui.QFont(DEFAULT_FONT_FAMILY, 11)
|
|
125
|
+
|
|
126
|
+
self._line_pen = QtGui.QPen(self.LINE_COLOR, 1.6)
|
|
127
|
+
self._line_pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
|
|
128
|
+
self._folder_pen = QtGui.QPen(self.FOLDER_COLOR)
|
|
129
|
+
self._file_pen = QtGui.QPen(self.FILE_COLOR)
|
|
130
|
+
|
|
131
|
+
if structure:
|
|
132
|
+
self._root = FolderTreeNode.from_dict(structure)
|
|
133
|
+
else:
|
|
134
|
+
self._root = self._build_default_structure()
|
|
135
|
+
|
|
136
|
+
self._node_info: dict[FolderTreeNode, dict[str, Any]] = {}
|
|
137
|
+
self._order: list[FolderTreeNode] = []
|
|
138
|
+
self._bounding_rect = QtCore.QRectF()
|
|
139
|
+
self._dot_items: dict[FolderTreeNode, FolderTreeBranchDot] = {}
|
|
140
|
+
|
|
141
|
+
self._rebuild_layout()
|
|
142
|
+
self.setTransformOriginPoint(self.boundingRect().center())
|
|
143
|
+
|
|
144
|
+
def update_handles(self) -> None:
|
|
145
|
+
"""Folder trees do not expose resize handles."""
|
|
146
|
+
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
def show_handles(self):
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
def hide_handles(self):
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
def boundingRect(self) -> QtCore.QRectF: # type: ignore[override]
|
|
156
|
+
return QtCore.QRectF(self._bounding_rect)
|
|
157
|
+
|
|
158
|
+
def paint(
|
|
159
|
+
self,
|
|
160
|
+
painter: QtGui.QPainter,
|
|
161
|
+
option: QtWidgets.QStyleOptionGraphicsItem,
|
|
162
|
+
widget: QtWidgets.QWidget | None = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
del option, widget
|
|
165
|
+
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True)
|
|
166
|
+
|
|
167
|
+
if _should_draw_selection(self):
|
|
168
|
+
painter.save()
|
|
169
|
+
painter.setPen(PEN_SELECTED)
|
|
170
|
+
painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
|
|
171
|
+
painter.drawRect(self.boundingRect())
|
|
172
|
+
painter.restore()
|
|
173
|
+
|
|
174
|
+
painter.setPen(self._line_pen)
|
|
175
|
+
painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
|
|
176
|
+
for node in self._order:
|
|
177
|
+
parent = node.parent
|
|
178
|
+
if parent is None:
|
|
179
|
+
continue
|
|
180
|
+
info = self._node_info[node]
|
|
181
|
+
parent_info = self._node_info[parent]
|
|
182
|
+
parent_center: QtCore.QPointF = parent_info["dot_center"]
|
|
183
|
+
child_center: QtCore.QPointF = info["dot_center"]
|
|
184
|
+
offset = self._dot_radius - 1.0
|
|
185
|
+
start = QtCore.QPointF(parent_center.x(), parent_center.y() + offset)
|
|
186
|
+
end = QtCore.QPointF(parent_center.x(), child_center.y())
|
|
187
|
+
painter.drawLine(start, end)
|
|
188
|
+
start_horizontal = QtCore.QPointF(parent_center.x(), child_center.y())
|
|
189
|
+
end_horizontal = QtCore.QPointF(
|
|
190
|
+
child_center.x() - (self._dot_radius - 1.0), child_center.y()
|
|
191
|
+
)
|
|
192
|
+
painter.drawLine(start_horizontal, end_horizontal)
|
|
193
|
+
|
|
194
|
+
painter.setFont(self._font)
|
|
195
|
+
for node in self._order:
|
|
196
|
+
info = self._node_info[node]
|
|
197
|
+
text_rect: QtCore.QRectF = info["text_rect"]
|
|
198
|
+
label = self._node_label(node)
|
|
199
|
+
painter.setPen(self._folder_pen if node.is_folder else self._file_pen)
|
|
200
|
+
painter.drawText(
|
|
201
|
+
text_rect,
|
|
202
|
+
QtCore.Qt.AlignmentFlag.AlignVCenter | QtCore.Qt.AlignmentFlag.AlignLeft,
|
|
203
|
+
label,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def _node_label(self, node: FolderTreeNode) -> str:
|
|
207
|
+
return f"{node.name}/" if node.is_folder else node.name
|
|
208
|
+
|
|
209
|
+
def _build_default_structure(self) -> FolderTreeNode:
|
|
210
|
+
root = FolderTreeNode("projekt", True)
|
|
211
|
+
docs = root.add_child(FolderTreeNode("docs", True))
|
|
212
|
+
api = docs.add_child(FolderTreeNode("api", True))
|
|
213
|
+
api.add_child(FolderTreeNode("index.md", False))
|
|
214
|
+
src = root.add_child(FolderTreeNode("src", True))
|
|
215
|
+
src.add_child(FolderTreeNode("app", True))
|
|
216
|
+
src.add_child(FolderTreeNode("main.py", False))
|
|
217
|
+
tests = root.add_child(FolderTreeNode("tests", True))
|
|
218
|
+
tests.add_child(FolderTreeNode("test_main.py", False))
|
|
219
|
+
return root
|
|
220
|
+
|
|
221
|
+
def _rebuild_layout(self) -> None:
|
|
222
|
+
fm = QtGui.QFontMetricsF(self._font)
|
|
223
|
+
self._line_height = max(self._line_height, fm.height() + 8.0)
|
|
224
|
+
|
|
225
|
+
self._node_info.clear()
|
|
226
|
+
self._order.clear()
|
|
227
|
+
|
|
228
|
+
def traverse(node: FolderTreeNode, depth: int) -> None:
|
|
229
|
+
info = {"depth": depth, "row": len(self._order)}
|
|
230
|
+
self._node_info[node] = info
|
|
231
|
+
self._order.append(node)
|
|
232
|
+
for child in node.children:
|
|
233
|
+
traverse(child, depth + 1)
|
|
234
|
+
|
|
235
|
+
traverse(self._root, 0)
|
|
236
|
+
|
|
237
|
+
max_text_right = self._padding
|
|
238
|
+
for node in self._order:
|
|
239
|
+
info = self._node_info[node]
|
|
240
|
+
depth = info["depth"]
|
|
241
|
+
row = info["row"]
|
|
242
|
+
y = self._padding + row * self._line_height
|
|
243
|
+
x = self._padding + depth * self._indent
|
|
244
|
+
text_x = x + self._dot_radius + self._text_gap
|
|
245
|
+
label = self._node_label(node)
|
|
246
|
+
text_width = fm.horizontalAdvance(label)
|
|
247
|
+
max_text_right = max(max_text_right, text_x + text_width)
|
|
248
|
+
info["dot_center"] = QtCore.QPointF(x, y + self._line_height / 2.0)
|
|
249
|
+
info["text_x"] = text_x
|
|
250
|
+
info["y"] = y
|
|
251
|
+
|
|
252
|
+
width = max_text_right + self._padding
|
|
253
|
+
min_width = self._padding * 2.0 + self._indent
|
|
254
|
+
width = max(width, min_width)
|
|
255
|
+
height = self._padding * 2.0 + max(1, len(self._order)) * self._line_height
|
|
256
|
+
|
|
257
|
+
self.prepareGeometryChange()
|
|
258
|
+
self._bounding_rect = QtCore.QRectF(0.0, 0.0, width, height)
|
|
259
|
+
|
|
260
|
+
for node in self._order:
|
|
261
|
+
info = self._node_info[node]
|
|
262
|
+
text_rect = QtCore.QRectF(
|
|
263
|
+
info["text_x"],
|
|
264
|
+
info["y"],
|
|
265
|
+
width - info["text_x"] - self._padding,
|
|
266
|
+
self._line_height,
|
|
267
|
+
)
|
|
268
|
+
info["text_rect"] = text_rect
|
|
269
|
+
|
|
270
|
+
self._update_branch_dots()
|
|
271
|
+
self.setTransformOriginPoint(self._bounding_rect.center())
|
|
272
|
+
self.update()
|
|
273
|
+
|
|
274
|
+
def _update_branch_dots(self) -> None:
|
|
275
|
+
current_nodes = set(self._order)
|
|
276
|
+
for node in list(self._dot_items.keys()):
|
|
277
|
+
if node not in current_nodes:
|
|
278
|
+
dot = self._dot_items.pop(node)
|
|
279
|
+
dot.setParentItem(None)
|
|
280
|
+
if dot.scene() is not None:
|
|
281
|
+
dot.scene().removeItem(dot)
|
|
282
|
+
for node in self._order:
|
|
283
|
+
info = self._node_info[node]
|
|
284
|
+
dot = self._dot_items.get(node)
|
|
285
|
+
if dot is None:
|
|
286
|
+
dot = FolderTreeBranchDot(self, node, self._dot_radius)
|
|
287
|
+
self._dot_items[node] = dot
|
|
288
|
+
dot.setPos(info["dot_center"])
|
|
289
|
+
|
|
290
|
+
def open_branch_menu(
|
|
291
|
+
self,
|
|
292
|
+
node: FolderTreeNode,
|
|
293
|
+
global_pos: QtCore.QPointF | QtCore.QPoint,
|
|
294
|
+
) -> None:
|
|
295
|
+
menu = QtWidgets.QMenu()
|
|
296
|
+
add_folder_action = None
|
|
297
|
+
add_file_action = None
|
|
298
|
+
delete_action = None
|
|
299
|
+
|
|
300
|
+
if node.is_folder:
|
|
301
|
+
add_folder_action = menu.addAction("Add Folder")
|
|
302
|
+
add_file_action = menu.addAction("Add File")
|
|
303
|
+
if node.parent is not None:
|
|
304
|
+
if menu.actions():
|
|
305
|
+
menu.addSeparator()
|
|
306
|
+
delete_action = menu.addAction("Remove Entry")
|
|
307
|
+
|
|
308
|
+
if isinstance(global_pos, QtCore.QPointF):
|
|
309
|
+
global_point = QtCore.QPoint(round(global_pos.x()), round(global_pos.y()))
|
|
310
|
+
else:
|
|
311
|
+
global_point = global_pos
|
|
312
|
+
|
|
313
|
+
selected = menu.exec(global_point)
|
|
314
|
+
if not selected:
|
|
315
|
+
return
|
|
316
|
+
if selected is add_folder_action:
|
|
317
|
+
self._create_child(node, True)
|
|
318
|
+
elif selected is add_file_action:
|
|
319
|
+
self._create_child(node, False)
|
|
320
|
+
elif selected is delete_action:
|
|
321
|
+
self._delete_node(node)
|
|
322
|
+
|
|
323
|
+
def _view_widget(self) -> QtWidgets.QWidget | None:
|
|
324
|
+
scene = self.scene()
|
|
325
|
+
if scene and scene.views():
|
|
326
|
+
return scene.views()[0]
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
def _prompt_name(
|
|
330
|
+
self,
|
|
331
|
+
title: str,
|
|
332
|
+
label: str,
|
|
333
|
+
default: str = "",
|
|
334
|
+
) -> str | None:
|
|
335
|
+
parent = self._view_widget()
|
|
336
|
+
text, ok = QtWidgets.QInputDialog.getText(
|
|
337
|
+
parent,
|
|
338
|
+
title,
|
|
339
|
+
label,
|
|
340
|
+
QtWidgets.QLineEdit.EchoMode.Normal,
|
|
341
|
+
default,
|
|
342
|
+
)
|
|
343
|
+
if not ok:
|
|
344
|
+
return None
|
|
345
|
+
name = text.strip()
|
|
346
|
+
return name or None
|
|
347
|
+
|
|
348
|
+
def _create_child(self, parent: FolderTreeNode, is_folder: bool) -> None:
|
|
349
|
+
title = "Add Folder" if is_folder else "Add File"
|
|
350
|
+
prompt = "Folder name:" if is_folder else "File name:"
|
|
351
|
+
name = self._prompt_name(title, prompt)
|
|
352
|
+
if not name:
|
|
353
|
+
return
|
|
354
|
+
parent.add_child(FolderTreeNode(name, is_folder))
|
|
355
|
+
self._rebuild_layout()
|
|
356
|
+
|
|
357
|
+
def _delete_node(self, node: FolderTreeNode) -> None:
|
|
358
|
+
if node.parent is None:
|
|
359
|
+
return
|
|
360
|
+
parent_widget = self._view_widget()
|
|
361
|
+
reply = QtWidgets.QMessageBox.question(
|
|
362
|
+
parent_widget,
|
|
363
|
+
"Remove Entry",
|
|
364
|
+
f"Remove '{self._node_label(node)}'?",
|
|
365
|
+
)
|
|
366
|
+
if reply != QtWidgets.QMessageBox.StandardButton.Yes:
|
|
367
|
+
return
|
|
368
|
+
node.parent.remove_child(node)
|
|
369
|
+
self._rebuild_layout()
|
|
370
|
+
|
|
371
|
+
def _rename_node(self, node: FolderTreeNode) -> None:
|
|
372
|
+
current = node.name
|
|
373
|
+
title = "Rename Entry"
|
|
374
|
+
prompt = "New name:"
|
|
375
|
+
name = self._prompt_name(title, prompt, current)
|
|
376
|
+
if not name or name == current:
|
|
377
|
+
return
|
|
378
|
+
node.name = name
|
|
379
|
+
self._rebuild_layout()
|
|
380
|
+
|
|
381
|
+
def structure(self) -> dict[str, Any]:
|
|
382
|
+
return self._root.to_dict()
|
|
383
|
+
|
|
384
|
+
def set_structure(self, structure: Mapping[str, Any]) -> None:
|
|
385
|
+
self._root = FolderTreeNode.from_dict(structure)
|
|
386
|
+
self._rebuild_layout()
|
|
387
|
+
|
|
388
|
+
def mousePressEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent) -> None:
|
|
389
|
+
super().mousePressEvent(event)
|
|
390
|
+
if event.button() != QtCore.Qt.MouseButton.LeftButton:
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
local_pos = event.pos()
|
|
394
|
+
for node in self._order:
|
|
395
|
+
if self._node_info[node]["text_rect"].contains(local_pos):
|
|
396
|
+
event.accept()
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
def mouseDoubleClickEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent) -> None:
|
|
400
|
+
if event.button() != QtCore.Qt.MouseButton.LeftButton:
|
|
401
|
+
super().mouseDoubleClickEvent(event)
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
local_pos = event.pos()
|
|
405
|
+
for node in self._order:
|
|
406
|
+
text_rect: QtCore.QRectF = self._node_info[node]["text_rect"]
|
|
407
|
+
if text_rect.contains(local_pos):
|
|
408
|
+
self._rename_node(node)
|
|
409
|
+
event.accept()
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
super().mouseDoubleClickEvent(event)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
__all__ = ["FolderTreeBranchDot", "FolderTreeItem", "FolderTreeNode"]
|
main.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import warnings
|
|
3
|
+
|
|
4
|
+
from PySide6 import QtCore, QtGui, QtWidgets
|
|
5
|
+
|
|
6
|
+
from main_window import MainWindow
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
app = QtWidgets.QApplication(sys.argv)
|
|
11
|
+
win = MainWindow()
|
|
12
|
+
win.show()
|
|
13
|
+
sys.exit(app.exec())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
warnings.filterwarnings(
|
|
18
|
+
"ignore",
|
|
19
|
+
message="Enum value 'Qt::ApplicationAttribute.AA_UseHighDpiPixmaps' is marked as deprecated",
|
|
20
|
+
category=DeprecationWarning,
|
|
21
|
+
)
|
|
22
|
+
QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps, True)
|
|
23
|
+
main()
|
main_window.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from PySide6 import QtCore, QtWidgets, QtGui
|
|
6
|
+
from PySide6.QtUiTools import QUiLoader
|
|
7
|
+
|
|
8
|
+
from app_info import GITHUB_URL, get_version
|
|
9
|
+
from canvas_view import CanvasView
|
|
10
|
+
from export_drawsvg import export_drawsvg_py
|
|
11
|
+
from import_drawsvg import import_drawsvg_py
|
|
12
|
+
from palette import PaletteList
|
|
13
|
+
from properties_panel import PropertiesPanel
|
|
14
|
+
|
|
15
|
+
_UI_PATH = Path(__file__).resolve().parent / "ui" / "main_window.ui"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MainWindow(QtWidgets.QMainWindow):
|
|
19
|
+
def __init__(self):
|
|
20
|
+
super().__init__()
|
|
21
|
+
|
|
22
|
+
self._load_ui()
|
|
23
|
+
|
|
24
|
+
self._install_custom_widgets()
|
|
25
|
+
self._configure_actions()
|
|
26
|
+
|
|
27
|
+
self.statusBar().showMessage(
|
|
28
|
+
"Tip: Ctrl+drag duplicates selected objects, Alt+mouse wheel zooms"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
self.properties_panel.clear()
|
|
32
|
+
self.canvas.selectionSnapshotChanged.connect(self._handle_selection_snapshot)
|
|
33
|
+
|
|
34
|
+
history = self.canvas.history()
|
|
35
|
+
history.historyChanged.connect(self._update_history_actions)
|
|
36
|
+
self._update_history_actions(history.can_undo(), history.can_redo())
|
|
37
|
+
|
|
38
|
+
def _load_ui(self) -> None:
|
|
39
|
+
loader = QUiLoader()
|
|
40
|
+
ui_file = QtCore.QFile(str(_UI_PATH))
|
|
41
|
+
if not ui_file.open(QtCore.QIODevice.OpenModeFlag.ReadOnly):
|
|
42
|
+
raise RuntimeError(f"Could not open UI file: {_UI_PATH}")
|
|
43
|
+
form = loader.load(ui_file, None)
|
|
44
|
+
ui_file.close()
|
|
45
|
+
if form is None:
|
|
46
|
+
raise RuntimeError(f"Failed to load UI from {_UI_PATH}")
|
|
47
|
+
|
|
48
|
+
central = self._take_widget(form, QtWidgets.QWidget, "centralwidget")
|
|
49
|
+
if central is None:
|
|
50
|
+
raise RuntimeError("Missing central widget in UI file")
|
|
51
|
+
self.setCentralWidget(central)
|
|
52
|
+
menu_bar = self._take_widget(form, QtWidgets.QMenuBar, "menubar")
|
|
53
|
+
if menu_bar is not None:
|
|
54
|
+
self.setMenuBar(menu_bar)
|
|
55
|
+
status_bar = self._take_widget(form, QtWidgets.QStatusBar, "statusbar")
|
|
56
|
+
if status_bar is not None:
|
|
57
|
+
self.setStatusBar(status_bar)
|
|
58
|
+
self.resize(form.size())
|
|
59
|
+
self.setWindowTitle(form.windowTitle())
|
|
60
|
+
|
|
61
|
+
self.mainSplitter = self._require_widget(central, QtWidgets.QSplitter, "mainSplitter")
|
|
62
|
+
self.paletteContainer = self._require_widget(central, QtWidgets.QWidget, "paletteContainer")
|
|
63
|
+
self.palettePlaceholder = self._require_widget(central, QtWidgets.QWidget, "palettePlaceholder")
|
|
64
|
+
self.canvasContainer = self._require_widget(central, QtWidgets.QWidget, "canvasContainer")
|
|
65
|
+
self.canvasPlaceholder = self._require_widget(central, QtWidgets.QGraphicsView, "canvasPlaceholder")
|
|
66
|
+
self.propertiesContainer = self._require_widget(central, QtWidgets.QWidget, "propertiesContainer")
|
|
67
|
+
self.propertiesPlaceholder = self._require_widget(central, QtWidgets.QWidget, "propertiesPlaceholder")
|
|
68
|
+
|
|
69
|
+
layouts = [
|
|
70
|
+
central.layout(),
|
|
71
|
+
self.paletteContainer.layout(),
|
|
72
|
+
self.canvasContainer.layout(),
|
|
73
|
+
self.propertiesContainer.layout(),
|
|
74
|
+
]
|
|
75
|
+
for layout in layouts:
|
|
76
|
+
if layout is not None:
|
|
77
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
78
|
+
layout.setSpacing(0)
|
|
79
|
+
|
|
80
|
+
action_names = [
|
|
81
|
+
"actionLoad_drawsvg_py",
|
|
82
|
+
"actionSave_drawsvg_py",
|
|
83
|
+
"actionQuit",
|
|
84
|
+
"actionUndo",
|
|
85
|
+
"actionRedo",
|
|
86
|
+
"actionClear_canvas",
|
|
87
|
+
"actionShow_grid",
|
|
88
|
+
"actionInfo",
|
|
89
|
+
]
|
|
90
|
+
for name in action_names:
|
|
91
|
+
action = form.findChild(QtGui.QAction, name)
|
|
92
|
+
if action is None:
|
|
93
|
+
raise RuntimeError(f"Missing QAction '{name}' in UI file")
|
|
94
|
+
action.setParent(self)
|
|
95
|
+
setattr(self, name, action)
|
|
96
|
+
|
|
97
|
+
form.deleteLater()
|
|
98
|
+
|
|
99
|
+
def _take_widget(
|
|
100
|
+
self,
|
|
101
|
+
parent: QtWidgets.QWidget,
|
|
102
|
+
widget_type: type[QtWidgets.QWidget],
|
|
103
|
+
object_name: str,
|
|
104
|
+
) -> QtWidgets.QWidget | None:
|
|
105
|
+
widget = parent.findChild(widget_type, object_name)
|
|
106
|
+
if widget is None:
|
|
107
|
+
return None
|
|
108
|
+
widget.setParent(None)
|
|
109
|
+
return widget
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _require_widget(
|
|
113
|
+
parent: QtWidgets.QWidget,
|
|
114
|
+
widget_type: type[QtWidgets.QWidget],
|
|
115
|
+
object_name: str,
|
|
116
|
+
) -> QtWidgets.QWidget:
|
|
117
|
+
widget = parent.findChild(widget_type, object_name)
|
|
118
|
+
if widget is None:
|
|
119
|
+
raise RuntimeError(f"Missing widget '{object_name}' in UI file")
|
|
120
|
+
return widget
|
|
121
|
+
|
|
122
|
+
def _install_custom_widgets(self) -> None:
|
|
123
|
+
self.splitter = self.mainSplitter
|
|
124
|
+
|
|
125
|
+
palette_container = self.paletteContainer
|
|
126
|
+
canvas_container = self.canvasContainer
|
|
127
|
+
properties_container = self.propertiesContainer
|
|
128
|
+
properties_container.setSizePolicy(
|
|
129
|
+
QtWidgets.QSizePolicy.Policy.Preferred,
|
|
130
|
+
QtWidgets.QSizePolicy.Policy.Expanding,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
self.palette = PaletteList(palette_container)
|
|
134
|
+
self.palette.setMinimumWidth(220)
|
|
135
|
+
self.palette.shapeClicked.connect(self._add_shape_at_center)
|
|
136
|
+
|
|
137
|
+
self.canvas = CanvasView(canvas_container)
|
|
138
|
+
|
|
139
|
+
self.properties_panel = PropertiesPanel(self.canvas)
|
|
140
|
+
properties_min_width = max(
|
|
141
|
+
260,
|
|
142
|
+
self.properties_panel.minimumWidth(),
|
|
143
|
+
self.properties_panel.minimumSizeHint().width(),
|
|
144
|
+
)
|
|
145
|
+
properties_container.setMinimumWidth(properties_min_width)
|
|
146
|
+
|
|
147
|
+
self._replace_placeholder(palette_container, self.palettePlaceholder, self.palette)
|
|
148
|
+
self._replace_placeholder(canvas_container, self.canvasPlaceholder, self.canvas)
|
|
149
|
+
self._replace_placeholder(
|
|
150
|
+
properties_container, self.propertiesPlaceholder, self.properties_panel
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
self.splitter.setStretchFactor(0, 0)
|
|
154
|
+
self.splitter.setStretchFactor(1, 1)
|
|
155
|
+
self.splitter.setStretchFactor(2, 0)
|
|
156
|
+
self.splitter.setCollapsible(2, True)
|
|
157
|
+
self.splitter.setSizes([self.palette.minimumWidth(), 900, properties_min_width + 40])
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _replace_placeholder(
|
|
161
|
+
container: QtWidgets.QWidget,
|
|
162
|
+
placeholder: QtWidgets.QWidget,
|
|
163
|
+
replacement: QtWidgets.QWidget,
|
|
164
|
+
) -> None:
|
|
165
|
+
layout = container.layout()
|
|
166
|
+
if layout is None:
|
|
167
|
+
layout = QtWidgets.QVBoxLayout(container)
|
|
168
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
169
|
+
layout.setSpacing(0)
|
|
170
|
+
index = layout.indexOf(placeholder)
|
|
171
|
+
if index >= 0:
|
|
172
|
+
item = layout.takeAt(index)
|
|
173
|
+
if item is not None:
|
|
174
|
+
orphan = item.widget()
|
|
175
|
+
if orphan is not None:
|
|
176
|
+
orphan.setParent(None)
|
|
177
|
+
placeholder.setParent(None)
|
|
178
|
+
placeholder.deleteLater()
|
|
179
|
+
if index >= 0:
|
|
180
|
+
layout.insertWidget(index, replacement)
|
|
181
|
+
else:
|
|
182
|
+
layout.addWidget(replacement)
|
|
183
|
+
|
|
184
|
+
def _configure_actions(self) -> None:
|
|
185
|
+
self.actionLoad_drawsvg_py.triggered.connect(self.load_drawsvg_py)
|
|
186
|
+
self.actionSave_drawsvg_py.triggered.connect(self.export_drawsvg_py)
|
|
187
|
+
self.actionQuit.triggered.connect(self.close)
|
|
188
|
+
|
|
189
|
+
self.actionUndo.setShortcutContext(
|
|
190
|
+
QtCore.Qt.ShortcutContext.WidgetWithChildrenShortcut
|
|
191
|
+
)
|
|
192
|
+
self.actionUndo.triggered.connect(self._handle_undo)
|
|
193
|
+
|
|
194
|
+
self.actionRedo.setShortcutContext(
|
|
195
|
+
QtCore.Qt.ShortcutContext.WidgetWithChildrenShortcut
|
|
196
|
+
)
|
|
197
|
+
self.actionRedo.triggered.connect(self._handle_redo)
|
|
198
|
+
|
|
199
|
+
self.actionClear_canvas.triggered.connect(self._handle_clear_canvas)
|
|
200
|
+
|
|
201
|
+
self.actionShow_grid.setCheckable(True)
|
|
202
|
+
self.actionShow_grid.setChecked(True)
|
|
203
|
+
self.actionShow_grid.toggled.connect(self._handle_toggle_grid)
|
|
204
|
+
self.canvas.gridVisibilityChanged.connect(self.actionShow_grid.setChecked)
|
|
205
|
+
|
|
206
|
+
self.actionInfo.triggered.connect(self._show_about_dialog)
|
|
207
|
+
|
|
208
|
+
def _handle_undo(self) -> None:
|
|
209
|
+
self.canvas.undo()
|
|
210
|
+
|
|
211
|
+
def _handle_redo(self) -> None:
|
|
212
|
+
self.canvas.redo()
|
|
213
|
+
|
|
214
|
+
def _handle_clear_canvas(self) -> None:
|
|
215
|
+
self.canvas.clear_canvas()
|
|
216
|
+
|
|
217
|
+
def _handle_toggle_grid(self, visible: bool) -> None:
|
|
218
|
+
self.canvas.set_grid_visible(visible)
|
|
219
|
+
|
|
220
|
+
def _show_about_dialog(self) -> None:
|
|
221
|
+
version = get_version()
|
|
222
|
+
body = (
|
|
223
|
+
"<b>DrawSVG UI</b><br>"
|
|
224
|
+
f"Version: {version}<br>"
|
|
225
|
+
f'<a href="{GITHUB_URL}">{GITHUB_URL}</a>'
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
dialog = QtWidgets.QMessageBox(self)
|
|
229
|
+
dialog.setIcon(QtWidgets.QMessageBox.Icon.Information)
|
|
230
|
+
dialog.setWindowTitle("About")
|
|
231
|
+
dialog.setTextFormat(QtCore.Qt.TextFormat.RichText)
|
|
232
|
+
dialog.setText(body)
|
|
233
|
+
dialog.setTextInteractionFlags(
|
|
234
|
+
QtCore.Qt.TextInteractionFlag.TextBrowserInteraction
|
|
235
|
+
| QtCore.Qt.TextInteractionFlag.LinksAccessibleByMouse
|
|
236
|
+
)
|
|
237
|
+
dialog.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Ok)
|
|
238
|
+
dialog.exec()
|
|
239
|
+
|
|
240
|
+
def export_drawsvg_py(self) -> None:
|
|
241
|
+
export_drawsvg_py(self.canvas.scene(), self)
|
|
242
|
+
|
|
243
|
+
def load_drawsvg_py(self) -> None:
|
|
244
|
+
import_drawsvg_py(self.canvas.scene(), self)
|
|
245
|
+
|
|
246
|
+
def _add_shape_at_center(self, shape: str) -> None:
|
|
247
|
+
self.canvas.add_shape_at_view_center(shape)
|
|
248
|
+
|
|
249
|
+
def _update_history_actions(self, can_undo: bool, can_redo: bool) -> None:
|
|
250
|
+
self.actionUndo.setEnabled(can_undo)
|
|
251
|
+
self.actionRedo.setEnabled(can_redo)
|
|
252
|
+
|
|
253
|
+
def _handle_selection_snapshot(self, payload: dict) -> None:
|
|
254
|
+
self.properties_panel.update_snapshot(payload)
|