from __future__ import annotations from typing import Optional, Callable from PySide6.QtCore import Qt, QModelIndex, Signal from PySide6.QtWidgets import ( QWidget, QTreeView, QVBoxLayout, QHBoxLayout, QPushButton, QMenu, QAbstractItemView, ) from PySide6.QtGui import QAction from trovedb.trove import Note, TreeNote, Trove from .trove_tree_model import TroveTreeModel, TroveNode class NoteBrowser(QWidget): """ A simple tree browser widget for a Trove. Signals: note_selected(Note) -- emitted when a non-tree note is activated tree_selected(TreeNote) -- emitted when a tree note is activated """ activeNoteChanged = Signal(object) # Note def __init__(self, trove: Trove, parent: Optional[QWidget] = None): super().__init__(parent) self._model = TroveTreeModel(trove) self._setup_ui() # ------------------------------------------------------------------ # UI setup # ------------------------------------------------------------------ def _setup_ui(self) -> None: layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(2) self._activeNote: Optional[Note] = None self._tree = QTreeView() self._tree.setModel(self._model) self._tree.setHeaderHidden(True) self._tree.setUniformRowHeights(True) self._tree.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self._tree.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self._tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self._tree.selectionModel().currentChanged.connect(self._on_current_changed) self._tree.customContextMenuRequested.connect(self._on_context_menu) layout.addWidget(self._tree) # Minimal toolbar — refresh only for now toolbar = QHBoxLayout() toolbar.setContentsMargins(0, 0, 0, 0) refresh_btn = QPushButton("Refresh") refresh_btn.setFixedWidth(70) refresh_btn.clicked.connect(self._on_refresh) toolbar.addWidget(refresh_btn) toolbar.addStretch() layout.addLayout(toolbar) # ------------------------------------------------------------------ # Slots # ------------------------------------------------------------------ def _on_current_changed(self, index: QModelIndex) -> None: note = index.data(Qt.ItemDataRole.UserRole) self._handle_set_active_note(note) def _on_context_menu(self, pos) -> None: index = self._tree.indexAt(pos) menu = QMenu(self) refresh_action = QAction("Refresh", self) refresh_action.triggered.connect( lambda: self._model.invalidate_node(index) if index.isValid() else self._on_refresh() ) menu.addAction(refresh_action) # Placeholder hooks — wire up to a controller when ready if index.isValid(): node: TroveNode = index.internalPointer() if node.is_tree(): menu.addSeparator() menu.addAction(QAction("New Note…", self)) # TODO: controller menu.addAction(QAction("New Folder…", self)) # TODO: controller menu.addSeparator() menu.addAction(QAction("Rename…", self)) # TODO: controller menu.addAction(QAction("Delete", self)) # TODO: controller menu.exec(self._tree.viewport().mapToGlobal(pos)) def _on_refresh(self) -> None: """Full model reset — use until event-based invalidation is wired up.""" self._model.invalidate_node(QModelIndex()) # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def _handle_set_active_note(self, note: Note | None) -> None: """Called by the controller to update the active note.""" if not isinstance(note, Note): note = None if self._activeNote != note: self._activeNote = note self.activeNoteChanged.emit(note) def select_note(self, note: Note) -> None: """Programmatically select the node matching the given note object.""" match = self._find_index(self._model.index(0, 0, QModelIndex()), note) if match is not None and match.isValid(): self._tree.setCurrentIndex(match) self._tree.scrollTo(match) def _find_index(self, start: QModelIndex, target: Note) -> Optional[QModelIndex]: """DFS over visible model nodes looking for a matching Note.""" if not start.isValid(): return None node: TroveNode = start.internalPointer() if node.note is target: return start for row in range(self._model.rowCount(start)): child = self._model.index(row, 0, start) result = self._find_index(child, target) if result is not None: return result return None