Skip to the content.

Behaviors

A Behavior is the third leg of the MVVM triangle (View / VM / Behavior). It’s a markup-attachable wiring object that lets a control host per-instance imperative setup — routed-event listeners, AllowDrop flips, named-element captures — without forcing the consumer’s bootstrap to reach into the materialized view via FindName and AddListener.

Mirrors WPF’s System.Windows.Interactivity / Microsoft.Xaml.Behaviors attached-behavior pattern: each Behavior subclass is a small class with DPs for per-instance configuration and an OnAttached(visual) hook that runs once at materialization.

Implemented in:

See also: property-system.md for the DP system behaviors register their per-instance config in, items-and-scrolling.md for the ItemsControl flow ListReorderBehavior plugs into.

0. Version scope

The Behaviors framework is staged across three labelled scopes. Each label is an explicit agreement about what’s in and what isn’t — a “Behaviors v1 only” mention in any code comment or doc resolves to the table below.

Scope Shipping? Includes
Behaviors v1 Yes Behavior abstract Model subclass; OnAttached(visual) hook; Visual.AddBehavior / Visual.Behaviors accessor; markup Behaviors { … } block lowering to AddBehavior calls in source order; DP setters run before OnAttached.
Behaviors v2 Yes (extends v1) OnDetached(visual) hook on Behavior (default no-op); Visual.AddUnloadedListener / RemoveUnloadedListener primitives; AddBehavior auto-wires OnDetached through the unloaded edge. Fires whenever the host Visual’s visualParent transitions from defined to undefined — every detach, not one-shot. Detach does NOT cascade down a subtree; only the direct detach point fires.
Behaviors v3 Planned, not built DataTrigger-driven conditional attach — behaviors attached only while a DataTrigger condition is true. Concrete shape under discussion: Triggers { DataTrigger { …, Behaviors { X } } }. Equivalent to backlog item 9.2.

Anything not in the table above is unscoped — if it comes up, we’d either fold it into one of the existing versions or open a new label with a separate agreement.

1. The Behavior base class

export abstract class Behavior extends Model
{
    public abstract OnAttached(visual: Visual): void;
}

A Behavior IS a Model — it carries DPs, supports bindings on those DPs, and participates in change notification the same way controls do. What makes it a behavior, not a control: it doesn’t render, doesn’t appear in the visual tree, and only ever lives inside a host visual’s AddBehavior slot.

The subclass implements OnAttached(visual) to wire whatever the behavior does — typically AddRoutedEventListener calls, attached property flips (AllowDrop = true), or capturing references to be re-read from later event handlers. The hook fires AFTER the behavior’s DP setters have run, so by the time the behavior reads its own configuration it’s already populated.

2. Authoring a Behavior

import { Behavior, MetaData, Model, type Visual } from '@pragmatic-tech-ai/mural/runtime';

export class TooltipBehavior extends Behavior
{
    public static readonly TextKey = Model.RegisterProperty<string>(
        TooltipBehavior, 'Text', '', MetaData.None);

    public get Text():  string  { return this.get_property_value(TooltipBehavior.TextKey); }
    public set Text(v:  string) { this.set_property_value(TooltipBehavior.TextKey, v); }

    public override OnAttached(visual: Visual): void
    {
        visual.AddRoutedEventListener('PointerEnter', () => {
            console.log('tooltip:', this.Text);
        });
    }
}

The DP shape is identical to a Control’s DPs — RegisterProperty with a default value and a MetaData flag set, plus a get/set pair. Most behavior DPs use MetaData.None because the behavior’s properties don’t directly drive measure / arrange / render of the host visual; they only control the behavior’s own logic.

MetaData.BindsTwoWayByDefault works on behavior DPs the same way it does on Visual DPs: a $path binding to that DP becomes TwoWay automatically.

3. Attaching from markup

The compiler recognises a Behaviors { … } block as a special body item inside any element. Each entry inside is a regular element form (name + attribute list) for a Behavior subclass; the lowering constructs the behavior, applies its DP setters, and emits a single AddBehavior call on the parent.

ListBox x:name="left" [ItemsSource=$LeftItems] {
    Behaviors {
        TooltipBehavior [Text="left column"]
        ListReorderBehavior [FromIndexFormat="@pragmatic-tech-ai/mural/reorder/idx"]
    }
}

Lowers to (illustrative — variable names will differ):

const _listBox = new ListBox();
_listBox.set_property_value(ItemsControl.ItemsSourceKey, DataContextBinding(_listBox, "LeftItems"));

const _tooltipBehavior = new TooltipBehavior();
_tooltipBehavior.set_property_value(TooltipBehavior.TextKey, "left column");
_listBox.AddBehavior(_tooltipBehavior);

const _listReorderBehavior = new ListReorderBehavior();
_listReorderBehavior.set_property_value(ListReorderBehavior.FromIndexFormatKey, "@pragmatic-tech-ai/mural/reorder/idx");
_listBox.AddBehavior(_listReorderBehavior);

The DP setters run before AddBehavior calls OnAttached, so the behavior’s configuration is fully populated by the time it wires anything up. Multiple behaviors attach in source order.

Composing behaviors and child elements is fine — they live in the same body:

StackPanel {
    Behaviors {
        DragSourceBehavior [DataFormat="@pragmatic-tech-ai/mural/toolbox-item"]
    }
    Border [Background=#ffffff]
    Border [Background=#f0f0f0]
}

The Borders are added to StackPanel.Children via the default slot; the behavior attaches via AddBehavior. The two paths don’t interfere.

4. Reading attached behaviors

The host visual exposes Behaviors as a read-only array:

const listBox = new ListBox();
listBox.AddBehavior(new TooltipBehavior());
for (const b of listBox.Behaviors) { /* … */ }

The slot is lazily allocated — a visual with no behaviors has Behaviors === [] (a stable shared empty array).

5. Detach lifecycle — OnDetached

Behavior exposes an OnDetached(visual) virtual that fires every time the host’s visualParent transitions from defined to undefined — i.e., every time the host is removed from its parent. The default is a no-op so behaviors that don’t need teardown can ignore it.

export class CursorListenerBehavior extends Behavior
{
    private _timer: ReturnType<typeof setInterval> | undefined;

    public override OnAttached(visual: Visual): void
    {
        this._timer = setInterval(() => { /* … */ }, 50);
        visual.AddRoutedEventListener('PointerMove', this.onMove);
    }

    public override OnDetached(visual: Visual): void
    {
        if (this._timer !== undefined) clearInterval(this._timer);
        this._timer = undefined;
        visual.RemoveRoutedEventListener('PointerMove', this.onMove);
    }

    private onMove = (_args: unknown): void => { /* … */ };
}

Semantics

Visual.AddUnloadedListener — the underlying primitive

The same listener API behaviors get implicitly is exposed publicly:

visual.AddUnloadedListener(() => console.log('detached'));
visual.RemoveUnloadedListener(listener);

Same shape as AddLoadedListener with one asymmetry: Loaded fires once per instance on the first attach to a host (matches WPF FrameworkElement.Loaded); Unloaded fires on EVERY detach edge. The asymmetry is pragmatic — Loaded’s one-shot is about “initialisation ran” semantics; Unloaded’s per-edge fires are about “teardown for THIS detach” semantics, and conflating them would mean behaviors couldn’t re-bind on re-attach.

6. ListReorderBehavior — a concrete behavior

A receiver-side drag-to-reorder helper for any ItemsControl. Marks the items control as AllowDrop=true, signals DragDropEffects.Move on a DragOver carrying the configured format key, computes the insertion index from cursor host-Y vs. container midpoints, and mutates the bound items collection on drop.

public static readonly FromIndexFormatKey = Model.RegisterProperty<string>(
    ListReorderBehavior, 'FromIndexFormat', '@pragmatic-tech-ai/mural/reorder/from-index', MetaData.None);
DP Default Meaning
FromIndexFormat '@pragmatic-tech-ai/mural/reorder/from-index' DataObject key the behavior reads to find the source row’s index.

What the behavior owns

What the behavior does NOT own

Mutable Items requirement

The behavior requires host.Items to be an ObservableCollection. A plain-array Items is read-only from the behavior’s perspective and the Drop silently no-ops. Callers who want array-backed reorder should wrap the array in an ObservableCollection. A VM-side OnReorder(from, to) callback as an alternative isn’t wired today — the contract is currently “the behavior mutates Items directly.”

Markup

ListBox x:name="leftList" [ItemsSource=$LeftItems,
                            ItemContainerStyle=@RowStyle] {
    Behaviors {
        ListReorderBehavior
    }
}

The behavior takes no required arguments — FromIndexFormat has a sensible default and only needs to be set when the consumer’s source side uses a non-default format key.

7. Patterns

8. Triggered Behavior attach (Style triggers)

A Behaviors { … } block inside a when() trigger body attaches behaviors only while the trigger is active and tears them off on deactivation. Useful for transient interactions — a Shake behavior on IsBusy, a Tooltip behavior on IsMouseOver, etc.

Style[TargetType=Button] {
    when( IsBusy ) {
        Behaviors { ShakeBehavior [Amplitude=4] }
    }
}

The compiler lowers each entry to a paired AttachBehaviorAction + DetachBehaviorAction in the trigger’s enterActions / exitActions arrays. The Attach action is factory-based — each enter invokes the factory to construct a fresh Behavior for the firing Visual. Two Visuals sharing the same Style each get their own Behavior instance (no cross-target stomping on DPs or per-instance state). Re-entry of a trigger that’s already in the attached state detaches the prior instance cleanly before installing the new one.

Behaviors { … } at Style body level (outside any when()) is rejected at compile time — attaching the same Behavior instances to every target would have them stomp on each other. Use the always-on Behaviors { … } block at the Visual level (§3 above) for behaviors that aren’t trigger-conditional.

Imperative companion

Visual.RemoveBehavior(behavior) is the imperative analogue. The trigger machinery uses it via DetachBehaviorAction, but consumers can also call it directly when a behavior needs to be torn off before the visual unloads. Calling RemoveBehavior fires OnDetached once and unsubscribes the auto-wired Unloaded listener, so a later unload edge doesn’t fire OnDetached a second time.