resolve method

TreeDropTarget<TKey>? resolve({
  1. required TKey draggedKey,
  2. required TKey targetKey,
  3. required double targetPaintedY,
  4. required double targetExtent,
  5. required double pointerY,
  6. int? preferredDepth,
})

Resolves the drop target for draggedKey with the pointer at pointerY (sliver-local) over the row targetKey, whose painted geometry is targetPaintedY / targetExtent.

preferredDepth is the depth level the pointer's HORIZONTAL position indicates, unclamped, because the widget layer maps x ~/ indentWidth without knowing which levels are legal. It matters at subtree boundaries, where one visible slot has several legal depth expressions: the below zone at a right-boundary (ancestor subtrees ending at the target row) and the above zone at a LEFT-boundary (deeper subtrees closing at the previous visible row). The resolver clamps the hint to the legal chain; null keeps each zone's classic default (deepest for below, the target's own depth for above).

When every ordered candidate is rejected, one HIDDEN-INTERIOR slot is tried as a last resort: the inside of a collapsed or empty container at this boundary (the row above for above, the target itself for below). It is outside the depth clamp, so a horizontal hint can never steer into an invisible subtree; it only ever converts a dead zone into a legal slot. This is what keeps resolution invariant under collapsing: collapsing a container deprioritizes its slots, it does not delete them.

Returns null if the resolved target is invalid (cycle, no-op, or rejected by canAcceptDrop) at every legal level including that fallback, or if targetKey is not in the visible order.

Dragged-key reads (TreeController.getParent and the live index in its parent) are snapshotted once per call and reused across the whole candidate chain, which is sound because canAcceptDrop must not mutate the controller (see its doc).

Implementation

TreeDropTarget<TKey>? resolve({
  required TKey draggedKey,
  required TKey targetKey,
  required double targetPaintedY,
  required double targetExtent,
  required double pointerY,
  int? preferredDepth,
}) {
  // Every zone anchors its gap on a visible-order index derived from
  // this row or its ancestors, so a target outside that order has no
  // expressible slot, and letting the -1 propagate would invent one at
  // the top of the list.
  //
  // UNREACHABLE from the drag path, deliberately kept anyway: both of
  // `findRowAtPaintedY`'s scans iterate `controller.visibleNodes`, so
  // every key it can hand back is in the order by construction (its
  // ghost handling substitutes a painted BASE for rows that are still
  // in `visibleNodes`, it does not surface rows that are not). The
  // guard exists for direct callers, which is how this resolver is
  // unit-tested, and it is what lets [TreeDropTarget.gapVisibleIndex]
  // promise a range rather than a range-or-minus-one.
  final targetVisibleIndex = treeController.getVisibleIndex(targetKey);
  if (targetVisibleIndex < 0) {
    return null;
  }

  // Dragged-key state is snapshotted ONCE per resolve and threaded to
  // every candidate attempt: both values are invariant for the whole
  // call (resolution mutates nothing, and [canAcceptDrop] must not
  // mutate the controller). The live index is LAZY as well as
  // memoized: it is only consulted for same-parent candidates, and a
  // cross-parent resolve (the steady state of hovering between
  // containers) must not pay an O(siblings) scan of the dragged
  // node's home list for a value it never reads.
  final TKey? draggedParent = treeController.getParent(draggedKey);
  int? draggedLiveIndexMemo;
  int draggedLiveIndex() {
    return draggedLiveIndexMemo ??= treeController.getIndexInParent(
      draggedKey,
    );
  }

  final localY = (pointerY - targetPaintedY).clamp(0.0, targetExtent);
  final t = targetExtent <= 0 ? 0.0 : localY / targetExtent;

  // Rows that can't take the dragged node as a child collapse to a
  // two-zone split at the MIDPOINT. "Can't take" is structural (self or
  // descendant, which is a cycle) or policy: a [canAcceptDrop] that
  // vetoes nesting under this row would leave the `into` third
  // permanently dead, so consult it here and give flat-list-style
  // policies clean ReorderableListView-like midpoint-crossing semantics
  // instead.
  final targetAllowsChildren =
      _canTargetAcceptInto(targetKey, draggedKey) &&
      (canAcceptDrop == null ||
          canAcceptDrop!(
            movingKey: draggedKey,
            newParent: targetKey,
            // A concrete 0, deliberately, never null.
            //
            // Null would read as "can the row take children at all"
            // rather than "may the node land at index 0", but the zones
            // downstream cannot act on that distinction: `into` and the
            // below-on-expanded-parent rule both commit to
            // `rawIndex: 0` and RETURN UNCONDITIONALLY, with no
            // fallback chain. Under null, a policy vetoing only index 0
            // passes this gate, gets the three-zone split, then
            // resolves null in the middle third while both outer thirds
            // work, flapping the gap as the pointer crosses 1/3 and
            // 2/3.
            //
            // With 0 the same policy collapses the row to a clean
            // two-zone midpoint split. A row that degrades uniformly
            // beats a row with a hole in the middle of it.
            index: 0,
          ));

  TreeDropZone zone;
  if (targetAllowsChildren) {
    if (t < 1 / 3) {
      zone = TreeDropZone.above;
    } else if (t < 2 / 3) {
      zone = TreeDropZone.into;
    } else {
      zone = TreeDropZone.below;
    }
  } else if (t < 0.5) {
    zone = TreeDropZone.above;
  } else {
    zone = TreeDropZone.below;
  }

  // Translate (targetKey, zone) to (parentKey, rawIndex) and validate.
  // All sibling indices are computed in live-list space, matching the
  // reorder APIs.
  switch (zone) {
    case TreeDropZone.above:
      // LEFT-boundary chain (mirror of the below-zone right-boundary
      // chain): the slot above [targetKey] is the SAME visible slot as
      // the tail of every deeper subtree that closes at the previous
      // visible row. Example: "above a section header" is also "after
      // the previous section's last child". When the shallow candidate
      // is filtered (policy vetoing root-level drops, cycles), the
      // deeper expressions of the same slot must be tried, or crossing
      // a boundary dies in a dead band (and, under make-room, flaps
      // the gap open/closed under a stationary pointer).
      final baseDepth = treeController.getDepth(targetKey);
      final candidates = <_Candidate<TKey>>[];
      final visIndex = targetVisibleIndex;
      // EVERY above candidate opens its gap immediately before the
      // target's row. The chain walks up from the row directly above
      // the target, and that row is by construction the last visible
      // row of every subtree in the chain, so each candidate's
      // "after N" edge lands on exactly this index. Already in hand,
      // so this whole branch costs no extra lookups.
      final aboveGapIndex = visIndex;
      if (visIndex > 0) {
        final prev = treeController.visibleNodes[visIndex - 1];
        var node = prev;
        var d = treeController.getDepth(prev);
        while (d > baseDepth) {
          final idx = treeController.getIndexInParent(node);
          if (idx < 0) {
            // Pending-deletion link: its live index is meaningless, so
            // stop the chain at this level.
            break;
          }
          final parent = treeController.getParent(node);
          candidates.add((
            parentKey: parent,
            rawIndex: idx + 1,
            depth: d,
            gapIndex: aboveGapIndex,
            gapAnchor: null,
          ));
          if (parent == null) {
            break;
          }
          node = parent;
          d--;
        }
      }
      candidates.add((
        parentKey: treeController.getParent(targetKey),
        rawIndex: treeController.getIndexInParent(targetKey),
        depth: baseDepth,
        gapIndex: aboveGapIndex,
        gapAnchor: null,
      ));
      // NO shallower ancestor climb here, deliberately, even though
      // the `below` zone has one. That climb is sound because it only
      // fires while a node is its parent's LAST live child, which makes
      // "after the node" and "after its ancestor" the SAME visible
      // edge. The mirror does not hold: the parent's own header row
      // sits between "before the parent" and "before its first child",
      // so offering the shallower slot here would place the gap a row
      // away from the pointer, which is the divergence
      // [TreeDropTarget.gapVisibleIndex] exists to eliminate.
      //
      // Consequence, and it is the honest one: dragging a container
      // over another container's expanded contents resolves only at
      // that subtree's tail, because no legal slot for it exists
      // anywhere else along those rows.

      // Hidden-interior fallback: when the row above is a CONTAINER
      // whose contents are not on screen, the chain above cannot see
      // into it (its depth is not greater than the target's), so the
      // slot at the end of its child list silently ceases to exist.
      // Collapsing should deprioritize that slot, not delete it.
      //
      // Appended rather than index 0, because this is the same slot the
      // chain yields when that container IS expanded, which is what
      // makes resolution invariant under collapsing.
      _Candidate<TKey>? lastResort;
      if (visIndex > 0) {
        final prev = treeController.visibleNodes[visIndex - 1];
        // A pending-deletion container is on its way out; adopting it
        // would drop the dragged node into a vanishing subtree. Its
        // live index is meaningless too, exactly as in the chain above.
        // And a container that is already expanded WITH live children
        // was reachable through the chain, so offering it here would
        // duplicate a candidate rather than recover a lost one.
        final prevIsOpen =
            treeController.isExpanded(prev) &&
            treeController.hasLiveChildren(prev);
        if (!treeController.isPendingDeletion(prev) && !prevIsOpen) {
          lastResort = (
            parentKey: prev,
            rawIndex: treeController.liveChildCount(prev),
            depth: treeController.getDepth(prev) + 1,
            gapIndex: null,
            gapAnchor: prev,
          );
        }
      }
      // No hint defaults to the SHALLOWEST candidate, the plain
      // above-target slot; deeper levels are reached by pointer x or by
      // filter fallback.
      return _resolveCandidates(
        draggedKey: draggedKey,
        draggedParent: draggedParent,
        draggedLiveIndex: draggedLiveIndex,
        targetKey: targetKey,
        zone: zone,
        candidates: candidates,
        lastResort: lastResort,
        preferredDepth: preferredDepth,
        defaultDepth: baseDepth,
        targetPaintedY: targetPaintedY,
        targetExtent: targetExtent,
      );
    case TreeDropZone.into:
      // First-child slot: the edge directly below the target's OWN row,
      // whether or not the target is expanded.
      return _buildTarget(
        draggedKey: draggedKey,
        draggedParent: draggedParent,
        draggedLiveIndex: draggedLiveIndex,
        targetKey: targetKey,
        zone: zone,
        parentKey: targetKey,
        rawIndex: 0,
        depth: treeController.getDepth(targetKey) + 1,
        gapIndex: targetVisibleIndex + 1,
        targetPaintedY: targetPaintedY,
        targetExtent: targetExtent,
      );
    case TreeDropZone.below:
      // Below an EXPANDED target with visible children, "next sibling
      // of target" sits after the whole visible subtree, potentially
      // many rows below the edge directly under the target row (which
      // is visually the FIRST CHILD's slot). Resolve as first-child
      // (identical to `into`) so the gap and the commit agree by
      // construction, which is the conventional tree drag-and-drop
      // semantics. Such a row is never a subtree right-boundary, since
      // its subtree continues below, so the x-aware chain never
      // applies.
      if (targetAllowsChildren &&
          treeController.isExpanded(targetKey) &&
          treeController.hasLiveChildren(targetKey)) {
        return _buildTarget(
          draggedKey: draggedKey,
          draggedParent: draggedParent,
          draggedLiveIndex: draggedLiveIndex,
          targetKey: targetKey,
          zone: zone,
          parentKey: targetKey,
          rawIndex: 0,
          depth: treeController.getDepth(targetKey) + 1,
          gapIndex: targetVisibleIndex + 1,
          targetPaintedY: targetPaintedY,
          targetExtent: targetExtent,
        );
      }

      // At a subtree right-boundary the slot under the target row is
      // ambiguous: it belongs equally to every ancestor whose subtree
      // ends at this row. Build the candidate chain deepest-first;
      // depths are contiguous, each ancestor level exactly one
      // shallower.
      // Each candidate anchors its gap on the node whose sibling index
      // it incremented, resolved lazily for the winner only. The levels
      // do NOT share one anchor: the chain climbs on a LIVE-space index
      // while the visible order still contains pending-deletion rows,
      // so a target that is the last live child climbs past an exiting
      // sibling whose rows are still painted, and the ancestor's
      // visible tail includes them while the target's does not.
      // Each level's live index is computed exactly once and carried
      // through the climb, serving both that level's candidate and the
      // NEXT iteration's boundary check. The value cannot change
      // mid-walk because the walk mutates nothing, and each lookup is
      // an O(siblings) scan worth not repeating.
      final targetIdx = treeController.getIndexInParent(targetKey);
      final candidates = <_Candidate<TKey>>[
        (
          parentKey: treeController.getParent(targetKey),
          rawIndex: targetIdx + 1,
          depth: treeController.getDepth(targetKey),
          gapIndex: null,
          gapAnchor: targetKey,
        ),
      ];
      TKey node = targetKey;
      int idxOfNode = targetIdx;
      while (true) {
        final parent = treeController.getParent(node);
        final liveCount = parent == null
            ? treeController.liveRootCount
            : treeController.liveChildCount(parent);
        if (idxOfNode != liveCount - 1) {
          // node has a later live sibling: the boundary ends here.
          break;
        }
        if (parent == null) {
          // node is the last root: no shallower level exists.
          break;
        }
        final parentIdx = treeController.getIndexInParent(parent);
        candidates.add((
          parentKey: treeController.getParent(parent),
          rawIndex: parentIdx + 1,
          depth: treeController.getDepth(parent),
          gapIndex: null,
          gapAnchor: parent,
        ));
        node = parent;
        idxOfNode = parentIdx;
      }

      // Hidden-interior fallback, mirroring the above zone: reaching
      // here with `targetAllowsChildren` means the first-child rule was
      // skipped because the target is collapsed or has no live
      // children, so its interior slot is exactly what fell out of the
      // candidate space. The gate is exact rather than approximate:
      // `targetAllowsChildren` already carries the cycle check and the
      // identical `canAcceptDrop(newParent: target, index: 0)` query.
      //
      // Consequence worth knowing: whenever `into` is legal on a row,
      // that row's `below` zone can never be dead.
      final _Candidate<TKey>? lastResort = targetAllowsChildren
          ? (
              parentKey: targetKey,
              rawIndex: 0,
              // First-child edge, directly below the target's own row,
              // the same slot `into` names, and deliberately NOT the
              // anchored form that would sit past the target's whole
              // visible subtree.
              //
              // Not because that subtree is off screen: this branch
              // only establishes that no VISIBLE CHILD produced a
              // candidate, which is also true of a target expanded over
              // nothing but pending-deletion children, and those are
              // still painted. It is because index 0 is where the node
              // actually lands, and the gap has to show the slot the
              // commit will use. Anchoring past rows that are on their
              // way out would open the gap below them and then close it
              // somewhere else when the exits finish.
              gapIndex: targetVisibleIndex + 1,
              gapAnchor: null,
              depth: treeController.getDepth(targetKey) + 1,
            )
          : null;

      // No hint defaults to the DEEPEST candidate, which is also what
      // a handle-drag pointer at the row's right edge clamps to.
      return _resolveCandidates(
        draggedKey: draggedKey,
        draggedParent: draggedParent,
        draggedLiveIndex: draggedLiveIndex,
        targetKey: targetKey,
        zone: zone,
        candidates: candidates,
        lastResort: lastResort,
        preferredDepth: preferredDepth,
        defaultDepth: candidates.first.depth,
        targetPaintedY: targetPaintedY,
        targetExtent: targetExtent,
      );
  }
}