so-gui/widgets/drop_tree_widget.py-kk

442 lines
17 KiB
Plaintext
Raw Normal View History

2025-02-01 18:16:23 +01:00
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
)
from PySide6.QtCore import Qt, QPoint
from PySide6.QtGui import QMouseEvent
# Import komend (Undo/Redo)
from commands import AddItemCommand, RemoveItemCommand
# Import narzędzi 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ł")
# Pola formularza
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?")
# Układ typu FormLayout
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)
# Przyciski OK/Cancel
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=self)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
# Główny layout
main_layout = QVBoxLayout()
main_layout.addLayout(form_layout)
main_layout.addWidget(self.button_box)
self.setLayout(main_layout)
def get_data(self) -> dict:
"""
Zwraca słownik z parametrami:
{"nr": int, "opis": str, "punkty": int, "obowiązkowe": bool}
"""
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, itd.
"""
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
# 1) Podłączanie sygnału itemClicked
self.itemClicked.connect(self.handle_item_click)
def handle_item_click(self, item, column):
"""
Po kliknięciu w węzeł kopiuje do schowka JSON zawierający
ścieżkę (rodzice) od korzenia do tego węzła ale BEZ dzieci!
"""
chain_json = self.item_to_json_parents_no_children(item)
print("[LOG] Copied item + parents (NO children) to clipboard:")
print(chain_json)
from PySide6.QtWidgets import QApplication
QApplication.clipboard().setText(chain_json)
def item_to_json_parents_no_children(self, item: QTreeWidgetItem) -> str:
"""
Buduje strukturę JSON (jako string) dla pojedynczej ścieżki
'korzeń -> ... -> item', bez dzieci. Każdy węzeł ma 'children': [].
"""
import json
# Zacznij od zaznaczonego węzła
node_dict = self.item_to_dict_no_children(item)
# Idź w górę, owijając node_dict w kolejnych rodziców
current = item.parent()
while current is not None:
parent_dict = self.item_to_dict_no_children(current)
parent_dict["children"] = [node_dict]
node_dict = parent_dict
current = current.parent()
# Zamień na ładny, sformatowany JSON
return json.dumps(node_dict, ensure_ascii=False, indent=2)
def item_to_dict_no_children(self, item: QTreeWidgetItem) -> dict:
"""
Zwraca słownik z polami (punkty, obowiązkowe, nr, opis),
ale 'children' zawsze jest pustą listą.
"""
return {
"punkty": item.text(0),
"obowiązkowe": item.text(1),
"nr": item.text(2),
"opis": item.text(3),
"children": []
}
# ----------------------------------------------------------------
# KONTEKSTOWE MENU (PRAWY KLIK)
# ----------------------------------------------------------------
def contextMenuEvent(self, event):
"""Wywołane przy kliknięciu prawym przyciskiem myszy (menu kontekstowe)."""
menu = QMenu(self)
add_act = menu.addAction("Dodaj węzeł")
paste_act = menu.addAction("Wklej JSON Subtree")
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()
def add_item_interactive(self):
"""
Wywoływane po wybraniu opcji "Dodaj węzeł" z menu kontekstowego.
Otwiera dialog, a następnie tworzy QTreeWidgetItem i wstawia do drzewa
z użyciem AddItemCommand (undo/redo).
"""
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()
# data ma postać: {"nr": int, "opis": str, "punkty": int, "obowiązkowe": bool}
# Wyznaczamy, gdzie wstawić nowy węzeł:
selected_items = self.selectedItems()
if selected_items:
parent_item = selected_items[0]
index = parent_item.childCount()
else:
parent_item = None
index = self.topLevelItemCount()
# Konwersja wartości do stringów (uwzględniając -1 => '-')
punkty_str = display_number_or_dash(data["punkty"])
# Dla bool w polu obowiązkowe -> 1 (tak) lub 0 (nie)
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, # kolumna 0 -> punkty
obow_str, # kolumna 1 -> obowiązkowe
nr_str, # kolumna 2 -> nr
opis_str # kolumna 3 -> opis
])
# Kolorowanie, jeśli używasz color_item_by_title:
from utils.colors import color_item_by_title
color_item_by_title(new_item)
# Undo/Redo tworzymy komendę i wrzucamy na stos
cmd = AddItemCommand(
tree=self,
parent_item=parent_item,
index=index,
cloned_item=new_item,
description="Add new item"
)
if self.undo_stack:
self.undo_stack.push(cmd)
else:
cmd.redo()
# ----------------------------------------------------------------
# NOWA OPCJA: WKLEJANIE SUBTREE Z JSON
# ----------------------------------------------------------------
def paste_json_subtree(self):
"""
Otwiera dialog, w którym użytkownik wkleja JSON w formie listy/dict.
Następnie zawartość jest dodawana rekurencyjnie do zaznaczonego węzła
(lub top-level, jeśli brak zaznaczenia).
"""
import json
from PySide6.QtWidgets import QDialog, QVBoxLayout, QDialogButtonBox, QPlainTextEdit
class JsonPasteDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Wklej JSON Subtree")
self.edit = QPlainTextEdit(self)
self.edit.setPlaceholderText("Wklej tutaj JSON...")
btn_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
btn_box.accepted.connect(self.accept)
btn_box.rejected.connect(self.reject)
layout = QVBoxLayout()
layout.addWidget(self.edit)
layout.addWidget(btn_box)
self.setLayout(layout)
dlg = JsonPasteDialog(self)
if dlg.exec() == QDialog.Accepted:
text = dlg.edit.toPlainText().strip()
if not text:
QMessageBox.information(self, "Brak danych", "Nie wklejono żadnego JSON.")
return
try:
data_list = json.loads(text)
# Jeśli ktoś wklei pojedyńczy obiekt (dict),
# to obłóżmy go w listę, żeby iterować tak samo:
if isinstance(data_list, dict):
data_list = [data_list]
except json.JSONDecodeError as e:
QMessageBox.warning(self, "Błąd JSON", f"Niepoprawny JSON:\n{e}")
return
# Wybieramy, gdzie wklejamy subtree
selected = self.selectedItems()
if selected:
parent_item = selected[0]
else:
parent_item = None
# Używamy istniejącej logiki add_subtree_recursively
for node_data in data_list:
self.add_subtree_recursively(parent_item, node_data)
# Upiększamy wcięcia
apply_indentation_to_all_columns(self)
QMessageBox.information(self, "OK", "Dodano subtree z JSON.")
# -------------------------------------
# Obsługa klawiatury (Backspace, Ctrl+T)
# -------------------------------------
def keyPressEvent(self, event):
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 delete_selected_items(self):
"""Usunięcie zaznaczonych elementów (Undo/Redo)."""
selected = self.selectedItems()
for item in reversed(selected):
cmd = RemoveItemCommand(self, item)
if self.undo_stack:
self.undo_stack.push(cmd)
else:
cmd.redo()
def toggle_expand_selected_items(self):
"""Przełącza rozwinięcie/zwinięcie dla zaznaczonych elementów i ich dzieci."""
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: # Ctrl+klik
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)
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):
"""
Dodaje/scala hierarchię, ignorując zduplikowanych rodziców
na podstawie (nr, opis).
"""
existing_parent = self.find_matching_parent(target_parent, node_data)
if existing_parent:
# Jeżeli już istnieje taki węzeł, scal dzieci
self.merge_all_children(existing_parent, node_data.get("children", []))
else:
# Tworzymy nowy element
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)
# Dodaj dzieci rekurencyjnie
self.merge_all_children(new_parent, node_data.get("children", []))
def find_matching_parent(self, target_parent: Optional[QTreeWidgetItem], node_data: dict) -> Optional[QTreeWidgetItem]:
"""Znajduje dziecko w target_parent (lub top-level) pasujące (nr, opis)."""
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):
"""Scal wszystkie dzieci z danymi JSON."""
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:
# Jeżeli już istnieje, rekurencyjnie scal
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]:
"""Wyszukuje dziecko o danych atrybutach (nr, opis)."""
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