pyscreeps-arena 0.5.7a1__py3-none-any.whl → 0.5.7a2__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.
- pyscreeps_arena/__init__.py +7 -2
- pyscreeps_arena/core/const.py +1 -1
- pyscreeps_arena/project.7z +0 -0
- pyscreeps_arena/ui/creeplogic_edit.py +14 -0
- pyscreeps_arena/ui/project_ui.py +1 -0
- pyscreeps_arena/ui/qcreeplogic/__init__.py +3 -0
- pyscreeps_arena/ui/qcreeplogic/model.py +72 -0
- pyscreeps_arena/ui/qcreeplogic/qcreeplogic.py +709 -0
- pyscreeps_arena/ui/qrecipe/__init__.py +1 -0
- pyscreeps_arena/ui/qrecipe/model.py +434 -0
- pyscreeps_arena/ui/qrecipe/qrecipe.py +914 -0
- {pyscreeps_arena-0.5.7a1.dist-info → pyscreeps_arena-0.5.7a2.dist-info}/METADATA +1 -1
- pyscreeps_arena-0.5.7a2.dist-info/RECORD +28 -0
- pyscreeps_arena-0.5.7a1.dist-info/RECORD +0 -21
- {pyscreeps_arena-0.5.7a1.dist-info → pyscreeps_arena-0.5.7a2.dist-info}/WHEEL +0 -0
- {pyscreeps_arena-0.5.7a1.dist-info → pyscreeps_arena-0.5.7a2.dist-info}/entry_points.txt +0 -0
- {pyscreeps_arena-0.5.7a1.dist-info → pyscreeps_arena-0.5.7a2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
from PyQt6.QtWidgets import (
|
|
2
|
+
QWidget, QHBoxLayout, QVBoxLayout, QGridLayout, QPushButton,
|
|
3
|
+
QRadioButton, QGroupBox, QLabel, QCheckBox, QScrollArea,
|
|
4
|
+
QFrame, QButtonGroup, QLayout, QSizePolicy
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
# 全局样式常量
|
|
8
|
+
CHECKBOX_STYLE = "QCheckBox::indicator { width: 16px; height: 16px; border: 3px solid #555; border-radius: 5px; background-color: white; } QCheckBox::indicator:checked { background-color: #4CAF50; image: url('data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'18\' height=\'18\' viewBox=\'0 0 24 24\'><path fill=\'white\' d=\'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z\'/></svg>'); }"
|
|
9
|
+
RADIO_STYLE = "QRadioButton::indicator { width: 16px; height: 16px; border: 3px solid #555; border-radius: 8px; background-color: white; } QRadioButton::indicator:checked { background-color: #4CAF50; }"
|
|
10
|
+
from PyQt6.QtGui import QContextMenuEvent, QMouseEvent, QKeyEvent, QDragMoveEvent, QFocusEvent
|
|
11
|
+
from typing import Set
|
|
12
|
+
from PyQt6.QtCore import (
|
|
13
|
+
Qt, QMimeData, QPoint, pyqtProperty, pyqtSignal,
|
|
14
|
+
QEvent, QRect
|
|
15
|
+
)
|
|
16
|
+
from PyQt6.QtGui import QDrag
|
|
17
|
+
from PyQt6.QtGui import (
|
|
18
|
+
QPainter, QColor, QPen, QBrush, QFont, QFontMetrics,
|
|
19
|
+
QDragEnterEvent, QDropEvent, QPalette
|
|
20
|
+
)
|
|
21
|
+
import json
|
|
22
|
+
from typing import List, Optional
|
|
23
|
+
from pyscreeps_arena.ui.qrecipe.model import RecipeModel, PartsVector, CreepInfo
|
|
24
|
+
from PyQt6.QtWidgets import QApplication
|
|
25
|
+
|
|
26
|
+
# Import configuration from build.py
|
|
27
|
+
import sys
|
|
28
|
+
import os
|
|
29
|
+
# Add the project root directory to Python path
|
|
30
|
+
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
from pyscreeps_arena import config
|
|
32
|
+
|
|
33
|
+
# Language mapping
|
|
34
|
+
LANG = {
|
|
35
|
+
'cn': {
|
|
36
|
+
'recipe': '配方表',
|
|
37
|
+
'optimised_recipe': '优化后的配方',
|
|
38
|
+
'static_info': '静态信息',
|
|
39
|
+
'cost': '价 格💲',
|
|
40
|
+
'optimise': '自动优化',
|
|
41
|
+
'yes': '✅',
|
|
42
|
+
'no': '❎',
|
|
43
|
+
'score': '分 数💯',
|
|
44
|
+
'efficiency': '效 率📈',
|
|
45
|
+
'melee': '近 战🔴',
|
|
46
|
+
'ranged': '远 程🔵',
|
|
47
|
+
'heal': '治 疗🟢',
|
|
48
|
+
'work': '工 作🟡',
|
|
49
|
+
'storable': '存 储⚫',
|
|
50
|
+
'attack_power': '攻击力💀',
|
|
51
|
+
'melee_power': '近战力⚔️',
|
|
52
|
+
'ranged_power': '远程力🏹',
|
|
53
|
+
'heal_power': '治疗力💉',
|
|
54
|
+
'motion_ability': '移动率🥾',
|
|
55
|
+
'armor_ratio': '装甲率🛡️',
|
|
56
|
+
'melee_ratio': '站撸率💪',
|
|
57
|
+
},
|
|
58
|
+
'en': {
|
|
59
|
+
'recipe': 'Recipe Table',
|
|
60
|
+
'optimised_recipe': 'Optimised Recipe',
|
|
61
|
+
'static_info': 'Static Info',
|
|
62
|
+
'cost': 'cost💲',
|
|
63
|
+
'optimise': 'Auto Optimise',
|
|
64
|
+
'yes': '✅',
|
|
65
|
+
'no': '❎',
|
|
66
|
+
'score': 'grade💯',
|
|
67
|
+
'efficiency': 'effect📈',
|
|
68
|
+
'melee': 'melee🔴',
|
|
69
|
+
'ranged': 'ranged🔵',
|
|
70
|
+
'heal': 'heal🟢',
|
|
71
|
+
'work': 'work🟡',
|
|
72
|
+
'storable': 'store⚫',
|
|
73
|
+
'attack_power': 'attack`💀',
|
|
74
|
+
'melee_power': 'melee`⚔️',
|
|
75
|
+
'ranged_power': 'ranged`🏹',
|
|
76
|
+
'heal_power': 'heal`💉',
|
|
77
|
+
'motion_ability': 'motion.🥾',
|
|
78
|
+
'armor_ratio': 'armor.🛡️',
|
|
79
|
+
'melee_ratio': 'melee.💪',
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# config.language = 'en'
|
|
84
|
+
|
|
85
|
+
def lang(key: str) -> str:
|
|
86
|
+
"""Helper function to get translated text"""
|
|
87
|
+
return LANG[config.language if hasattr(config, 'language') and config.language in LANG else 'cn'][key]
|
|
88
|
+
|
|
89
|
+
class QPSABodyPart(QWidget):
|
|
90
|
+
def __init__(self, part_type: str, parent: Optional[QWidget] = None):
|
|
91
|
+
super().__init__(parent)
|
|
92
|
+
self.part_type = part_type
|
|
93
|
+
self.color = QColor(PartsVector.COLORS.get(part_type, '#9B9B9B'))
|
|
94
|
+
self.is_selected = False
|
|
95
|
+
self.index = -1 # Index in the recipe list
|
|
96
|
+
self.drag_start_position = QPoint() # Initialize drag start position
|
|
97
|
+
|
|
98
|
+
self.setFixedSize(30, 30)
|
|
99
|
+
self.setAcceptDrops(True) # Enable drop for reordering
|
|
100
|
+
self.setCursor(Qt.CursorShape.PointingHandCursor) # For selectable parts
|
|
101
|
+
|
|
102
|
+
def mousePressEvent(self, event):
|
|
103
|
+
# This method is overridden in QPSARecipe for recipe parts
|
|
104
|
+
# but kept here for top row draggable parts
|
|
105
|
+
if self.cursor().shape() == Qt.CursorShape.OpenHandCursor:
|
|
106
|
+
# Only handle drag start for top row draggable parts
|
|
107
|
+
if event.button() == Qt.MouseButton.LeftButton:
|
|
108
|
+
self.drag_start_position = event.pos()
|
|
109
|
+
else:
|
|
110
|
+
# Handle click for selectable parts
|
|
111
|
+
super().mousePressEvent(event)
|
|
112
|
+
|
|
113
|
+
def mouseMoveEvent(self, event):
|
|
114
|
+
# Handle drag for reordering selected parts
|
|
115
|
+
if not (event.buttons() & Qt.MouseButton.LeftButton):
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
# Only allow dragging selected parts in recipe area
|
|
119
|
+
if self.is_selected and self.cursor().shape() != Qt.CursorShape.OpenHandCursor:
|
|
120
|
+
distance = (event.pos() - self.drag_start_position).manhattanLength()
|
|
121
|
+
from PyQt6.QtWidgets import QApplication
|
|
122
|
+
if distance < QApplication.startDragDistance():
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
# Create drag data with the selected indices
|
|
126
|
+
mime_data = QMimeData()
|
|
127
|
+
drag_data = {
|
|
128
|
+
"type": "recipe_part_reorder",
|
|
129
|
+
"selected_indices": list(self.parent().parent().selected_indices),
|
|
130
|
+
"source_index": self.index
|
|
131
|
+
}
|
|
132
|
+
mime_data.setText(json.dumps(drag_data))
|
|
133
|
+
|
|
134
|
+
drag = QDrag(self)
|
|
135
|
+
drag.setMimeData(mime_data)
|
|
136
|
+
|
|
137
|
+
# Create drag pixmap with visual feedback
|
|
138
|
+
drag.setPixmap(self.grab())
|
|
139
|
+
drag.setHotSpot(QPoint(15, 15))
|
|
140
|
+
|
|
141
|
+
drag.exec(Qt.DropAction.MoveAction)
|
|
142
|
+
else:
|
|
143
|
+
# Original drag behavior for top row parts
|
|
144
|
+
super().mouseMoveEvent(event)
|
|
145
|
+
|
|
146
|
+
def paintEvent(self, event):
|
|
147
|
+
painter = QPainter(self)
|
|
148
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
149
|
+
|
|
150
|
+
rect = QRect(5, 5, 20, 20)
|
|
151
|
+
base_rect = QRect(5, 5, 20, 20)
|
|
152
|
+
|
|
153
|
+
if self.is_selected:
|
|
154
|
+
# For selected state, draw thicker outline with inverted color
|
|
155
|
+
# Create inverted color for outline
|
|
156
|
+
r, g, b, a = self.color.getRgb()
|
|
157
|
+
inverted_color = QColor(255 - r, 255 - g, 255 - b, a)
|
|
158
|
+
|
|
159
|
+
# Draw main circle with original color and default stroke
|
|
160
|
+
painter.setBrush(QBrush(self.color))
|
|
161
|
+
painter.setPen(QPen(QColor("#CDC5BF"), 1))
|
|
162
|
+
painter.drawEllipse(rect)
|
|
163
|
+
|
|
164
|
+
# Draw thicker selection outline with inverted color
|
|
165
|
+
outline_rect = QRect(2, 2, 26, 26)
|
|
166
|
+
painter.setPen(QPen(inverted_color, 3))
|
|
167
|
+
painter.setBrush(QBrush(Qt.BrushStyle.NoBrush))
|
|
168
|
+
painter.drawEllipse(outline_rect)
|
|
169
|
+
else:
|
|
170
|
+
# Normal state - draw circle with default 1px #CDC5BF stroke
|
|
171
|
+
painter.setBrush(QBrush(self.color))
|
|
172
|
+
painter.setPen(QPen(QColor("#CDC5BF"), 1))
|
|
173
|
+
painter.drawEllipse(rect)
|
|
174
|
+
|
|
175
|
+
def set_selected(self, selected: bool):
|
|
176
|
+
self.is_selected = selected
|
|
177
|
+
self.update()
|
|
178
|
+
|
|
179
|
+
def mousePressEvent(self, event):
|
|
180
|
+
if event.button() == Qt.MouseButton.LeftButton:
|
|
181
|
+
# Always set drag_start_position to avoid AttributeError in mouseMoveEvent
|
|
182
|
+
self.drag_start_position = event.pos()
|
|
183
|
+
|
|
184
|
+
def mouseMoveEvent(self, event):
|
|
185
|
+
if not (event.buttons() & Qt.MouseButton.LeftButton):
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
# Only allow dragging from the source parts, not from recipe area parts
|
|
189
|
+
# Source parts have OpenHandCursor, recipe parts have PointingHandCursor
|
|
190
|
+
if self.cursor().shape() != Qt.CursorShape.OpenHandCursor:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
distance = (event.pos() - self.drag_start_position).manhattanLength()
|
|
194
|
+
if distance < QApplication.startDragDistance():
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
# Create drag data
|
|
198
|
+
mime_data = QMimeData()
|
|
199
|
+
part_data = json.dumps([self.part_type])
|
|
200
|
+
mime_data.setText(part_data)
|
|
201
|
+
|
|
202
|
+
drag = QDrag(self)
|
|
203
|
+
drag.setMimeData(mime_data)
|
|
204
|
+
|
|
205
|
+
# Create drag pixmap
|
|
206
|
+
drag.setPixmap(self.grab())
|
|
207
|
+
drag.setHotSpot(QPoint(15, 15))
|
|
208
|
+
|
|
209
|
+
drag.exec(Qt.DropAction.CopyAction)
|
|
210
|
+
|
|
211
|
+
class QPSARecipe(QWidget):
|
|
212
|
+
onChanged = pyqtSignal()
|
|
213
|
+
|
|
214
|
+
def __init__(self, parent: Optional[QWidget] = None):
|
|
215
|
+
super().__init__(parent)
|
|
216
|
+
|
|
217
|
+
# Model
|
|
218
|
+
self.model = RecipeModel()
|
|
219
|
+
|
|
220
|
+
# Set focus policy to accept keyboard events
|
|
221
|
+
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
222
|
+
|
|
223
|
+
# UI Setup
|
|
224
|
+
self.init_ui()
|
|
225
|
+
|
|
226
|
+
# Property storage
|
|
227
|
+
self._recipe: List[str] = []
|
|
228
|
+
self._preview: str = "[]"
|
|
229
|
+
self._string: str = ""
|
|
230
|
+
self._optimise: bool = True
|
|
231
|
+
self._cost: int = 0
|
|
232
|
+
self._grade: int = 0
|
|
233
|
+
self._effect: float = 0.0
|
|
234
|
+
self._multiplier: int = 1 # Only affects drag-and-drop operations
|
|
235
|
+
|
|
236
|
+
# Selection management
|
|
237
|
+
self.selected_indices: Set[int] = set()
|
|
238
|
+
self.last_selected_index: int = -1
|
|
239
|
+
|
|
240
|
+
# Update initial values
|
|
241
|
+
self.update_info()
|
|
242
|
+
|
|
243
|
+
def init_ui(self):
|
|
244
|
+
main_layout = QVBoxLayout(self)
|
|
245
|
+
main_layout.setSpacing(15)
|
|
246
|
+
main_layout.setContentsMargins(10, 10, 10, 10)
|
|
247
|
+
|
|
248
|
+
# 1. Body Part Types (Top row) - Draggable parts
|
|
249
|
+
self.part_types = ['WORK', 'ATTACK', 'RANGED_ATTACK', 'HEAL', 'TOUGH', 'CARRY', 'MOVE']
|
|
250
|
+
part_layout = QHBoxLayout()
|
|
251
|
+
part_layout.setSpacing(10)
|
|
252
|
+
|
|
253
|
+
for part_type in self.part_types:
|
|
254
|
+
part_widget = QPSABodyPart(part_type)
|
|
255
|
+
part_widget.setCursor(Qt.CursorShape.OpenHandCursor) # Set draggable cursor
|
|
256
|
+
part_layout.addWidget(part_widget)
|
|
257
|
+
|
|
258
|
+
part_layout.addStretch()
|
|
259
|
+
main_layout.addLayout(part_layout)
|
|
260
|
+
|
|
261
|
+
# 2. Multiplier Selection (Second row)
|
|
262
|
+
multiplier_group = QGroupBox()
|
|
263
|
+
multiplier_layout = QHBoxLayout(multiplier_group)
|
|
264
|
+
multiplier_layout.setSpacing(15)
|
|
265
|
+
|
|
266
|
+
self.multiplier_group = QButtonGroup(self)
|
|
267
|
+
multipliers = ['x1', 'x2', 'x4', 'x5', 'x10']
|
|
268
|
+
|
|
269
|
+
for i, multiplier in enumerate(multipliers):
|
|
270
|
+
radio = QRadioButton(multiplier)
|
|
271
|
+
# Set NoFocus policy for radio buttons so they don't steal keyboard events
|
|
272
|
+
radio.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
273
|
+
radio.setStyleSheet(RADIO_STYLE)
|
|
274
|
+
self.multiplier_group.addButton(radio, i)
|
|
275
|
+
multiplier_layout.addWidget(radio)
|
|
276
|
+
|
|
277
|
+
# Set default to x1
|
|
278
|
+
self.multiplier_group.button(0).setChecked(True)
|
|
279
|
+
self.multiplier_group.buttonClicked.connect(self.on_multiplier_changed)
|
|
280
|
+
multiplier_layout.addStretch()
|
|
281
|
+
main_layout.addWidget(multiplier_group)
|
|
282
|
+
|
|
283
|
+
# 3. Recipe Table (Third row) - Entire area accepts drops
|
|
284
|
+
recipe_group = QGroupBox(lang('recipe'))
|
|
285
|
+
recipe_layout = QVBoxLayout(recipe_group)
|
|
286
|
+
|
|
287
|
+
# Create a single widget that accepts drops
|
|
288
|
+
self.recipe_area = QWidget()
|
|
289
|
+
self.recipe_area.setFixedSize(320, 170) # 10 columns * 30px + 5px spacing, 5 rows * 30px + 5px spacing
|
|
290
|
+
self.recipe_area.setStyleSheet("background-color: #f5f5f5; border: 1px dashed #ccc; border-radius: 5px;")
|
|
291
|
+
self.recipe_area.setAcceptDrops(True)
|
|
292
|
+
self.recipe_area.installEventFilter(self)
|
|
293
|
+
|
|
294
|
+
# Create a vertical layout for the recipe area
|
|
295
|
+
# This will hold 5 horizontal layouts (one for each row)
|
|
296
|
+
self.recipe_main_layout = QVBoxLayout(self.recipe_area)
|
|
297
|
+
self.recipe_main_layout.setSpacing(5)
|
|
298
|
+
self.recipe_main_layout.setContentsMargins(5, 5, 5, 5)
|
|
299
|
+
self.recipe_main_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
|
|
300
|
+
|
|
301
|
+
# Create 5 horizontal layouts (one for each row, max 10 parts per row)
|
|
302
|
+
self.recipe_row_layouts: List[QHBoxLayout] = []
|
|
303
|
+
for _ in range(5):
|
|
304
|
+
row_layout = QHBoxLayout()
|
|
305
|
+
row_layout.setSpacing(5)
|
|
306
|
+
row_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
|
307
|
+
self.recipe_main_layout.addLayout(row_layout)
|
|
308
|
+
self.recipe_row_layouts.append(row_layout)
|
|
309
|
+
|
|
310
|
+
# Store references to displayed part widgets
|
|
311
|
+
self.displayed_parts: List[QPSABodyPart] = []
|
|
312
|
+
|
|
313
|
+
recipe_layout.addWidget(self.recipe_area)
|
|
314
|
+
main_layout.addWidget(recipe_group)
|
|
315
|
+
|
|
316
|
+
# 4. Cost and Optimise (Fourth row)
|
|
317
|
+
control_layout = QHBoxLayout()
|
|
318
|
+
|
|
319
|
+
# Left: Cost
|
|
320
|
+
self.cost_label = QLabel(f"{lang('cost')}: 0")
|
|
321
|
+
control_layout.addWidget(self.cost_label)
|
|
322
|
+
control_layout.addStretch()
|
|
323
|
+
|
|
324
|
+
# Right: Optimise checkbox
|
|
325
|
+
self.optimise_checkbox = QCheckBox(lang('optimise'))
|
|
326
|
+
self.optimise_checkbox.setChecked(True)
|
|
327
|
+
self.optimise_checkbox.setStyleSheet(CHECKBOX_STYLE)
|
|
328
|
+
self.optimise_checkbox.stateChanged.connect(self.on_optimise_changed)
|
|
329
|
+
control_layout.addWidget(self.optimise_checkbox)
|
|
330
|
+
|
|
331
|
+
main_layout.addLayout(control_layout)
|
|
332
|
+
|
|
333
|
+
# 5. Optimised Recipe (Fifth row) - same layout as recipe area, fixed height, no scrollbars
|
|
334
|
+
self.optimised_group = QGroupBox(lang('optimised_recipe'))
|
|
335
|
+
self.optimised_layout = QVBoxLayout(self.optimised_group)
|
|
336
|
+
self.optimised_layout.setSpacing(5)
|
|
337
|
+
|
|
338
|
+
# Create a display area for optimised recipe - same size as recipe area
|
|
339
|
+
self.optimised_display = QWidget()
|
|
340
|
+
self.optimised_display.setFixedSize(320, 170) # Same size as recipe area
|
|
341
|
+
self.optimised_display.setStyleSheet("background-color: #f5f5f5; border: 1px dashed #ccc; border-radius: 5px;")
|
|
342
|
+
|
|
343
|
+
# Create main layout for optimised recipe - vertical with horizontal rows
|
|
344
|
+
self.optimised_main_layout = QVBoxLayout(self.optimised_display)
|
|
345
|
+
self.optimised_main_layout.setSpacing(5)
|
|
346
|
+
self.optimised_main_layout.setContentsMargins(5, 5, 5, 5)
|
|
347
|
+
self.optimised_main_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
|
|
348
|
+
|
|
349
|
+
# Create 5 horizontal layouts for optimised recipe rows (max 10 per row)
|
|
350
|
+
self.optimised_row_layouts: List[QHBoxLayout] = []
|
|
351
|
+
for _ in range(5):
|
|
352
|
+
row_layout = QHBoxLayout()
|
|
353
|
+
row_layout.setSpacing(5)
|
|
354
|
+
row_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
|
355
|
+
self.optimised_main_layout.addLayout(row_layout)
|
|
356
|
+
self.optimised_row_layouts.append(row_layout)
|
|
357
|
+
|
|
358
|
+
# No scroll area, just add the fixed-size widget directly
|
|
359
|
+
self.optimised_layout.addWidget(self.optimised_display)
|
|
360
|
+
main_layout.addWidget(self.optimised_group)
|
|
361
|
+
|
|
362
|
+
# 6. Static Info (Sixth row)
|
|
363
|
+
info_group = QGroupBox(lang('static_info'))
|
|
364
|
+
info_layout = QGridLayout(info_group)
|
|
365
|
+
info_layout.setSpacing(10)
|
|
366
|
+
|
|
367
|
+
# Info labels with emojis - use language mapping
|
|
368
|
+
info_labels = [
|
|
369
|
+
(lang('score'), "grade"),
|
|
370
|
+
(lang('efficiency'), "effect"),
|
|
371
|
+
(lang('melee'), "melee"),
|
|
372
|
+
(lang('ranged'), "ranged"),
|
|
373
|
+
(lang('heal'), "heal"),
|
|
374
|
+
(lang('work'), "work"),
|
|
375
|
+
(lang('storable'), "storable"),
|
|
376
|
+
(lang('attack_power'), "attack_power"),
|
|
377
|
+
(lang('melee_power'), "melee_power"),
|
|
378
|
+
(lang('ranged_power'), "ranged_power"),
|
|
379
|
+
(lang('heal_power'), "heal_power"),
|
|
380
|
+
(lang('motion_ability'), "motion_ability"),
|
|
381
|
+
(lang('armor_ratio'), "armor_ratio"),
|
|
382
|
+
(lang('melee_ratio'), "melee_ratio"),
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
self.info_display = {}
|
|
386
|
+
for i, (label_text, key) in enumerate(info_labels):
|
|
387
|
+
label = QLabel(f"{label_text}: ")
|
|
388
|
+
value = QLabel("0")
|
|
389
|
+
value.setObjectName(f"info_{key}")
|
|
390
|
+
info_layout.addWidget(label, i // 2, (i % 2) * 2)
|
|
391
|
+
info_layout.addWidget(value, i // 2, (i % 2) * 2 + 1)
|
|
392
|
+
self.info_display[key] = value
|
|
393
|
+
|
|
394
|
+
main_layout.addWidget(info_group)
|
|
395
|
+
|
|
396
|
+
def eventFilter(self, obj: QWidget, event: QEvent) -> bool:
|
|
397
|
+
if isinstance(obj, QPSABodyPart):
|
|
398
|
+
# Handle drag events for reordering parts
|
|
399
|
+
if event.type() == QEvent.Type.DragEnter:
|
|
400
|
+
return self.on_part_drag_enter(event, obj)
|
|
401
|
+
elif event.type() == QEvent.Type.DragMove:
|
|
402
|
+
return self.on_part_drag_move(event, obj)
|
|
403
|
+
elif event.type() == QEvent.Type.Drop:
|
|
404
|
+
return self.on_part_drop(event, obj)
|
|
405
|
+
elif obj == self.recipe_area:
|
|
406
|
+
if event.type() == QEvent.Type.DragEnter:
|
|
407
|
+
return self.on_drag_enter_event(event)
|
|
408
|
+
elif event.type() == QEvent.Type.Drop:
|
|
409
|
+
return self.on_drop_event(event, obj)
|
|
410
|
+
return super().eventFilter(obj, event)
|
|
411
|
+
|
|
412
|
+
def on_drag_enter_event(self, event: QDragEnterEvent) -> bool:
|
|
413
|
+
if event.mimeData().hasText():
|
|
414
|
+
event.acceptProposedAction()
|
|
415
|
+
return True
|
|
416
|
+
return False
|
|
417
|
+
|
|
418
|
+
def on_drop_event(self, event: QDropEvent, target_area: QWidget) -> bool:
|
|
419
|
+
# Parse dropped data
|
|
420
|
+
text = event.mimeData().text()
|
|
421
|
+
try:
|
|
422
|
+
parts = json.loads(text)
|
|
423
|
+
if isinstance(parts, list) and parts:
|
|
424
|
+
part_type = parts[0]
|
|
425
|
+
|
|
426
|
+
# Apply multiplier to this single part drop
|
|
427
|
+
new_parts = [part_type] * self._multiplier
|
|
428
|
+
|
|
429
|
+
# Calculate how many parts we can add without exceeding 50
|
|
430
|
+
available_slots = 50 - len(self._recipe)
|
|
431
|
+
if available_slots <= 0:
|
|
432
|
+
return False
|
|
433
|
+
|
|
434
|
+
# Only take what fits
|
|
435
|
+
new_parts = new_parts[:available_slots]
|
|
436
|
+
|
|
437
|
+
# Calculate insertion position based on drop coordinates
|
|
438
|
+
# Get drop position in target_area coordinates
|
|
439
|
+
drop_local = event.position().toPoint()
|
|
440
|
+
|
|
441
|
+
# Simplified insertion logic: iterate through all parts and find insertion point
|
|
442
|
+
insertion_index = len(self._recipe)
|
|
443
|
+
|
|
444
|
+
# Iterate through each part widget to determine insertion position
|
|
445
|
+
for i, part_widget in enumerate(self.displayed_parts):
|
|
446
|
+
# Get part widget's geometry
|
|
447
|
+
part_geo = part_widget.geometry()
|
|
448
|
+
# Get part's global position
|
|
449
|
+
part_global = part_widget.mapToGlobal(QPoint(0, 0))
|
|
450
|
+
# Convert to target_area's local coordinates
|
|
451
|
+
part_local = target_area.mapFromGlobal(part_global)
|
|
452
|
+
|
|
453
|
+
# Check if drop is above or below the part row
|
|
454
|
+
if drop_local.y() < part_local.y() or drop_local.y() > part_local.y() + 30:
|
|
455
|
+
continue # Skip if not in the same row
|
|
456
|
+
|
|
457
|
+
# Check if drop is to the left or right of the part
|
|
458
|
+
part_mid_x = part_local.x() + 15 # 30px width / 2
|
|
459
|
+
if drop_local.x() < part_mid_x:
|
|
460
|
+
# Drop is to the left, insert before this part
|
|
461
|
+
insertion_index = i
|
|
462
|
+
break
|
|
463
|
+
else:
|
|
464
|
+
# Drop is to the right, insert after this part
|
|
465
|
+
insertion_index = i + 1
|
|
466
|
+
|
|
467
|
+
# Create new recipe with parts inserted at calculated position
|
|
468
|
+
updated_recipe = self._recipe.copy()
|
|
469
|
+
# Insert all new parts at once to maintain their order
|
|
470
|
+
for i in range(len(new_parts)):
|
|
471
|
+
updated_recipe.insert(insertion_index + i, new_parts[i])
|
|
472
|
+
|
|
473
|
+
# Update model and UI
|
|
474
|
+
self.recipe = updated_recipe
|
|
475
|
+
event.acceptProposedAction()
|
|
476
|
+
return True
|
|
477
|
+
except json.JSONDecodeError:
|
|
478
|
+
pass
|
|
479
|
+
return False
|
|
480
|
+
|
|
481
|
+
def on_part_drag_enter(self, event: QDragEnterEvent, target_part: QPSABodyPart) -> bool:
|
|
482
|
+
# Check if this is a recipe part reorder drag (JSON object) or top-row part drag (JSON list)
|
|
483
|
+
if event.mimeData().hasText():
|
|
484
|
+
text = event.mimeData().text()
|
|
485
|
+
try:
|
|
486
|
+
# Try to parse as JSON
|
|
487
|
+
data = json.loads(text)
|
|
488
|
+
# Only accept recipe part reorder drags (JSON objects), not top-row part drags (JSON lists)
|
|
489
|
+
if isinstance(data, dict) and data.get("type") == "recipe_part_reorder":
|
|
490
|
+
event.acceptProposedAction()
|
|
491
|
+
return True
|
|
492
|
+
except json.JSONDecodeError:
|
|
493
|
+
pass
|
|
494
|
+
return False
|
|
495
|
+
|
|
496
|
+
def on_part_drag_move(self, event: QDragMoveEvent, target_part: QPSABodyPart) -> bool:
|
|
497
|
+
# Allow drop on parts for reordering (JSON objects only)
|
|
498
|
+
if event.mimeData().hasText():
|
|
499
|
+
text = event.mimeData().text()
|
|
500
|
+
try:
|
|
501
|
+
data = json.loads(text)
|
|
502
|
+
if isinstance(data, dict) and data.get("type") == "recipe_part_reorder":
|
|
503
|
+
event.acceptProposedAction()
|
|
504
|
+
return True
|
|
505
|
+
except json.JSONDecodeError:
|
|
506
|
+
pass
|
|
507
|
+
return False
|
|
508
|
+
|
|
509
|
+
def on_part_drop(self, event: QDropEvent, target_part: QPSABodyPart) -> bool:
|
|
510
|
+
# Handle dropping selected parts onto another part to reorder
|
|
511
|
+
text = event.mimeData().text()
|
|
512
|
+
try:
|
|
513
|
+
drag_data = json.loads(text)
|
|
514
|
+
# Only handle recipe_part_reorder JSON objects, not top-row JSON lists
|
|
515
|
+
if isinstance(drag_data, dict) and drag_data.get("type") == "recipe_part_reorder":
|
|
516
|
+
selected_indices = drag_data["selected_indices"]
|
|
517
|
+
source_index = drag_data["source_index"]
|
|
518
|
+
target_index = target_part.index
|
|
519
|
+
|
|
520
|
+
if not selected_indices: # No selected parts to move
|
|
521
|
+
return False
|
|
522
|
+
|
|
523
|
+
# Create recipe copy, remove selected parts in reverse order
|
|
524
|
+
new_recipe = self._recipe.copy()
|
|
525
|
+
selected_parts = []
|
|
526
|
+
for index in sorted(selected_indices, reverse=True):
|
|
527
|
+
selected_parts.append(new_recipe.pop(index))
|
|
528
|
+
|
|
529
|
+
# Reverse to maintain original order when inserting
|
|
530
|
+
selected_parts.reverse()
|
|
531
|
+
|
|
532
|
+
# Insert at target position
|
|
533
|
+
for part in selected_parts:
|
|
534
|
+
new_recipe.insert(target_index, part)
|
|
535
|
+
|
|
536
|
+
# Update recipe and selection
|
|
537
|
+
self.recipe = new_recipe
|
|
538
|
+
|
|
539
|
+
# Update selection to moved parts
|
|
540
|
+
new_selected = set()
|
|
541
|
+
for i in range(len(selected_parts)):
|
|
542
|
+
new_selected.add(target_index + i)
|
|
543
|
+
self.selected_indices = new_selected
|
|
544
|
+
self.last_selected_index = max(new_selected)
|
|
545
|
+
self.update_selection_display()
|
|
546
|
+
|
|
547
|
+
event.acceptProposedAction()
|
|
548
|
+
return True
|
|
549
|
+
except json.JSONDecodeError:
|
|
550
|
+
pass
|
|
551
|
+
return False
|
|
552
|
+
|
|
553
|
+
def contextMenuEvent(self, event: QContextMenuEvent):
|
|
554
|
+
# Clear all parts when right-clicking on the recipe area
|
|
555
|
+
if self.recipe_area.geometry().contains(self.mapFromGlobal(event.globalPos())):
|
|
556
|
+
self.recipe = []
|
|
557
|
+
|
|
558
|
+
def mousePressEvent(self, event: QMouseEvent):
|
|
559
|
+
# Ensure this widget has focus when clicked anywhere on it
|
|
560
|
+
self.setFocus()
|
|
561
|
+
|
|
562
|
+
# Check if clicked on recipe area's blank space
|
|
563
|
+
if self.recipe_area.geometry().contains(event.pos()):
|
|
564
|
+
# Convert to recipe area's local coordinates
|
|
565
|
+
local_pos = self.recipe_area.mapFrom(self, event.pos())
|
|
566
|
+
|
|
567
|
+
# Check if clicked on any part widget
|
|
568
|
+
clicked_on_part = False
|
|
569
|
+
for part_widget in self.displayed_parts:
|
|
570
|
+
if part_widget.geometry().contains(part_widget.parent().mapFrom(self.recipe_area, local_pos)):
|
|
571
|
+
clicked_on_part = True
|
|
572
|
+
break
|
|
573
|
+
|
|
574
|
+
# If clicked on blank space, clear selection
|
|
575
|
+
if not clicked_on_part:
|
|
576
|
+
self.selected_indices.clear()
|
|
577
|
+
self.last_selected_index = -1
|
|
578
|
+
self.update_selection_display()
|
|
579
|
+
|
|
580
|
+
super().mousePressEvent(event)
|
|
581
|
+
|
|
582
|
+
def focusInEvent(self, event: QFocusEvent):
|
|
583
|
+
# Highlight the recipe area when focused to indicate keyboard events are handled here
|
|
584
|
+
self.recipe_area.setStyleSheet("background-color: #f0f0f0; border: 2px solid #aaa; border-radius: 5px;")
|
|
585
|
+
super().focusInEvent(event)
|
|
586
|
+
|
|
587
|
+
def focusOutEvent(self, event: QFocusEvent):
|
|
588
|
+
# Restore normal styling when focus is lost
|
|
589
|
+
self.recipe_area.setStyleSheet("background-color: #f5f5f5; border: 1px dashed #ccc; border-radius: 5px;")
|
|
590
|
+
super().focusOutEvent(event)
|
|
591
|
+
|
|
592
|
+
def keyPressEvent(self, event: QKeyEvent):
|
|
593
|
+
# Prevent event propagation for arrow keys - handle them here
|
|
594
|
+
if event.key() in [Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Delete, Qt.Key.Key_Backspace]:
|
|
595
|
+
handled = False
|
|
596
|
+
|
|
597
|
+
if event.key() in [Qt.Key.Key_Delete, Qt.Key.Key_Backspace]:
|
|
598
|
+
# Handle Delete and Backspace keys to remove selected parts
|
|
599
|
+
if self.selected_indices:
|
|
600
|
+
# Create a new recipe without the selected indices
|
|
601
|
+
new_recipe = []
|
|
602
|
+
for i, part in enumerate(self._recipe):
|
|
603
|
+
if i not in self.selected_indices:
|
|
604
|
+
new_recipe.append(part)
|
|
605
|
+
|
|
606
|
+
# Update recipe and reset selection
|
|
607
|
+
self.recipe = new_recipe
|
|
608
|
+
handled = True
|
|
609
|
+
|
|
610
|
+
elif event.key() == Qt.Key.Key_Left:
|
|
611
|
+
# Handle left arrow key
|
|
612
|
+
if self.selected_indices:
|
|
613
|
+
# Move selected parts one position to the left if possible
|
|
614
|
+
self._move_selected_parts(-1)
|
|
615
|
+
handled = True
|
|
616
|
+
|
|
617
|
+
elif event.key() == Qt.Key.Key_Right:
|
|
618
|
+
# Handle right arrow key
|
|
619
|
+
if self.selected_indices:
|
|
620
|
+
# Move selected parts one position to the right if possible
|
|
621
|
+
self._move_selected_parts(1)
|
|
622
|
+
handled = True
|
|
623
|
+
|
|
624
|
+
if handled:
|
|
625
|
+
# Stop event from propagating to other widgets
|
|
626
|
+
event.accept()
|
|
627
|
+
return
|
|
628
|
+
|
|
629
|
+
# For other keys, let parent handle
|
|
630
|
+
super().keyPressEvent(event)
|
|
631
|
+
|
|
632
|
+
def _move_selected_parts(self, direction: int):
|
|
633
|
+
"""Move selected parts in the recipe by direction (-1 for left, 1 for right)
|
|
634
|
+
Always moves by 1 position, regardless of how many parts are selected
|
|
635
|
+
"""
|
|
636
|
+
if not self.selected_indices:
|
|
637
|
+
return
|
|
638
|
+
|
|
639
|
+
# Get sorted list of selected indices
|
|
640
|
+
sorted_indices = sorted(self.selected_indices)
|
|
641
|
+
|
|
642
|
+
# Check if movement is possible
|
|
643
|
+
if direction == -1:
|
|
644
|
+
# Left: check if first selected index is at position 0
|
|
645
|
+
if sorted_indices[0] == 0:
|
|
646
|
+
return # Cannot move further left
|
|
647
|
+
# Calculate new start index: move entire selection left by 1
|
|
648
|
+
new_start_index = sorted_indices[0] - 1
|
|
649
|
+
else: # direction == 1
|
|
650
|
+
# Right: check if last selected index is at last position
|
|
651
|
+
if sorted_indices[-1] == len(self._recipe) - 1:
|
|
652
|
+
return # Cannot move further right
|
|
653
|
+
# Calculate new start index: move entire selection right by 1
|
|
654
|
+
new_start_index = sorted_indices[0] + 1
|
|
655
|
+
|
|
656
|
+
# Create a copy of the current recipe
|
|
657
|
+
new_recipe = self._recipe.copy()
|
|
658
|
+
|
|
659
|
+
# Remove selected parts from their original positions (in reverse order to maintain indices)
|
|
660
|
+
selected_parts = []
|
|
661
|
+
for index in reversed(sorted_indices):
|
|
662
|
+
selected_parts.append(new_recipe.pop(index))
|
|
663
|
+
|
|
664
|
+
# Reverse the selected parts to maintain their original order
|
|
665
|
+
selected_parts.reverse()
|
|
666
|
+
|
|
667
|
+
# Ensure new_start_index is within bounds
|
|
668
|
+
new_start_index = max(0, min(new_start_index, len(new_recipe)))
|
|
669
|
+
|
|
670
|
+
# Insert selected parts at the new position (moved by exactly 1)
|
|
671
|
+
insert_pos = new_start_index
|
|
672
|
+
for part in selected_parts:
|
|
673
|
+
new_recipe.insert(insert_pos, part)
|
|
674
|
+
insert_pos += 1
|
|
675
|
+
|
|
676
|
+
# Update recipe
|
|
677
|
+
self.recipe = new_recipe
|
|
678
|
+
|
|
679
|
+
# Update selection to new positions
|
|
680
|
+
new_selected = set()
|
|
681
|
+
for i in range(len(selected_parts)):
|
|
682
|
+
new_selected.add(new_start_index + i)
|
|
683
|
+
self.selected_indices = new_selected
|
|
684
|
+
self.last_selected_index = max(new_selected) if new_selected else -1
|
|
685
|
+
|
|
686
|
+
self.update_selection_display()
|
|
687
|
+
|
|
688
|
+
def on_multiplier_changed(self):
|
|
689
|
+
# Get selected multiplier - only affects drag-and-drop operations
|
|
690
|
+
index = self.multiplier_group.checkedId()
|
|
691
|
+
multipliers = [1, 2, 4, 5, 10]
|
|
692
|
+
self._multiplier = multipliers[index]
|
|
693
|
+
# No need to update info since multiplier doesn't affect existing recipe
|
|
694
|
+
self.onChanged.emit()
|
|
695
|
+
|
|
696
|
+
def on_optimise_changed(self, state: int):
|
|
697
|
+
optimise = state == Qt.CheckState.Checked.value
|
|
698
|
+
self.model.set_optimise(optimise)
|
|
699
|
+
self._optimise = optimise
|
|
700
|
+
self.update_info()
|
|
701
|
+
self.onChanged.emit()
|
|
702
|
+
|
|
703
|
+
def update_info(self):
|
|
704
|
+
# Update cost label
|
|
705
|
+
creep_info = self.model.get_creep_info()
|
|
706
|
+
self.cost_label.setText(f"{lang('cost')}: {creep_info.cost}")
|
|
707
|
+
self._cost = creep_info.cost
|
|
708
|
+
|
|
709
|
+
# Update optimised recipe display
|
|
710
|
+
self.update_optimised_display()
|
|
711
|
+
|
|
712
|
+
# Update static info with ✅/❌ for boolean values
|
|
713
|
+
self.info_display["grade"].setText(str(creep_info.grade))
|
|
714
|
+
self.info_display["effect"].setText(f"{creep_info.effect:.2f}%")
|
|
715
|
+
self.info_display["melee"].setText("✅" if creep_info.melee else "❌")
|
|
716
|
+
self.info_display["ranged"].setText("✅" if creep_info.ranged else "❌")
|
|
717
|
+
self.info_display["heal"].setText("✅" if creep_info.heal else "❌")
|
|
718
|
+
self.info_display["work"].setText("✅" if creep_info.work else "❌")
|
|
719
|
+
self.info_display["storable"].setText("✅" if creep_info.storable else "❌")
|
|
720
|
+
self.info_display["attack_power"].setText(str(creep_info.attack_power))
|
|
721
|
+
self.info_display["melee_power"].setText(str(creep_info.melee_power))
|
|
722
|
+
self.info_display["ranged_power"].setText(str(creep_info.ranged_power))
|
|
723
|
+
self.info_display["heal_power"].setText(str(creep_info.heal_power))
|
|
724
|
+
self.info_display["motion_ability"].setText(f"{creep_info.motion_ability:.2f}")
|
|
725
|
+
self.info_display["armor_ratio"].setText(f"{creep_info.armor_ratio:.2f}")
|
|
726
|
+
self.info_display["melee_ratio"].setText(f"{int(creep_info.melee_ratio)}")
|
|
727
|
+
|
|
728
|
+
# Update properties
|
|
729
|
+
self._grade = creep_info.grade
|
|
730
|
+
self._effect = creep_info.effect
|
|
731
|
+
|
|
732
|
+
# Update preview and string representation
|
|
733
|
+
final_recipe = self.model.get_final_recipe()
|
|
734
|
+
self._preview = str(final_recipe).replace("'", "\"").replace('"', "'")
|
|
735
|
+
self._string = creep_info.get_recipe_string()
|
|
736
|
+
|
|
737
|
+
def update_optimised_display(self):
|
|
738
|
+
# Clear all items from each optimised row layout
|
|
739
|
+
for row_layout in self.optimised_row_layouts:
|
|
740
|
+
for i in reversed(range(row_layout.count())):
|
|
741
|
+
item = row_layout.itemAt(i)
|
|
742
|
+
if item:
|
|
743
|
+
widget = item.widget()
|
|
744
|
+
if widget:
|
|
745
|
+
widget.deleteLater()
|
|
746
|
+
|
|
747
|
+
# Display optimised recipe with same top-left layout as recipe area
|
|
748
|
+
final_recipe = self.model.get_final_recipe()
|
|
749
|
+
|
|
750
|
+
# Add parts from top-left corner, 10 per row, no auto-centering
|
|
751
|
+
for i, part_type in enumerate(final_recipe):
|
|
752
|
+
row = i // 10
|
|
753
|
+
col = i % 10
|
|
754
|
+
if row < 5 and col < 10: # Keep within 5 rows, 10 columns
|
|
755
|
+
part_widget = QPSABodyPart(part_type)
|
|
756
|
+
part_widget.setCursor(Qt.CursorShape.ArrowCursor)
|
|
757
|
+
part_widget.setAcceptDrops(False)
|
|
758
|
+
# Add to the appropriate row layout
|
|
759
|
+
self.optimised_row_layouts[row].addWidget(part_widget)
|
|
760
|
+
|
|
761
|
+
# Properties
|
|
762
|
+
@pyqtProperty(list)
|
|
763
|
+
def recipe(self) -> List[str]:
|
|
764
|
+
return self._recipe
|
|
765
|
+
|
|
766
|
+
@recipe.setter
|
|
767
|
+
def recipe(self, value: List[str]):
|
|
768
|
+
# Limit recipe to maximum 50 parts
|
|
769
|
+
self._recipe = value[:50]
|
|
770
|
+
self.model.update_recipe(self._recipe)
|
|
771
|
+
self.update_recipe_display()
|
|
772
|
+
self.update_info()
|
|
773
|
+
self.onChanged.emit()
|
|
774
|
+
|
|
775
|
+
@pyqtProperty(str)
|
|
776
|
+
def preview(self) -> str:
|
|
777
|
+
return self._preview
|
|
778
|
+
|
|
779
|
+
@pyqtProperty(str)
|
|
780
|
+
def string(self) -> str:
|
|
781
|
+
return self._string
|
|
782
|
+
|
|
783
|
+
@pyqtProperty(bool)
|
|
784
|
+
def optimise(self) -> bool:
|
|
785
|
+
return self._optimise
|
|
786
|
+
|
|
787
|
+
@optimise.setter
|
|
788
|
+
def optimise(self, value: bool):
|
|
789
|
+
self._optimise = value
|
|
790
|
+
self.optimise_checkbox.setChecked(value)
|
|
791
|
+
self.model.set_optimise(value)
|
|
792
|
+
self.update_info()
|
|
793
|
+
self.onChanged.emit()
|
|
794
|
+
|
|
795
|
+
@pyqtProperty(int)
|
|
796
|
+
def cost(self) -> int:
|
|
797
|
+
return self._cost
|
|
798
|
+
|
|
799
|
+
@pyqtProperty(int)
|
|
800
|
+
def grade(self) -> int:
|
|
801
|
+
return self._grade
|
|
802
|
+
|
|
803
|
+
@pyqtProperty(float)
|
|
804
|
+
def effect(self) -> float:
|
|
805
|
+
return self._effect
|
|
806
|
+
|
|
807
|
+
def update_recipe_display(self):
|
|
808
|
+
# Clear existing part widgets
|
|
809
|
+
for widget in self.displayed_parts:
|
|
810
|
+
widget.deleteLater()
|
|
811
|
+
self.displayed_parts.clear()
|
|
812
|
+
|
|
813
|
+
# Clear all items from each row layout
|
|
814
|
+
for row_layout in self.recipe_row_layouts:
|
|
815
|
+
for i in reversed(range(row_layout.count())):
|
|
816
|
+
item = row_layout.itemAt(i)
|
|
817
|
+
if item:
|
|
818
|
+
widget = item.widget()
|
|
819
|
+
if widget:
|
|
820
|
+
widget.deleteLater()
|
|
821
|
+
|
|
822
|
+
# Clear selection when updating display
|
|
823
|
+
self.selected_indices.clear()
|
|
824
|
+
self.last_selected_index = -1
|
|
825
|
+
|
|
826
|
+
# Fill with recipe - up to 50 parts (10x5 grid)
|
|
827
|
+
# Add parts from top-left corner, no auto-centering
|
|
828
|
+
for i, part_type in enumerate(self._recipe[:50]):
|
|
829
|
+
row = i // 10
|
|
830
|
+
col = i % 10
|
|
831
|
+
if row < 5 and col < 10:
|
|
832
|
+
part_widget = QPSABodyPart(part_type)
|
|
833
|
+
part_widget.index = i # Set index for selection management
|
|
834
|
+
part_widget.setCursor(Qt.CursorShape.PointingHandCursor) # For selectable parts
|
|
835
|
+
# Install event filter for drag-and-drop reordering
|
|
836
|
+
part_widget.installEventFilter(self)
|
|
837
|
+
# Connect click event for selection
|
|
838
|
+
original_mouse_press = part_widget.mousePressEvent
|
|
839
|
+
def custom_mouse_press(event, w=part_widget, original=original_mouse_press):
|
|
840
|
+
# Call original to set drag_start_position
|
|
841
|
+
original(event)
|
|
842
|
+
# Then handle selection
|
|
843
|
+
self.on_part_clicked(event, w)
|
|
844
|
+
part_widget.mousePressEvent = custom_mouse_press
|
|
845
|
+
# Add to the appropriate row layout
|
|
846
|
+
self.recipe_row_layouts[row].addWidget(part_widget)
|
|
847
|
+
self.displayed_parts.append(part_widget)
|
|
848
|
+
|
|
849
|
+
# Update selection display
|
|
850
|
+
self.update_selection_display()
|
|
851
|
+
|
|
852
|
+
def on_part_clicked(self, event: QMouseEvent, part_widget: QPSABodyPart):
|
|
853
|
+
# Get clicked index
|
|
854
|
+
clicked_index = part_widget.index
|
|
855
|
+
|
|
856
|
+
# Check modifier keys
|
|
857
|
+
is_control_down = event.modifiers() & Qt.KeyboardModifier.ControlModifier
|
|
858
|
+
is_shift_down = event.modifiers() & Qt.KeyboardModifier.ShiftModifier
|
|
859
|
+
|
|
860
|
+
if is_shift_down and self.last_selected_index >= 0:
|
|
861
|
+
# SHIFT selection - select range from last selected to current
|
|
862
|
+
start = min(self.last_selected_index, clicked_index)
|
|
863
|
+
end = max(self.last_selected_index, clicked_index)
|
|
864
|
+
|
|
865
|
+
if is_control_down:
|
|
866
|
+
# CONTROL+SHIFT - toggle range selection
|
|
867
|
+
for i in range(start, end + 1):
|
|
868
|
+
if i in self.selected_indices:
|
|
869
|
+
self.selected_indices.remove(i)
|
|
870
|
+
else:
|
|
871
|
+
self.selected_indices.add(i)
|
|
872
|
+
else:
|
|
873
|
+
# Just SHIFT - select entire range, replacing current selection
|
|
874
|
+
self.selected_indices = set(range(start, end + 1))
|
|
875
|
+
elif is_control_down:
|
|
876
|
+
# CONTROL selection - toggle clicked item
|
|
877
|
+
if clicked_index in self.selected_indices:
|
|
878
|
+
self.selected_indices.remove(clicked_index)
|
|
879
|
+
else:
|
|
880
|
+
self.selected_indices.add(clicked_index)
|
|
881
|
+
else:
|
|
882
|
+
# Single click - select only clicked item
|
|
883
|
+
self.selected_indices = {clicked_index}
|
|
884
|
+
|
|
885
|
+
# Update last selected index
|
|
886
|
+
self.last_selected_index = clicked_index
|
|
887
|
+
|
|
888
|
+
# Update display
|
|
889
|
+
self.update_selection_display()
|
|
890
|
+
|
|
891
|
+
def update_selection_display(self):
|
|
892
|
+
# Update selected state for all displayed parts
|
|
893
|
+
for widget in self.displayed_parts:
|
|
894
|
+
widget.set_selected(widget.index in self.selected_indices)
|
|
895
|
+
|
|
896
|
+
if __name__ == "__main__":
|
|
897
|
+
from PyQt6.QtWidgets import QApplication
|
|
898
|
+
import sys
|
|
899
|
+
|
|
900
|
+
app = QApplication(sys.argv)
|
|
901
|
+
window = QWidget()
|
|
902
|
+
window.setWindowTitle("QPSARecipe Test")
|
|
903
|
+
layout = QVBoxLayout(window)
|
|
904
|
+
|
|
905
|
+
recipe_widget = QPSARecipe()
|
|
906
|
+
layout.addWidget(recipe_widget)
|
|
907
|
+
|
|
908
|
+
# Test display
|
|
909
|
+
test_recipe = ['WORK', 'WORK', 'WORK', 'MOVE', 'MOVE', 'MOVE']
|
|
910
|
+
recipe_widget.recipe = test_recipe
|
|
911
|
+
|
|
912
|
+
window.resize(400, 600)
|
|
913
|
+
window.show()
|
|
914
|
+
sys.exit(app.exec())
|