The single owner of the split view structure: which node ids are
open, how they are arranged, which pane has the focus, and the content
revisions used to sync duplicated panes. It is a plain ChangeNotifier
— no widget dependencies, fully unit-testable.
final SplitViewController controller = SplitViewController(
maxPanes: 6, // optional hard limits
maxColumns: 3,
syncDuplicatedPaneContent: true, // enable notifyNodeChanged
);insertPane (and every drop) follows the zone semantics:
left → new FIRST column (global edge, target ignored) ── global
right → new LAST column (global edge, target ignored) ── global
top → stacked above the target, same column ── isolated
bottom → stacked below the target, same column ── isolated
center → replaces the target pane content
When the view is empty, the first buffer takes the whole area no matter the zone.
Why do left/right ignore the target? There is only ONE left and ONE right in the whole split view (see Zones): they always create the new first/last column, so a target position would be meaningless.
The same node may be open several times, but never inserted at the exact position it already occupies:
top/center: rejected when the destination slot in the column already shows the node.bottom: rejected when the slot right below the target already shows it.left: rejected when the FIRST column is a single-pane column already showing it (the leftmost position would not change).right: rejected when the LAST column is a single-pane column already showing it. A single column holds the LEFT position (buffers fill from the left), so its right side is vacant and accepts.- Lone-buffer exception: a single full-size pane accepts its own node on left/right/top — with one pane, both sides of each axis are still open positions.
Why "same position" instead of "no duplicates"? Viewing one
document in two panes is a core of the most common writing softwares workflow. The only insert
that makes no sense is the one that changes nothing — so that is the
only one rejected. canInsertAt exposes the exact same verdict the
drop shadows use, so UI and behavior can never disagree.
maxPanesgates every additive insertion (centerreplacements are free — they do not increase the count).maxColumnsgates only the operations that CREATE columns: left/right insertions andopen.isPaneLimitReached/isColumnLimitReachedare public so app UI can react (e.g. disable buttons).
movePane is net-zero aware: because it removes first and validates
after, moving a pane at the limit works, while moves that would exceed
a limit are restored atomically.
| Operation | Notes |
|---|---|
open(nodeId) |
New column at the end. Gated by both limits. |
insertPane(column, pane, zone, nodeId) |
Zone semantics above; silent no-op when invalid. |
canInsertAt(nodeId, column, pane, zone) |
The validation the shadows use. |
closePane(column, pane) / close(nodeId) / closeAll() |
Empty columns are removed; focus is reconciled. |
movePane(from…, to…, zone) → bool |
Moves the SplitPane OBJECT: weight + widget state travel with it. Atomic restore on invalid destinations. |
canMovePane(…) → bool |
Dry-run of movePane: same simulation, state always restored, no notify. Used for honest drop shadows. |
swapPanes(from…, to…) → bool |
Pure permutation — nothing opens or closes; both identities travel. The dragged pane keeps the focus. |
focusPane(column, pane) / focused |
Focus as a (column, pane) record. |
locate(nodeId) / locatePaneById(paneId) / isOpen / paneCountOf |
Lookups. Pane ids are the stable way to find a pane after structure changes. |
Why does movePane remove-then-validate? The validity of a
destination depends on the state without the moved pane (indices
shift, columns may collapse). Simulating removal, validating, and
restoring on failure keeps one single code path for movePane and
canMovePane (_tryMovePane(commit: …)) — no duplicated rule logic
that could drift apart.
No-op discipline: every operation that changes nothing emits no notification. Rebuilds are never free; the controller refuses to trigger them gratuitously.
With syncDuplicatedPaneContent: true, calling
notifyNodeChanged(nodeId) after an edit:
- Bumps the node's revision (
nodeRevisionOf). - Notifies listeners only when 2+ panes show that node — a lone pane has nobody to sync with, so typing in a unique document costs zero rebuilds.
- Every pane of that node receives the new
PaneContext.revision; compare it indidUpdateWidgetto reload external edits.
Revisions are transient sync state: they die with the last pane of
their node (closePane/closeAll clean them).
Why revisions instead of content? The library never owns content (see Architecture). It transports the signal; what "reloading" means belongs to your widgets. If you prefer a fully app-owned mechanism, see the store pattern in Recipes — both approaches are supported.