Skip to content

visualdynamics.gui.project_tree

project_tree

The project tree: the window's list of objects, and the drags, drops, copies and pastes that act on it.

Files dropped on the tree are passed to the window's own importer, the same one a drop anywhere else on the window reaches. Objects dragged within the tree move between object groups. Objects dragged out of the tree to a file manager are saved there as files, one per object (a .vdyn, or a spreadsheet for a channel table and images for photos), and dragging the project row out saves the whole project as one file.

A drag of the tree's own objects is always accepted when it is dropped, including a move the project will refuse, so that the window always answers the drop. The landing line is drawn only where the move would happen.

Classes:

Name Description
ProjectTree

The tree of the project's objects.

Functions:

Name Description
trace_drag

Write one line about a drag event to the drop log.

start_trace

Empty the drop log. The window calls this once, when it opens.

dropped_files

The local file paths a drag is carrying, or an empty list.

dragged_objects

The object names a drag of our own is carrying.

Classes

ProjectTree

ProjectTree(parent: QWidget | None = None)

Bases: QTreeWidget

The tree of the project's objects.

Linked objects are joined by a bracket painted down the left edge. link_spans is a list of (color, [items], bold, name), kept up to date by the window, and the bold flag marks the Basis group's bracket.

Methods:

Name Description
eventFilter

Keep focus entering a record grid from changing the tree's

draggable

Whether an item is one of the project's objects, which is

startDrag

Start a drag of the selected objects, or of the project row.

copy_selected

Copy (Cmd/Ctrl+C) the selection to the clipboard.

paste

Paste (Cmd/Ctrl+V) from the clipboard.

span_at

The index in link_spans of the bracket under a viewport

Source code in src/visualdynamics/gui/project_tree.py
def __init__(self, parent: QWidget | None = None) -> None:
    super().__init__(parent)
    # The mode first, then the flags, and the order is the whole
    # trick: setDragDropMode clears acceptDrops on the view and on
    # its viewport, so accepting before setting the mode accepts
    # nothing. The mode keeps the view's own item drop machinery out
    # of it; the flags are what get the events delivered here at
    # all. The viewport needs its own — that is the child actually
    # under the cursor.
    self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop)
    self.setAcceptDrops(True)
    self.viewport().setAcceptDrops(True)
    # NoDragDrop cleared this too, and starting a drag is the half of
    # it the item machinery may still do: what it must not do is
    # decide what a drop *means*.
    self.setDragEnabled(True)
    self.link_spans: list[Any] = []
    #: the window's answer to "where would this move land?", asked
    #: while the drag is still in the air: the items the dragged
    #: objects would sit above (None meaning the end), or None when
    #: the move would not happen. It decides the landing line, never
    #: whether the drop lands. Standalone, nothing is marked.
    self.landing_for: Callable[[list[str], Any], list | None] = (
        lambda names, target: None)
    self._drop_target = None
    self._drop_lines: list = []
    #: set by the window: (names or None for the whole project,
    #: folder) -> the paths written. None means dragging out of the
    #: window offers nothing, which is what a bare ProjectTree does.
    self.write_for_drag: Callable[
        [list[str] | None, str], Sequence[str]] | None = None
Methods:
eventFilter
eventFilter(watched: Any, event: Any) -> bool

Keep focus entering a record grid from changing the tree's selection.

The grid keeps the focus and the tree keeps the selection, so a click on a grid's header or margin leaves every other object's picked records as they were.

Source code in src/visualdynamics/gui/project_tree.py
def eventFilter(self, watched: Any, event: Any) -> bool:
    """Keep focus entering a record grid from changing the tree's
    selection.

    The grid keeps the focus and the tree keeps the selection, so a
    click on a grid's header or margin leaves every other object's
    picked records as they were.
    """
    # `setItemWidget` makes this view an event filter on the widget,
    # and QAbstractItemView's filter answers the widget's FocusIn by
    # moving the current index to the widget's own row: the grid's
    # holder, which is deliberately unselectable, so the move is a
    # ClearAndSelect that selects nothing and clears everything.
    # Any focus into a grid that a cell pick did not follow (a
    # click on its header, its margins, the space right of its last
    # column) silently dropped every other object's picks: a record
    # chosen in one FRF's grid vanished when a click aimed at
    # another grid's first row landed a few pixels high (Brandon,
    # 2026-08-30; reproduced with setFocus() alone).
    from .record_grid import RecordGrid

    if (event.type() == QEvent.Type.FocusIn
            and isinstance(watched, RecordGrid)):
        return False
    return super().eventFilter(watched, event)
draggable
draggable(item: QTreeWidgetItem | None) -> bool

Whether an item is one of the project's objects, which is what an object group holds. Sub-items, such as a geometry's Nodes or a record inside a data array, are parts of an object and return False.

Source code in src/visualdynamics/gui/project_tree.py
def draggable(self, item: QTreeWidgetItem | None) -> bool:
    """Whether an item is one of the project's objects, which is
    what an object group holds. Sub-items, such as a geometry's
    Nodes or a record inside a data array, are parts of an object
    and return False."""
    return item is not None and bool(item.data(0, ROLE_DRAGGABLE))
startDrag
startDrag(actions: DropAction) -> None

Start a drag of the selected objects, or of the project row.

The drag carries two things. Dropped inside the tree, it carries OBJECT_MIME and moves objects between object groups. Dropped in a file manager, it carries text/uri-list, and the objects are saved there as files, written only when the drop asks for them. The tree reads the object names first, so a drop inside the tree writes no file.

Copy and Move are both offered, with Move the default: Finder refuses a move-only drag, and Move is what a drop inside the tree means.

Source code in src/visualdynamics/gui/project_tree.py
def startDrag(self, actions: Qt.DropAction) -> None:
    """Start a drag of the selected objects, or of the project row.

    The drag carries two things. Dropped inside the tree, it carries
    `OBJECT_MIME` and moves objects between object groups. Dropped
    in a file manager, it carries `text/uri-list`, and the objects
    are saved there as files, written only when the drop asks for
    them. The tree reads the object names first, so a drop inside
    the tree writes no file.

    Copy and Move are both offered, with Move the default: Finder
    refuses a move-only drag, and Move is what a drop inside the
    tree means.
    """
    solids = self._solid_payload()
    if solids is not None:
        # solid rows only ever move among their siblings, so the drag
        # carries nothing a file manager could take
        mime = QMimeData()
        mime.setData(SOLIDS_MIME, json.dumps(solids).encode('utf-8'))
        drag = QDrag(self)
        drag.setMimeData(mime)
        drag.exec(Qt.DropAction.MoveAction, Qt.DropAction.MoveAction)
        return
    payload = self._selection_payload()
    if payload is None:
        return
    mime, _names, _whole = payload
    drag = QDrag(self)
    drag.setMimeData(mime)
    drag.exec(Qt.DropAction.CopyAction | Qt.DropAction.MoveAction,
              Qt.DropAction.MoveAction)
copy_selected
copy_selected() -> list[str]

Copy (Cmd/Ctrl+C) the selection to the clipboard.

The clipboard gets the same two things a drag carries: the object names, for a paste back into a tree, and the objects' files, for a paste into a file manager, written only when the file manager asks for them. Returns the names copied, or [PROJECT_ROW] when the project row was copied as one file of the whole project. When nothing is selected it returns an empty list and leaves the clipboard as it was.

Source code in src/visualdynamics/gui/project_tree.py
def copy_selected(self) -> list[str]:
    """Copy (Cmd/Ctrl+C) the selection to the clipboard.

    The clipboard gets the same two things a drag carries: the
    object names, for a paste back into a tree, and the objects'
    files, for a paste into a file manager, written only when the
    file manager asks for them. Returns the names copied, or
    `[PROJECT_ROW]` when the project row was copied as one file of
    the whole project. When nothing is selected it returns an empty
    list and leaves the clipboard as it was."""
    # Copying as files for a file manager: Brandon, 2026-09-03.
    payload = self._selection_payload()
    if payload is None:
        return []
    mime, names, _whole = payload
    QApplication.clipboard().setMimeData(mime)
    return names or [PROJECT_ROW]
paste
paste() -> bool

Paste (Cmd/Ctrl+V) from the clipboard.

Object names copied from a tree go to the window as objects_pasted. Files from anywhere else, such as a file manager or another window, go to the window as a drop of files does. Returns whether the clipboard held either.

Source code in src/visualdynamics/gui/project_tree.py
def paste(self) -> bool:
    """Paste (Cmd/Ctrl+V) from the clipboard.

    Object names copied from a tree go to the window as
    `objects_pasted`. Files from anywhere else, such as a file
    manager or another window, go to the window as a drop of files
    does. Returns whether the clipboard held either."""
    mime = QApplication.clipboard().mimeData()
    if mime is None:
        return False
    names = dragged_objects(mime)
    if names:
        self.objects_pasted.emit(names)
        return True
    paths = [url.toLocalFile() for url in mime.urls()
             if url.isLocalFile()] if mime.hasUrls() else []
    if paths:
        self.files_dropped.emit(paths)
        return True
    return False
span_at
span_at(position: QPoint) -> int | None

The index in link_spans of the bracket under a viewport position, or None. A right-click on a bracket uses this to act on the group rather than on the object beside it.

Source code in src/visualdynamics/gui/project_tree.py
def span_at(self, position: QPoint) -> int | None:
    """The index in `link_spans` of the bracket under a viewport
    position, or None. A right-click on a bracket uses this to act
    on the group rather than on the object beside it."""
    if position.x() > 14:
        return None
    for index, (_color, items, _bold, _name) in enumerate(self.link_spans):
        rects = [self.visualItemRect(item) for item in items
                 if item is not None]
        rects = [rect for rect in rects if rect.height() > 0]
        if len(rects) < 2:
            continue
        top = min(rect.center().y() for rect in rects)
        bottom = max(rect.center().y() for rect in rects)
        if top - 4 <= position.y() <= bottom + 4:
            return index
    return None

Functions:

trace_drag

trace_drag(who: str, what: str, mime: QMimeData | None = None, throttle: bool = False) -> None

Write one line about a drag event to the drop log.

The log is ~/Library/Logs/VisualDynamics-drops.log, emptied each time the window opens. who names the surface (the tree, the window), what the event, and mime, when given, adds how many URLs the drag carries and its first six formats. With throttle, the same event from the same surface is written at most once every half second. A write that fails is skipped.

A drop on a real window from a real file manager cannot be reproduced by the tests, and when one fails, every stage (routing, accepting, delivering, importing) looks the same from the outside. This log shows which stage the drag reached.

Source code in src/visualdynamics/gui/project_tree.py
def trace_drag(who: str, what: str, mime: QMimeData | None = None,
               throttle: bool = False) -> None:
    """Write one line about a drag event to the drop log.

    The log is `~/Library/Logs/VisualDynamics-drops.log`, emptied each
    time the window opens. `who` names the surface (the tree, the
    window), `what` the event, and `mime`, when given, adds how many
    URLs the drag carries and its first six formats. With `throttle`,
    the same event from the same surface is written at most once every
    half second. A write that fails is skipped.

    A drop on a real window from a real file manager cannot be
    reproduced by the tests, and when one fails, every stage (routing,
    accepting, delivering, importing) looks the same from the outside.
    This log shows which stage the drag reached.
    """
    try:
        if throttle:
            import time
            now = time.monotonic()
            key = f'{who}:{what}'
            if now - _LAST_TRACE.get(key, 0.0) < 0.5:
                return
            _LAST_TRACE[key] = now
        detail = ''
        if mime is not None:
            formats = ','.join(mime.formats()[:6])
            detail = (f' urls={len(mime.urls())}'
                      f' formats=[{formats}]')
        # local wall-clock time is the point: the log is read against
        # what the person saw happen
        stamp = datetime.datetime.now().strftime('%H:%M:%S.%f')[:-3]
        with open(TRACE, 'a', encoding='utf-8') as log:
            log.write(f'{stamp} {who}: {what}{detail}\n')
    except OSError:
        pass                    # a full disk must not break a drop

start_trace

start_trace() -> None

Empty the drop log. The window calls this once, when it opens.

Source code in src/visualdynamics/gui/project_tree.py
def start_trace() -> None:
    """Empty the drop log. The window calls this once, when it opens."""
    # Deliberately *not* an application-wide event filter, though one
    # would also catch a drop delivered to a widget nobody instrumented:
    # a Python filter running for every event in a process that hosts
    # QtWebEngine segfaulted inside Chromium's own machinery. The
    # per-surface lines are enough: an accepted enter and accepted moves
    # followed by no drop is a platform non-delivery, whoever it was not
    # delivered to.
    try:
        TRACE.parent.mkdir(parents=True, exist_ok=True)
        TRACE.write_text('')
    except OSError:
        pass

dropped_files

dropped_files(mime: QMimeData) -> list[str]

The local file paths a drag is carrying, or an empty list.

A drag started by the tree itself returns an empty list, even when it offers a real .vdyn file, so that dropping a project back on its own window does not import it a second time. The tree and the window both call this, so they always agree on what a drop carries.

Source code in src/visualdynamics/gui/project_tree.py
def dropped_files(mime: QMimeData) -> list[str]:
    """The local file paths a drag is carrying, or an empty list.

    A drag started by the tree itself returns an empty list, even when
    it offers a real `.vdyn` file, so that dropping a project back on
    its own window does not import it a second time. The tree and the
    window both call this, so they always agree on what a drop carries.
    """
    # The tree ignoring its own drop was not enough: an ignored drop
    # propagates to the window, which takes a file dropped anywhere on
    # it (on purpose, since a fresh window's tree is a 90 px strip and
    # missing it reads as the drop being ignored).
    if not mime.hasUrls() or mime.hasFormat(SELF_MIME):
        return []
    return [url.toLocalFile() for url in mime.urls() if url.isLocalFile()]

dragged_objects

dragged_objects(mime: QMimeData) -> list[str]

The object names a drag of our own is carrying.

Source code in src/visualdynamics/gui/project_tree.py
def dragged_objects(mime: QMimeData) -> list[str]:
    """The object names a drag of our own is carrying."""
    if not mime.hasFormat(OBJECT_MIME):
        return []
    raw = bytes(mime.data(OBJECT_MIME)).decode('utf-8')
    return [name for name in raw.split('\n') if name]