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