diff --git a/trovedb/qgui/main_window.py b/trovedb/qgui/main_window.py index a33d4b1..daa611a 100644 --- a/trovedb/qgui/main_window.py +++ b/trovedb/qgui/main_window.py @@ -12,8 +12,11 @@ from PySide6.QtWidgets import ( from .settings import get_settings +from trovedb import trove as tr +from .note_browser import NoteBrowser + class TroveMainWindow(QMainWindow): - def __init__(self): + def __init__(self, trove: tr.Trove): super().__init__() self.setWindowTitle("Trove") @@ -43,7 +46,7 @@ class TroveMainWindow(QMainWindow): # Horizontal splitter: tree | editor splitter = QSplitter(Qt.Orientation.Horizontal) - self._note_browser = QLabel("Note Browser") + self._note_browser = NoteBrowser(trove) splitter.addWidget(self._note_browser) self._tool = QLabel("View/Edit Tool") diff --git a/trovedb/qgui/note_browser.py b/trovedb/qgui/note_browser.py new file mode 100644 index 0000000..6be5177 --- /dev/null +++ b/trovedb/qgui/note_browser.py @@ -0,0 +1,127 @@ +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 + """ + + note_selected = Signal(object) # Note + tree_selected = Signal(object) # TreeNote + + 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._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.activated.connect(self._on_activated) + 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_activated(self, index: QModelIndex) -> None: + note = index.data(Qt.ItemDataRole.UserRole) + if note is None: + return + if isinstance(note, TreeNote): + self.tree_selected.emit(note) + else: + self.note_selected.emit(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 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 \ No newline at end of file diff --git a/trovedb/qgui/qtrove.py b/trovedb/qgui/qtrove.py index 5a18725..e41b1e8 100644 --- a/trovedb/qgui/qtrove.py +++ b/trovedb/qgui/qtrove.py @@ -2,9 +2,11 @@ """PySide6 GUI for Trove""" import sys +from trovedb import trove_factory, user_env from PySide6.QtWidgets import ( QApplication, + QMessageBox, ) from .main_window import TroveMainWindow @@ -12,10 +14,16 @@ from .main_window import TroveMainWindow def main(): app = QApplication(sys.argv) + trove = trove_factory.get_trove(user_env.TROVEBASE) if user_env.TROVEBASE else None + + if trove is None: + QMessageBox.critical(None, "Error", "Trove Database Not Found") + sys.exit(1) + # Respect system theme on KDE app.setStyle("Fusion") - window = TroveMainWindow() + window = TroveMainWindow(trove) window.show() window.restore_settings() sys.exit(app.exec()) diff --git a/trovedb/qgui/trove_tree_model.py b/trovedb/qgui/trove_tree_model.py new file mode 100644 index 0000000..c74612f --- /dev/null +++ b/trovedb/qgui/trove_tree_model.py @@ -0,0 +1,129 @@ +from typing import Optional +from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, QObject + +from trovedb.trove import Note, TreeNote, Trove, ObjectId, TreeEntry + + +class TroveNode: + """Wrapper around a Note, caching children for tree model use.""" + + def __init__(self, note: Note, name: str, parent: 'Optional[TroveNode]' = None): + self.note = note + self.name = name + self.parent = parent + self._children: Optional[list[TroveNode]] = None # None = not yet loaded + + def is_tree(self) -> bool: + return isinstance(self.note, TreeNote) + + def children(self) -> 'list[TroveNode]': + """Lazy-load and cache children. Sorted by name.""" + if self._children is None: + if self.is_tree(): + tree: TreeNote = self.note # type: ignore[assignment] + entries: list[TreeEntry] = sorted(tree.entries(), key=lambda e: e.name) + self._children = [ + TroveNode(tree.child(e.name), e.name, parent=self) + for e in entries + ] + else: + self._children = [] + return self._children + + def invalidate(self) -> None: + """Clear cached children, forcing a reload on next access.""" + self._children = None + + def child_at(self, row: int) -> 'Optional[TroveNode]': + kids = self.children() + return kids[row] if 0 <= row < len(kids) else None + + def row(self) -> int: + if self.parent is None: + return 0 + return self.parent.children().index(self) + + def child_count(self) -> int: + return len(self.children()) + + +class TroveTreeModel(QAbstractItemModel): + """ + Read-only tree model backed by a Trove instance. + + The root TreeNote is treated as an invisible root; its children + are the top-level items in the view. + """ + + def __init__(self, trove: Trove, parent: Optional[QObject] = None): + super().__init__(parent) + root_note = trove.get_root() + self._root = TroveNode(root_note, "/", parent=None) + + # ------------------------------------------------------------------ + # QAbstractItemModel interface + # ------------------------------------------------------------------ + + def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex: + if not self.hasIndex(row, column, parent): + return QModelIndex() + + parent_node = self._node_from_index(parent) + child = parent_node.child_at(row) + if child is None: + return QModelIndex() + return self.createIndex(row, column, child) + + def parent(self, index: QModelIndex) -> QModelIndex: # type: ignore[override] + if not index.isValid(): + return QModelIndex() + + node: TroveNode = index.internalPointer() # type: ignore[assignment] + parent_node = node.parent + + if parent_node is None or parent_node is self._root: + return QModelIndex() + + return self.createIndex(parent_node.row(), 0, parent_node) + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + parent_node = self._node_from_index(parent) + return parent_node.child_count() + + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: + return 1 + + def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> object: # type: ignore[assignment] + if not index.isValid(): + return None + node: TroveNode = index.internalPointer() # type: ignore[assignment] + if role == Qt.DisplayRole: + return node.name + if role == Qt.UserRole: + return node.note + return None + + def hasChildren(self, parent: QModelIndex = QModelIndex()) -> bool: + node = self._node_from_index(parent) + return node.is_tree() + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _node_from_index(self, index: QModelIndex) -> TroveNode: + if index.isValid(): + node = index.internalPointer() + if node is not None: + return node # type: ignore[return-value] + return self._root + + def invalidate_node(self, index: QModelIndex) -> None: + """ + Force a reload of the node at index. + Call this after mutations to the underlying Trove tree. + """ + node = self._node_from_index(index) + self.beginResetModel() + node.invalidate() + self.endResetModel() \ No newline at end of file