445 lines
16 KiB
Python
445 lines
16 KiB
Python
![]() |
import json
|
||
|
from typing import Optional
|
||
|
|
||
|
from PySide6.QtWidgets import (
|
||
|
QTreeWidget, QTreeWidgetItem, QAbstractItemView, QMessageBox, QMenu,
|
||
|
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QCheckBox,
|
||
|
QPushButton, QSpinBox, QDialogButtonBox, QFormLayout, QPlainTextEdit,
|
||
|
QInputDialog, QStyledItemDelegate, QStyleOptionViewItem
|
||
|
)
|
||
|
from PySide6.QtCore import Qt, QPoint, QModelIndex
|
||
|
from PySide6.QtGui import QMouseEvent, QPainter
|
||
|
|
||
|
# --------------------------------------------------------------------
|
||
|
# DELEGAT DO WCIĘĆ (IndentationDelegate)
|
||
|
# --------------------------------------------------------------------
|
||
|
class IndentationDelegate(QStyledItemDelegate):
|
||
|
"""
|
||
|
Delegate rysujący wcięcie w KAŻDEJ kolumnie,
|
||
|
zależne od poziomu zagnieżdżenia w drzewie (rodziców).
|
||
|
Nie modyfikuje item.text().
|
||
|
"""
|
||
|
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex):
|
||
|
level = self.get_depth(index)
|
||
|
indent_px = 20 * level # 20 pikseli na poziom
|
||
|
new_option = QStyleOptionViewItem(option)
|
||
|
# przesunięcie rect w prawo o indent_px
|
||
|
new_option.rect.translate(indent_px, 0)
|
||
|
# zmniejszenie szerokości o indent_px
|
||
|
new_option.rect.setWidth(new_option.rect.width() - indent_px)
|
||
|
|
||
|
super().paint(painter, new_option, index)
|
||
|
|
||
|
def get_depth(self, index: QModelIndex) -> int:
|
||
|
depth = 0
|
||
|
model = index.model()
|
||
|
parent_idx = model.parent(index)
|
||
|
while parent_idx.isValid():
|
||
|
depth += 1
|
||
|
parent_idx = model.parent(parent_idx)
|
||
|
return depth
|
||
|
|
||
|
|
||
|
from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem, QAbstractItemView, QMessageBox, QMenu
|
||
|
from PySide6.QtCore import Qt, QPoint
|
||
|
from PySide6.QtGui import QMouseEvent
|
||
|
|
||
|
# Import komend (Undo/Redo)
|
||
|
from commands import AddItemCommand, RemoveItemCommand, EditPunktyCommand
|
||
|
|
||
|
# Narzędzia kolorowania i wcięć
|
||
|
from utils.colors import color_item_by_title
|
||
|
from utils.indentation import apply_indentation_to_all_columns
|
||
|
|
||
|
|
||
|
###############################################################################
|
||
|
# AddItemDialog #
|
||
|
###############################################################################
|
||
|
class AddItemDialog(QDialog):
|
||
|
"""
|
||
|
Proste okno dialogowe, pozwalające wprowadzić:
|
||
|
- nr (int)
|
||
|
- opis (string)
|
||
|
- punkty (int, np. -1, 0, 5)
|
||
|
- obowiązkowe (checkbox -> True/False)
|
||
|
"""
|
||
|
def __init__(self, parent=None):
|
||
|
super().__init__(parent)
|
||
|
self.setWindowTitle("Dodaj nowy węzeł")
|
||
|
|
||
|
self.nr_spin = QSpinBox()
|
||
|
self.nr_spin.setRange(-9999, 9999)
|
||
|
self.nr_spin.setValue(1)
|
||
|
|
||
|
self.opis_edit = QLineEdit()
|
||
|
self.opis_edit.setPlaceholderText("np. Sprawność rachunkowa...")
|
||
|
|
||
|
self.punkty_spin = QSpinBox()
|
||
|
self.punkty_spin.setRange(-9999, 9999)
|
||
|
self.punkty_spin.setValue(-1)
|
||
|
|
||
|
self.obow_checkbox = QCheckBox("Obowiązkowe?")
|
||
|
|
||
|
form_layout = QFormLayout()
|
||
|
form_layout.addRow("Nr:", self.nr_spin)
|
||
|
form_layout.addRow("Opis:", self.opis_edit)
|
||
|
form_layout.addRow("Punkty:", self.punkty_spin)
|
||
|
form_layout.addRow(self.obow_checkbox)
|
||
|
|
||
|
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=self)
|
||
|
self.button_box.accepted.connect(self.accept)
|
||
|
self.button_box.rejected.connect(self.reject)
|
||
|
|
||
|
main_layout = QVBoxLayout()
|
||
|
main_layout.addLayout(form_layout)
|
||
|
main_layout.addWidget(self.button_box)
|
||
|
self.setLayout(main_layout)
|
||
|
|
||
|
def get_data(self) -> dict:
|
||
|
return {
|
||
|
"nr": self.nr_spin.value(),
|
||
|
"opis": self.opis_edit.text().strip(),
|
||
|
"punkty": self.punkty_spin.value(),
|
||
|
"obowiązkowe": self.obow_checkbox.isChecked()
|
||
|
}
|
||
|
|
||
|
|
||
|
###############################################################################
|
||
|
# DropTreeWidget #
|
||
|
###############################################################################
|
||
|
class DropTreeWidget(QTreeWidget):
|
||
|
"""
|
||
|
Drzewo docelowe (drop target). Przyjmuje JSON via drag&drop z DragTreeWidget,
|
||
|
umożliwia Undo/Redo, kasowanie, edycję punktów, itd.
|
||
|
|
||
|
Dodajemy IndentationDelegate do rysowania wcięć we wszystkich kolumnach.
|
||
|
"""
|
||
|
def __init__(self, undo_stack=None, parent=None):
|
||
|
super().__init__(parent)
|
||
|
self.undo_stack = undo_stack
|
||
|
self.setAcceptDrops(True)
|
||
|
self.setDropIndicatorShown(True)
|
||
|
self.setDragDropMode(QAbstractItemView.DropOnly)
|
||
|
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||
|
self.drop_event_in_progress = False
|
||
|
|
||
|
# ------- Ustaw delegata do wcięć w 4 kolumnach -------
|
||
|
delegate = IndentationDelegate(self)
|
||
|
for col in range(4):
|
||
|
self.setItemDelegateForColumn(col, delegate)
|
||
|
# -----------------------------------------------------
|
||
|
|
||
|
# ----------------------------------------------------------------
|
||
|
# KONTEKSTOWE MENU (PRAWY KLIK)
|
||
|
# ----------------------------------------------------------------
|
||
|
def contextMenuEvent(self, event):
|
||
|
menu = QMenu(self)
|
||
|
|
||
|
add_act = menu.addAction("Dodaj węzeł")
|
||
|
paste_act = menu.addAction("Wklej JSON Subtree")
|
||
|
|
||
|
# Jeśli mamy 1 zaznaczony węzeł z punktem != -1, pokaż edycję
|
||
|
selected_items = self.selectedItems()
|
||
|
edit_punkty_act = None
|
||
|
if len(selected_items) == 1:
|
||
|
item = selected_items[0]
|
||
|
punkty_str = item.text(0).strip()
|
||
|
if punkty_str not in ("-1", "-", ""):
|
||
|
try:
|
||
|
val = int(punkty_str)
|
||
|
if val != -1:
|
||
|
edit_punkty_act = menu.addAction("Edytuj punktację (1..10)")
|
||
|
except ValueError:
|
||
|
pass
|
||
|
|
||
|
chosen_action = menu.exec(self.mapToGlobal(event.pos()))
|
||
|
if chosen_action == add_act:
|
||
|
self.add_item_interactive()
|
||
|
elif chosen_action == paste_act:
|
||
|
self.paste_json_subtree()
|
||
|
elif chosen_action == edit_punkty_act:
|
||
|
self.edit_punkty_1_10(selected_items[0])
|
||
|
|
||
|
def add_item_interactive(self):
|
||
|
from PySide6.QtWidgets import QTreeWidgetItem
|
||
|
from commands import AddItemCommand
|
||
|
from utils.helpers import display_number_or_dash, display_obligatory
|
||
|
|
||
|
dialog = AddItemDialog(self)
|
||
|
if dialog.exec() == QDialog.Accepted:
|
||
|
data = dialog.get_data()
|
||
|
selected_items = self.selectedItems()
|
||
|
if selected_items:
|
||
|
parent_item = selected_items[0]
|
||
|
index = parent_item.childCount()
|
||
|
else:
|
||
|
parent_item = None
|
||
|
index = self.topLevelItemCount()
|
||
|
|
||
|
punkty_str = display_number_or_dash(data["punkty"])
|
||
|
obow_val = 1 if data["obowiązkowe"] else 0
|
||
|
obow_str = display_obligatory(obow_val)
|
||
|
nr_str = display_number_or_dash(data["nr"])
|
||
|
opis_str = data["opis"]
|
||
|
|
||
|
new_item = QTreeWidgetItem([punkty_str, obow_str, nr_str, opis_str])
|
||
|
color_item_by_title(new_item)
|
||
|
|
||
|
cmd = AddItemCommand(self, parent_item, index, new_item, "Add new item")
|
||
|
if self.undo_stack:
|
||
|
self.undo_stack.push(cmd)
|
||
|
else:
|
||
|
cmd.redo()
|
||
|
|
||
|
# ----------------------------------------------------------------
|
||
|
# EDYTOWANIE PUNKTÓW (1..10) - QInputDialog, Undo/Redo
|
||
|
# ----------------------------------------------------------------
|
||
|
def edit_punkty_1_10(self, item: QTreeWidgetItem):
|
||
|
from commands import EditPunktyCommand
|
||
|
|
||
|
old_val_str = item.text(0).strip()
|
||
|
try:
|
||
|
old_val_int = int(old_val_str)
|
||
|
except ValueError:
|
||
|
return
|
||
|
|
||
|
new_val_int, ok = QInputDialog.getInt(
|
||
|
self,
|
||
|
"Edytuj punktację",
|
||
|
f"Aktualna wartość = {old_val_int}. Wybierz nową (1..10):",
|
||
|
old_val_int,
|
||
|
1, # min
|
||
|
10 # max
|
||
|
)
|
||
|
if not ok:
|
||
|
return
|
||
|
if new_val_int == old_val_int:
|
||
|
return
|
||
|
|
||
|
cmd = EditPunktyCommand(item, str(old_val_int), str(new_val_int), "Edit punkty")
|
||
|
if self.undo_stack:
|
||
|
self.undo_stack.push(cmd)
|
||
|
else:
|
||
|
cmd.redo()
|
||
|
|
||
|
# -------------------------------------
|
||
|
# Shift + = / Shift + - -> +/- 1
|
||
|
# -------------------------------------
|
||
|
def keyPressEvent(self, event):
|
||
|
if event.modifiers() & Qt.ShiftModifier:
|
||
|
if event.key() in (Qt.Key_Equal, Qt.Key_Plus):
|
||
|
self.handle_punkty_inc_dec(+1)
|
||
|
return
|
||
|
elif event.key() in (Qt.Key_Minus, Qt.Key_Underscore):
|
||
|
self.handle_punkty_inc_dec(-1)
|
||
|
return
|
||
|
|
||
|
if event.key() == Qt.Key_Backspace:
|
||
|
self.delete_selected_items()
|
||
|
event.accept()
|
||
|
elif event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_T:
|
||
|
self.toggle_expand_selected_items()
|
||
|
event.accept()
|
||
|
else:
|
||
|
super().keyPressEvent(event)
|
||
|
|
||
|
def handle_punkty_inc_dec(self, inc: int):
|
||
|
from commands import EditPunktyCommand
|
||
|
|
||
|
sel = self.selectedItems()
|
||
|
if len(sel) != 1:
|
||
|
return
|
||
|
item = sel[0]
|
||
|
old_val_str = item.text(0).strip()
|
||
|
try:
|
||
|
old_val_int = int(old_val_str)
|
||
|
except ValueError:
|
||
|
return
|
||
|
if old_val_int == -1:
|
||
|
return
|
||
|
|
||
|
new_val_int = old_val_int + inc
|
||
|
new_val_int = max(1, min(10, new_val_int))
|
||
|
if new_val_int == old_val_int:
|
||
|
return
|
||
|
|
||
|
cmd = EditPunktyCommand(item, str(old_val_int), str(new_val_int), "Inc/Dec punkty")
|
||
|
if self.undo_stack:
|
||
|
self.undo_stack.push(cmd)
|
||
|
else:
|
||
|
cmd.redo()
|
||
|
|
||
|
# ----------------------------------------------------------------
|
||
|
# USUWANIE - Undo/Redo
|
||
|
# ----------------------------------------------------------------
|
||
|
def delete_selected_items(self):
|
||
|
selected = self.selectedItems()
|
||
|
for item in reversed(selected):
|
||
|
cmd = RemoveItemCommand(self, item)
|
||
|
if self.undo_stack:
|
||
|
self.undo_stack.push(cmd)
|
||
|
else:
|
||
|
cmd.redo()
|
||
|
|
||
|
# ----------------------------------------------------------------
|
||
|
# Expand toggling (np. Ctrl+T w MainWindow)
|
||
|
# ----------------------------------------------------------------
|
||
|
def toggle_expand_selected_items(self):
|
||
|
selected_items = self.selectedItems()
|
||
|
for item in selected_items:
|
||
|
self.toggle_expand_item_and_children(item)
|
||
|
|
||
|
def toggle_expand_item_and_children(self, item: QTreeWidgetItem):
|
||
|
if item.isExpanded():
|
||
|
self.collapseItem(item)
|
||
|
else:
|
||
|
self.expandItem(item)
|
||
|
for i in range(item.childCount()):
|
||
|
self.toggle_expand_item_and_children(item.child(i))
|
||
|
|
||
|
# -------------------------------------
|
||
|
# Obsługa myszy (zaznaczenie)
|
||
|
# -------------------------------------
|
||
|
def mousePressEvent(self, event: QMouseEvent):
|
||
|
item = self.itemAt(event.position().toPoint())
|
||
|
if item:
|
||
|
if event.modifiers() & Qt.ControlModifier:
|
||
|
item.setSelected(not item.isSelected())
|
||
|
else:
|
||
|
super().mousePressEvent(event)
|
||
|
else:
|
||
|
super().mousePressEvent(event)
|
||
|
|
||
|
# -------------------------------------
|
||
|
# DRAG & DROP
|
||
|
# -------------------------------------
|
||
|
def dragEnterEvent(self, event):
|
||
|
if event.mimeData().hasText():
|
||
|
event.acceptProposedAction()
|
||
|
else:
|
||
|
event.ignore()
|
||
|
|
||
|
def dragMoveEvent(self, event):
|
||
|
if event.mimeData().hasText():
|
||
|
event.acceptProposedAction()
|
||
|
else:
|
||
|
event.ignore()
|
||
|
|
||
|
def dropEvent(self, event):
|
||
|
if self.drop_event_in_progress:
|
||
|
event.ignore()
|
||
|
return
|
||
|
|
||
|
self.drop_event_in_progress = True
|
||
|
try:
|
||
|
pos = event.position().toPoint()
|
||
|
target_item = self.itemAt(pos)
|
||
|
|
||
|
try:
|
||
|
data_list = json.loads(event.mimeData().text())
|
||
|
except json.JSONDecodeError:
|
||
|
event.ignore()
|
||
|
return
|
||
|
|
||
|
for node_data in data_list:
|
||
|
self.add_subtree_recursively(target_item, node_data)
|
||
|
|
||
|
# POZOSTAWIAMY Twoją logikę (apply_indentation_to_all_columns),
|
||
|
# choć delegat robi wcięcia.
|
||
|
apply_indentation_to_all_columns(self)
|
||
|
|
||
|
event.acceptProposedAction()
|
||
|
finally:
|
||
|
self.drop_event_in_progress = False
|
||
|
|
||
|
# ----------------------------------------------------------------
|
||
|
# Logika dodawania / scalania węzłów
|
||
|
# ----------------------------------------------------------------
|
||
|
def add_subtree_recursively(self, target_parent: Optional[QTreeWidgetItem], node_data: dict):
|
||
|
existing_parent = self.find_matching_parent(target_parent, node_data)
|
||
|
if existing_parent:
|
||
|
self.merge_all_children(existing_parent, node_data.get("children", []))
|
||
|
else:
|
||
|
new_parent = QTreeWidgetItem([
|
||
|
node_data.get("punkty", "0"),
|
||
|
node_data.get("obowiązkowe", "nie"),
|
||
|
node_data.get("nr", ""),
|
||
|
node_data.get("opis", "")
|
||
|
])
|
||
|
color_item_by_title(new_parent)
|
||
|
|
||
|
if target_parent:
|
||
|
target_parent.addChild(new_parent)
|
||
|
else:
|
||
|
self.addTopLevelItem(new_parent)
|
||
|
|
||
|
self.merge_all_children(new_parent, node_data.get("children", []))
|
||
|
|
||
|
def find_matching_parent(self, target_parent: Optional[QTreeWidgetItem], node_data: dict) -> Optional[QTreeWidgetItem]:
|
||
|
return self.find_child_by_attributes(target_parent, node_data.get("nr", ""), node_data.get("opis", ""))
|
||
|
|
||
|
def merge_all_children(self, parent_item: QTreeWidgetItem, children_data: list):
|
||
|
for child_data in children_data:
|
||
|
existing_child = self.find_child_by_attributes(
|
||
|
parent_item,
|
||
|
child_data.get("nr", ""),
|
||
|
child_data.get("opis", "")
|
||
|
)
|
||
|
if existing_child:
|
||
|
self.merge_all_children(existing_child, child_data.get("children", []))
|
||
|
else:
|
||
|
new_child = QTreeWidgetItem([
|
||
|
child_data.get("punkty", "0"),
|
||
|
child_data.get("obowiązkowe", "nie"),
|
||
|
child_data.get("nr", ""),
|
||
|
child_data.get("opis", "")
|
||
|
])
|
||
|
color_item_by_title(new_child)
|
||
|
parent_item.addChild(new_child)
|
||
|
self.merge_all_children(new_child, child_data.get("children", []))
|
||
|
|
||
|
def find_child_by_attributes(self, parent_item: Optional[QTreeWidgetItem], nr: str, opis: str) -> Optional[QTreeWidgetItem]:
|
||
|
if parent_item:
|
||
|
for i in range(parent_item.childCount()):
|
||
|
child = parent_item.child(i)
|
||
|
if child.text(2).strip() == nr.strip() and child.text(3).strip() == opis.strip():
|
||
|
return child
|
||
|
else:
|
||
|
for i in range(self.topLevelItemCount()):
|
||
|
item = self.topLevelItem(i)
|
||
|
if item.text(2).strip() == nr.strip() and item.text(3).strip() == opis.strip():
|
||
|
return item
|
||
|
return None
|
||
|
|
||
|
# ---- [PONIŻEJ DODAJEMY DELEGAT DO WCIĘĆ] ----
|
||
|
class IndentationDelegate(QStyledItemDelegate):
|
||
|
"""
|
||
|
Delegate rysujący wcięcia w każdej kolumnie w zależności od poziomu
|
||
|
zagnieżdżenia w drzewie. Nie modyfikuje samego tekstu w węźle.
|
||
|
"""
|
||
|
def paint(self, painter, option, index):
|
||
|
level = self.get_depth(index)
|
||
|
indent_px = 20 * level
|
||
|
new_option = QStyleOptionViewItem(option)
|
||
|
new_option.rect.translate(indent_px, 0)
|
||
|
new_option.rect.setWidth(new_option.rect.width() - indent_px)
|
||
|
super().paint(painter, new_option, index)
|
||
|
|
||
|
def get_depth(self, index):
|
||
|
depth = 0
|
||
|
model = index.model()
|
||
|
parent_index = model.parent(index)
|
||
|
while parent_index.isValid():
|
||
|
depth += 1
|
||
|
parent_index = model.parent(parent_index)
|
||
|
return depth
|
||
|
|
||
|
|
||
|
# (Na końcu pliku) W konstruktorze DropTreeWidget
|
||
|
# dopisz:
|
||
|
"""
|
||
|
delegate = IndentationDelegate(self)
|
||
|
for col in range(4):
|
||
|
self.setItemDelegateForColumn(col, delegate)
|
||
|
"""
|