442 lines
17 KiB
Plaintext
442 lines
17 KiB
Plaintext
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
|