Layout
The layout layer saves and restores model state by navigation path and a local layout identifier. Typical values include panel widths, expanded sections, selected views, and filter settings. It is independent of visual layout measurement: Asv.Modeling does not arrange controls or know about Avalonia panels.
A node registers the values it can restore. A manager attached to a tree root catches routed load/save events and forwards them to a store. This separates local state handling from persistence and follows the same tree, routed event, and navigation contracts as the other modeling features.
Core Types
Type | Responsibility |
|---|---|
| Exposes the node's layout controller. |
| Registers typed values and explicitly loads all registrations on that controller. |
| Loads or saves one registered value; disposal unregisters it. |
| Applies a loaded value and receives a cancellation token. |
| Requests a value through a bubble event and reports whether it was loaded. |
| Publishes a typed value through a bubble event. |
| Exposes the manager responsible for a tree's layout state. |
| Connects the tree's events to a store. |
| Stores values by |
LayoutController<TBase> requires an owner that supports routed events and INotifyPropertyChanged. The controller does not infer properties from notifications: the application still registers load callbacks and decides when to save.
Stable Storage Identity
Every value is addressed by two parts:
The sender's navigation path relative to the manager's owner, excluding the manager owner's own ID.
The
layoutIdsupplied toRegister, unique within that controller.
Both nodes can register expanded because their paths differ. A registration on the manager owner itself uses an empty relative path. A manager must be an ancestor of the sender for path calculation to succeed.
Use stable node IDs and arguments when state should survive reopening a page. Renaming a node, changing its navigation arguments, or changing a layout ID changes the storage address. Reparenting a node can change the address as well.
Registration Is Explicit
Registering a value creates a sink; it does not load the value immediately. Duplicate layout IDs on the same controller are rejected. Dispose a registration before replacing it with another registration using the same ID.
The synchronous Action<TData> callback overload is a convenience wrapper. Use the LoadLayoutCallback<TData> overload when applying a value requires asynchronous work.
LoadAllAsync loads registrations belonging to that controller, sequentially from a snapshot of its registration collection. It does not walk child nodes. Each participating child needs its own load call or root-attachment subscription.
Save Flow
The manager ignores events already marked handled. A handled save means the store accepted the value; it does not guarantee that a file was written. SaveAsync rejects null data. Errors from the callback/event/store path can propagate to the caller.
Load Flow
IsHandled and IsLoaded have different meanings. A manager can handle the request without finding saved data. Reading LayoutData before a successful load throws. If no manager handles the request, the registration also has no loaded value to apply.
Save On A Trigger
The trigger overload combines a registration with an R3 subscription. On each trigger, it obtains the current value and saves it. A null result is skipped for types that can represent null.
The trigger's payload does not become the stored value automatically: the save function supplies that value. The returned disposable owns both the registration and the trigger subscription. Registration still does not initiate loading.
Choose a save trigger that reflects a meaningful change. If applying a loaded value raises that same trigger, prevent an unwanted save feedback loop in the application. The layout helper does not suppress publication during loading, and it does not record undo changes by itself.
Load When Attached To A Root
Register values before requesting automatic root-based loading. Root tracking provides the point at which the node has a usable parent chain and can reach the manager.
LoadWhenRootAttached requests LoadAllAsync immediately if a root is already attached, and again on later root attachments. It does not create a manager or a store. When the supplied cancellation token is cancellable, the helper passes it to loading; otherwise it uses the callback token supplied by root tracking.
Dispose the returned subscription with the node. Ensure the manager exists before attachment triggers the first load. Do not assume callbacks run on a UI dispatcher: loading uses asynchronous routed events and ConfigureAwait(false), and the application must marshal UI-bound updates where needed.
Persistence Stores
NullLayoutStore.Instance ignores saves, never returns loaded data, and makes Flush and disposal no-ops. It is useful when persistence is intentionally disabled.
JsonTokenLayoutStore keeps serialized JToken snapshots in memory and writes one human-readable layout.json file under the supplied storage directory.
This example addresses the store directly. When using a manager, it computes the path relative to its own root instead.
The JSON store:
Loads existing snapshots when constructed.
Stores
Path,LayoutId, andDatafor each entry and writes them as an indented array, ordered by path and layout ID.Accepts the legacy dictionary representation when loading.
Updates the in-memory snapshot and dirty flag on
Save.Flushes automatically every five seconds by default; the constructor accepts a different interval and
TimeProvider.Flushes pending changes during
Dispose, or explicitly throughFlush.Writes through a temporary file and replaces
layout.jsonafter writing.Logs and skips invalid files or values; a failed load leaves the node's defaults available.
The JSON store uses a hash of the path and layout ID for its internal lookup key. Snapshot metadata is checked during loading. Do not depend on an internal hash as an application's navigation identifier.
Use small serializable data objects or scalar values. A changed data shape may no longer deserialize; validate restored values in the callback before applying them. Explicit Flush and disposal can propagate write errors; the timer logs flush errors.
Ownership And Integration
LayoutManager<TBase> takes ownership of its store and disposes it with the manager. Give the manager a store whose lifetime matches that ownership; do not dispose a shared store while other managers still use it.
A node owns its controller, its registration sinks, and any trigger or root-attachment subscriptions. Disposing the controller unregisters its remaining entries. Dispose trigger subscriptions too: removing an entry alone does not stop an unrelated observable from producing values.
To add layout persistence to a model tree:
Provide routed events and stable navigation IDs on the participating nodes.
Create the manager at the root with the chosen store.
Create each node's controller and register its values.
Attach nodes with correct parent references.
Load each controller explicitly, or subscribe through root tracking.
Save on application-defined changes and flush at the required persistence boundary.
Source Files And Tests
src/Asv.Modeling/Layout/Controllercontains registrations, sinks, and helper overloads.src/Asv.Modeling/Layout/Eventscontains the bubble load/save events.src/Asv.Modeling/Layout/Managercontains the tree manager and stores.src/Asv.Modeling.Test/Layout/ISupportLayoutTest.cscovers path-based restoration, missing values, JSON output, explicit and timed flushing, and loading after root attachment.