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.
@@ -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())