Skip to content

visualdynamics.gui.project_tree

project_tree

The project tree.

Dropping files on the tree works, and it takes some doing.

The tree used to import what landed on it through QAbstractItemView's own drop machinery, which decides what a drop means from the item under the cursor and the drag-drop mode — so the same file dragged onto the same tree would import on the third go. That was replaced by having the window answer drags for every part of itself at once, and the tree refuse them, on the understanding that Qt walks up from a widget that refuses to one that accepts.

It does walk up. But the tree lives in a dock widget inside a scroll area, the walk starts at whichever child is under the cursor, and whether the window is reached depends on where in that stack the drag was resolved. Dropping on the tree still missed sometimes, while the plot pane on the other side of the window never did.

So the tree accepts drags again — but it does not handle them. It takes a drag carrying files, refuses everything else outright so nothing else is swallowed, and hands the paths straight to the window's own importer. setDragDropMode(NoDragDrop) keeps the view's item machinery out of it entirely: these three overrides never call up to it. One importer, reached two ways, neither of which depends on Qt resolving a target through a dock.

Objects drag within the tree too, to move one between link groups, and that rides on the same hand-rolled handling rather than on the item machinery: a drag of our own carries a mime type of our own, and a drop is read as the object row it lands on.

Every one of our drags is accepted, including ones the project will go on to refuse, and this is the part that was wrong first. Refusing the drag reads well — the move is simply not offered — but Qt takes an ignored dragMoveEvent to mean the widget is not a drop target there, so releasing the mouse produces no drop at all: no move, no message, nothing. A geometry dragged onto a side that already had one behaved exactly like a broken feature, because from the outside there is no difference. So the drop always lands and the window always answers it, and landing_for decides only whether the landing line is drawn — the part that is genuinely about showing what applies.

Classes:

Name Description
ProjectTree

Tree of imported objects.

Functions:

Name Description
trace_drag

One line per drag event, into a small log truncated at launch.

start_trace

Truncate the log; called once, when the window comes up.

dropped_files

The local paths a drag is carrying, if it carries any.

dragged_objects

The object names a drag of our own is carrying.

Classes

ProjectTree

ProjectTree(parent: QWidget | None = None)

Bases: QTreeWidget

Tree of imported objects.

Linked objects are joined by a bracket painted down the left edge — link_spans is [(color, [items], bold)], maintained by the window; the bold flag marks the Basis group's bracket.

Methods:

Name Description
eventFilter

Focus entering a record grid must not move the tree's current.

draggable

Whether an item is one of the project's objects. Sub-items —

startDrag

One drag, two readers.

copy_selected

Copy (Cmd/Ctrl+C): the selection onto the clipboard, as the

paste

Paste (Cmd/Ctrl+V): our own object names go to the window as

span_at

Index of the link bracket under a viewport position, or

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

Focus entering a record grid must not move the tree's current.

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). The grid keeps the focus; the tree keeps the selection.

Source code in src/visualdynamics/gui/project_tree.py
def eventFilter(self, watched: Any, event: Any) -> bool:
    """Focus entering a record grid must not move the tree's current.

    `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). The grid keeps
    the focus; the tree keeps the selection.
    """
    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. Sub-items — a geometry's Nodes, a record inside a data array — are parts of an object, and an object is what a link group holds.

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. Sub-items —
    a geometry's Nodes, a record inside a data array — are parts of
    an object, and an object is what a link group holds."""
    return item is not None and bool(item.data(0, ROLE_DRAGGABLE))
startDrag
startDrag(actions: DropAction) -> None

One drag, two readers.

Inside the tree it carries OBJECT_MIME and moves objects between link groups, exactly as before. Outside — Finder, Explorer, a file manager — it carries text/uri-list and becomes a .vdyn saved wherever it lands. _offer and dropEvent both ask for the object names first, so an internal drop never sees the file half.

Copy and Move are offered, defaulting to Move: Finder refuses a move-only drag outright, 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:
    """One drag, two readers.

    Inside the tree it carries `OBJECT_MIME` and moves objects
    between link groups, exactly as before. Outside — Finder,
    Explorer, a file manager — it carries `text/uri-list` and
    becomes a `.vdyn` saved wherever it lands. `_offer` and
    `dropEvent` both ask for the object names first, so an internal
    drop never sees the file half.

    Copy *and* Move are offered, defaulting to Move: Finder refuses
    a move-only drag outright, and Move is what a drop inside the
    tree means.
    """
    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 onto the clipboard, as the same two-faced payload a drag carries — object names for a paste back into a tree, and .vdyn files for a paste into Finder or Explorer, written only when a file manager actually asks (Brandon, 2026-09-03). Returns the names copied — or [PROJECT_ROW] for the project row, which copies the whole project as one file — and an empty list when nothing was selected, in which case the clipboard is left as it was.

Source code in src/visualdynamics/gui/project_tree.py
def copy_selected(self) -> list[str]:
    """Copy (Cmd/Ctrl+C): the selection onto the clipboard, as the
    same two-faced payload a drag carries — object names for a
    paste back into a tree, and `.vdyn` files for a paste into
    Finder or Explorer, written only when a file manager actually
    asks (Brandon, 2026-09-03). Returns the names copied — or
    `[PROJECT_ROW]` for the project row, which copies the whole
    project as one file — and an empty list when nothing was
    selected, in which case the clipboard is left as it was."""
    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): our own object names go to the window as objects_pasted; files from anywhere — a file manager, another window — arrive the way a drop of files does. Returns whether the clipboard held anything we read.

Source code in src/visualdynamics/gui/project_tree.py
def paste(self) -> bool:
    """Paste (Cmd/Ctrl+V): our own object names go to the window as
    `objects_pasted`; files from anywhere — a file manager, another
    window — arrive the way a drop of files does. Returns whether
    the clipboard held anything we read."""
    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

Index of the link bracket under a viewport position, or None — how a right-click lands on a 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:
    """Index of the link bracket under a viewport position, or
    None — how a right-click lands on a group rather than on the
    object beside it."""
    if position.x() > 14:
        return None
    for index, (_color, items, _bold) 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

One line per drag event, into a small log truncated at launch.

Here because a drop that dies silently is undiagnosable from a description: the badge shows, nothing lands, and every stage of the chain — routing, accepting, delivering, importing — looks the same from the outside. A drop on a real window from a real Finder cannot be reproduced by tests, so when one misbehaves this log is the only witness. The cost is a few lines per drag, in a file that never outlives the session.

Source code in src/visualdynamics/gui/project_tree.py
def trace_drag(who: str, what: str, mime: QMimeData | None = None,
               throttle: bool = False) -> None:
    """One line per drag event, into a small log truncated at launch.

    Here because a drop that dies silently is undiagnosable from a
    description: the badge shows, nothing lands, and every stage of the
    chain — routing, accepting, delivering, importing — looks the same
    from the outside. A drop on a real window from a real Finder cannot
    be reproduced by tests, so when one misbehaves this log is the only
    witness. The cost is a few lines per drag, in a file that never
    outlives the session.
    """
    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}]')
        stamp = datetime.datetime.now(  # noqa: DTZ005 — local
            ).strftime('%H:%M:%S.%f')[:-3]  # wall-clock is the point
        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

Truncate the log; called once, when the window comes up.

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.

Source code in src/visualdynamics/gui/project_tree.py
def start_trace() -> None:
    """Truncate the log; called once, when the window comes up.

    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 paths a drag is carrying, if it carries any.

Our own drags carry none, however real the file they are offering. This is the one place that can say so: the tree ignoring the drop was not enough, because an ignored drop propagates to the window, which takes a file dropped anywhere on it — deliberately, since a fresh window's tree is a 90 px strip and missing it reads as the drop being ignored. Both ask here, so both get the same answer.

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

    **Our own drags carry none**, however real the file they are
    offering. This is the one place that can say so: the tree ignoring
    the drop was not enough, because an ignored drop propagates to the
    window, which takes a file dropped anywhere on it — deliberately,
    since a fresh window's tree is a 90 px strip and missing it reads as
    the drop being ignored. Both ask here, so both get the same answer.
    """
    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]