Browse Source

Import prototype code

Michael Hanke 1 year ago
parent
commit
f239cd56ba

+ 3 - 0
datalad_gooey/__main__.py

@@ -0,0 +1,3 @@
+from .app import main
+
+main()

+ 81 - 0
datalad_gooey/app.py

@@ -0,0 +1,81 @@
+import sys
+from pathlib import Path
+from PySide6.QtUiTools import QUiLoader
+from PySide6.QtWidgets import (
+    QApplication,
+    QTreeView,
+)
+from PySide6.QtCore import (
+    QFile,
+    QIODevice,
+)
+from .fsview_model import (
+    DataladTree,
+    DataladTreeModel,
+)
+
+
+def load_ui(name):
+    ui_file_name = Path(__file__).parent / 'resources' / 'ui' / f"{name}.ui"
+    ui_file = QFile(ui_file_name)
+    if not ui_file.open(QIODevice.ReadOnly):
+        raise RuntimeError(
+            f"Cannot open {ui_file_name}: {ui_file.errorString()}")
+    loader = QUiLoader()
+    ui = loader.load(ui_file)
+    ui_file.close()
+    if not ui:
+        raise RuntimeError(
+            f"Cannot load UI {ui_file_name}: {loader.errorString()}")
+    return ui
+
+
+def get_directory_browser(window):
+    wgt = window.findChild(QTreeView, name='filesystemView')
+    if not wgt:
+        raise RuntimeError("Could not locate directory browser widget")
+    return wgt
+
+
+def setup_main_window():
+    main_window = load_ui('main_window')
+
+    dbrowser = get_directory_browser(main_window)
+    dmodel = DataladTreeModel()
+    dmodel.set_tree(DataladTree(Path.cwd()))
+    dbrowser.setModel(dmodel)
+    dbrowser.clicked.connect(clicked)
+    dbrowser.doubleClicked.connect(doubleclicked)
+    dbrowser.activated.connect(activated)
+    dbrowser.pressed.connect(pressed)
+    dbrowser.customContextMenuRequested.connect(contextrequest)
+
+    return main_window
+
+
+def clicked(*args, **kwargs):
+    print(f'clicked {args!r} {kwargs!r}')
+
+
+def doubleclicked(*args, **kwargs):
+    print(f'doubleclicked {args!r} {kwargs!r}')
+
+
+def activated(*args, **kwargs):
+    print(f'activated {args!r} {kwargs!r}')
+
+
+def pressed(*args, **kwargs):
+    print(f'pressed {args!r} {kwargs!r}')
+
+
+def contextrequest(*args, **kwargs):
+    print(f'contextrequest {args!r} {kwargs!r}')
+
+
+def main():
+    app = QApplication(sys.argv)
+    main_window = setup_main_window()
+    main_window.show()
+
+    sys.exit(app.exec())

+ 233 - 0
datalad_gooey/fsview_model.py

@@ -0,0 +1,233 @@
+import logging
+from pathlib import Path
+from PySide6.QtCore import (
+    QAbstractItemModel,
+    QModelIndex,
+    Qt,
+)
+from datalad_next.tree import TreeCommand
+
+
+lgr = logging.getLogger('datalad.gooey.fsview_model')
+
+
+class DataladTreeNode:
+    """Representation of a filesystem tree node
+
+    In addition to the ``path``, each instance holds a mapping
+    of node property names to values (e.g. ``type='dataset'``), and
+    a mapping of child names to ``DataladTreeNode`` instances contained
+    in this node (e.g. subdirectories of a directory).
+
+    Path and properties are given at initialization. Child nodes are
+    discovered lazily on accessing the ``children`` property.
+    """
+    def __init__(self, path, type, **props):
+        self._path = Path(path)
+        self._children = \
+            None \
+            if type in ('dataset', 'directory') \
+            and not props.get('is_broken_symlink') \
+            else False
+        self._props = props
+        self._props['type'] = type
+
+    @property
+    def path(self) -> Path:
+        return self._path
+
+    @property
+    def properties(self) -> dict:
+        return self._props
+
+    @property
+    def children(self) -> dict:
+        if self._children is False:
+            # quick answer if this node was set to never have children
+            return tuple()
+        elif self._children is None:
+            refpath = self._path.resolve() \
+                if 'symlink_target' in self._props else self._path
+            children = {
+                # keys are plain strings of the 1st-level directory/dataset
+                # content, rather than full paths, to make things leaner
+                str(Path(r['path']).relative_to(refpath)):
+                    # for now just the path (mandatory) and `type` as an
+                    # example property
+                    DataladTreeNode.from_tree_result(r)
+                # we use `tree()` and limit to immediate children of this node
+                for r in TreeCommand.__call__(
+                    # start parsing in symlink target, if there is any
+                    refpath,
+                    depth=1, include_files=True,
+                    result_renderer='disabled',
+                    return_type='generator',
+                    # permission issues may error, but we do not want to fail
+                    # TODO we would rather annotate the nodes with this info
+                    on_failure='ignore',
+                )
+                # tree would also return the root, which we are not interested
+                # in
+                if Path(r['path']) != self._path
+            }
+            self._children = children
+        return self._children
+
+    @staticmethod
+    def from_tree_result(res):
+        return DataladTreeNode(
+            res['path'],
+            type=res['type'],
+            **{
+                k: v
+                for k, v in res.items()
+                if k in ("is_broken_symlink", "symlink_target")
+            }
+        )
+
+
+class DataladTree:
+    """Tree representation of DataladTreeNode instances
+
+    A tree is initialized by providing a root ``Path``.
+
+    The primary/only purpose of this class is to implement ``Path``-based
+    child node access/traversal, triggering the lazy evaluation of
+    ``DataladTreeNode.children`` properties.
+    """
+    def __init__(self, root: Path) -> None:
+        rootp = TreeCommand.__call__(
+            root, depth=0, include_files=False,
+            result_renderer='disabled', return_type='item-or-list',
+        )
+        self._root = DataladTreeNode.from_tree_result(rootp)
+
+    @property
+    def root(self) -> DataladTreeNode:
+        return self._root
+
+    def __getitem__(self, key: Path) -> DataladTreeNode:
+        lgr.log(5, "  DataladTree.__getitem__(%r)", key)
+        # starting node
+        node = self._root
+        key = key.relative_to(self._root.path) if key is not None else None
+        if key is None or key == Path('.'):
+            # this is asking for the root
+            pass
+        else:
+            # traverse the child nodes, using each part of the
+            # path as the key within the respective parent node
+            for p in key.parts:
+                node = node.children[p]
+        return node
+
+
+class DataladTreeModel(QAbstractItemModel):
+    """Glue between DataLad and Qt's model/view architecture
+
+    The class implements the ``QAbstractItemModel`` API for connecting
+    a DataLad-driven filesystem representation (``DataladTree``) with
+    an (abstract) Qt-based visualization of this information, for example,
+    using the provided `QTreeView` -- without further, DataLad-specific,
+    customization requirements of the UI code.
+
+    The concept of this abstraction is described in
+    https://doc.qt.io/qtforpython/overviews/model-view-programming.html
+
+    The purpose of all implemented methods is best inferred from
+    https://doc.qt.io/qtforpython/PySide6/QtCore/QAbstractItemModel.html
+
+    Inline comments in the method bodies provide additional information
+    on particular choices made here.
+    """
+    def __init__(self):
+        super().__init__()
+        self._tree = None
+
+    # TODO unclear whether we anyhow benefit from a separation of this
+    # initialization step from the constructor. It is here, because it
+    # models the setup of a tutorial -- but it feels unnecessary.
+    # When the tree root path needs to be changed, such a method could
+    # help to reuse pieces of an already explored tree, but this would
+    # require further research into indices vs persistent indices, and
+    # also on informing views connected to the model about such data
+    # changes.
+    def set_tree(self, tree: DataladTree) -> None:
+        self._tree = tree
+
+    def hasChildren(self, parent: QModelIndex) -> bool:
+        # this method is implemented, because it allows connected
+        # views to inspect the model more efficiently (sparse), as
+        # if they would only have `rowCount()`
+        lgr.log(8, f"hasChildren({parent.row()} {parent.internalPointer()})")
+        res = False
+        if self._tree is not None:
+            pnode = self._tree[parent.internalPointer()]
+            # triggers parsing immediate children on the filesystem
+            res = True if pnode.children else False
+        lgr.log(8, f"hasChildren() -> {res}")
+        return res
+
+    def columnCount(self, parent: QModelIndex) -> int:
+        # Basically how many 2nd-axis nodes exist for a parent.
+        # In a tree this is always 1 (AKA only one axis: rows)
+        return 3
+
+    def rowCount(self, parent: QModelIndex) -> int:
+        lgr.log(8, f"rowCount({parent.internalPointer()})")
+        if not parent.internalPointer():
+            # no parent? this is the tree root
+            res = 1
+        else:
+            res = len(self._tree[parent.internalPointer()].children)
+        lgr.log(8, f"rowCount() -> {res}")
+        return res
+
+    def index(self, row: int, column: int, parent: QModelIndex) -> QModelIndex:
+        lgr.log(8, f"index({row}, {column}, {parent.internalPointer()})")
+        if not parent.internalPointer():
+            # no parent? this is the tree root
+            node = self._tree.root
+        else:
+            pnode = self._tree[parent.internalPointer()]
+            node = pnode.children[list(pnode.children.keys())[row]]
+        res = self.createIndex(row, column, node.path)
+        lgr.log(8, f"index() -> {node.path}")
+        return res
+
+    def parent(self, child: QModelIndex) -> QModelIndex:
+        lgr.log(8, f"parent({child.internalPointer()} {child.row()} {child.column()})")
+        try:
+            pnode = self._tree[child.internalPointer().parent]
+        except ValueError:
+            # we have no entry for this thing -> no parent
+            return QModelIndex()
+
+        # now determine the (row) index of the child within its immediate
+        # parent
+        res = self.createIndex(
+            list(pnode.children.keys()).index(child.internalPointer().name),
+            0,
+            pnode.path)
+        lgr.log(8, f"parent() -> {res}")
+        return res
+
+    def data(self, index: QModelIndex, role: Qt.ItemDataRole = Qt.DisplayRole) -> QModelIndex:
+        loglevel = 8 if role == Qt.DisplayRole else 5
+        lgr.log(loglevel, "data(%s, role=%r)", index.internalPointer(), role)
+        #If you do not have a value to return, return an invalid (default-constructed) QVariant .
+        res = None
+        if role == Qt.DisplayRole:
+            p = index.internalPointer()
+            if index.column() == 0:
+                res = p.name
+            elif index.column() == 1:
+                res = self._tree[p]._props.get('type', 'UNDEF')
+        lgr.log(loglevel, "data() -> %r", res)
+        return res
+
+    def headerData(self, section: int, orientation: Qt.Orientation,
+                   role: Qt.ItemDataRole = Qt.DisplayRole):
+        if role == Qt.DisplayRole:
+            lgr.log(8, f"headerData({section}, {orientation}, {role})")
+            return f"Section {section}"

+ 149 - 0
datalad_gooey/resources/ui/main_window.ui

@@ -0,0 +1,149 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>MainWindow</class>
+ <widget class="QMainWindow" name="MainWindow">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>800</width>
+    <height>600</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>DataLad Gooey</string>
+  </property>
+  <widget class="QWidget" name="centralwidget">
+   <property name="enabled">
+    <bool>true</bool>
+   </property>
+   <property name="sizePolicy">
+    <sizepolicy hsizetype="Maximum" vsizetype="Maximum">
+     <horstretch>1</horstretch>
+     <verstretch>1</verstretch>
+    </sizepolicy>
+   </property>
+   <layout class="QVBoxLayout" name="verticalLayout_3">
+    <item>
+     <widget class="QSplitter" name="mainVSplitter">
+      <property name="sizePolicy">
+       <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+        <horstretch>0</horstretch>
+        <verstretch>0</verstretch>
+       </sizepolicy>
+      </property>
+      <property name="orientation">
+       <enum>Qt::Vertical</enum>
+      </property>
+      <widget class="QSplitter" name="mainHSplitter">
+       <property name="orientation">
+        <enum>Qt::Horizontal</enum>
+       </property>
+       <widget class="QTreeView" name="filesystemView">
+        <property name="contextMenuPolicy">
+         <enum>Qt::ActionsContextMenu</enum>
+        </property>
+        <property name="dragEnabled">
+         <bool>false</bool>
+        </property>
+        <property name="alternatingRowColors">
+         <bool>true</bool>
+        </property>
+        <property name="selectionBehavior">
+         <enum>QAbstractItemView::SelectRows</enum>
+        </property>
+        <property name="sortingEnabled">
+         <bool>true</bool>
+        </property>
+       </widget>
+       <widget class="QTabWidget" name="contextTabs">
+        <property name="enabled">
+         <bool>true</bool>
+        </property>
+        <property name="currentIndex">
+         <number>1</number>
+        </property>
+        <widget class="QWidget" name="actionsTab">
+         <attribute name="title">
+          <string>Actions</string>
+         </attribute>
+         <layout class="QVBoxLayout" name="verticalLayout_4">
+          <item>
+           <widget class="QTextBrowser" name="textBrowser_3">
+            <property name="enabled">
+             <bool>false</bool>
+            </property>
+           </widget>
+          </item>
+         </layout>
+        </widget>
+        <widget class="QWidget" name="propertiesTab">
+         <attribute name="title">
+          <string>Properties</string>
+         </attribute>
+         <layout class="QGridLayout" name="gridLayout">
+          <item row="0" column="0">
+           <widget class="QTextBrowser" name="propertyBrowser"/>
+          </item>
+         </layout>
+        </widget>
+        <widget class="QWidget" name="historyTab">
+         <property name="enabled">
+          <bool>false</bool>
+         </property>
+         <attribute name="title">
+          <string>History</string>
+         </attribute>
+        </widget>
+        <widget class="QWidget" name="otherContextTab">
+         <property name="enabled">
+          <bool>false</bool>
+         </property>
+         <attribute name="title">
+          <string>...</string>
+         </attribute>
+        </widget>
+       </widget>
+      </widget>
+      <widget class="QTabWidget" name="consoleTabs">
+       <property name="currentIndex">
+        <number>0</number>
+       </property>
+       <widget class="QWidget" name="tab">
+        <attribute name="title">
+         <string>Log/Console</string>
+        </attribute>
+        <layout class="QVBoxLayout" name="verticalLayout_2">
+         <item>
+          <widget class="QTextBrowser" name="logBrowser"/>
+         </item>
+        </layout>
+       </widget>
+       <widget class="QWidget" name="tab_2">
+        <property name="enabled">
+         <bool>false</bool>
+        </property>
+        <attribute name="title">
+         <string>...</string>
+        </attribute>
+       </widget>
+      </widget>
+     </widget>
+    </item>
+   </layout>
+  </widget>
+  <widget class="QMenuBar" name="menubar">
+   <property name="geometry">
+    <rect>
+     <x>0</x>
+     <y>0</y>
+     <width>800</width>
+     <height>21</height>
+    </rect>
+   </property>
+  </widget>
+  <widget class="QStatusBar" name="statusbar"/>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>

+ 3 - 0
setup.cfg

@@ -35,6 +35,9 @@ datalad.extensions =
     # the entrypoint can point to any symbol of any name, as long it is
     # valid datalad interface specification (see demo in this extensions)
     gooey = datalad_gooey:command_suite
+# install the GUI starter as a direct entrypoint to avoid the datalad CLI
+# overhead
+gui_scripts = datalad-gooey = datalad_gooey.app:main
 
 [versioneer]
 # See the docstring in versioneer.py for instructions. Note that you must