///|
/// A composable TEA unit: the same five-tuple as `Program` (init, update,
/// view, subscriptions, commands) packaged as a value that can be defined,
/// tested, and mounted into a parent with `Feature::scope` independently of
/// the runtime root. `Program` stays the runtime-facing root; bridge the two
/// with `Program::from_feature`.
///
/// P13 note: the `write` half of a `Feature::scope` lens is a pure function
/// `(Child, Parent) -> Parent`. It is not an arbitrary setter — application
/// state still changes only inside `update`, and `Feature` introduces no
/// lifecycle state machine, binding, context, or mutable holder. Lens laws
/// expected of every `read`/`write` pair: `read(write(child, parent)) ==
/// child`, `write(read(parent), parent) == parent`, and a later `write`
/// supersedes an earlier one (last writer wins).
///
/// There is intentionally no `Feature::map`: a one-way message map is not
/// closed over `update`, because the input side would also need the inverse
/// function. Message lifting lives in `Feature::scope(wrap)`, where the
/// parent match arm has already unwrapped the child message — the same shape
/// as hand-written wrapper variants that lift with `Effect::map`.
///
/// Terminology: this `Feature` (core TEA composition unit) is unrelated to
/// `RendererFeature` (renderer capability tags) and to the feature-status /
/// feature-proof documentation pages.
struct Feature[Model, Msg] {
init : () -> (Model, Effect[Msg])
update : (Model, Msg) -> (Model, Effect[Msg])
view : (Model, ViewEnvironment) -> View[Msg]
subscriptions : (Model) -> Subscription[Msg]
commands : (Model) -> Array[ProgramCommand[Msg]]
view_key : ((Model, ViewEnvironment) -> DeclarationKey)?
}
///|
pub fn[Model, Msg] Feature::new(
init~ : () -> (Model, Effect[Msg]),
update~ : (Model, Msg) -> (Model, Effect[Msg]),
view~ : (Model) -> View[Msg],
subscriptions~ : (Model) -> Subscription[Msg] = _model => Subscription::none(),
) -> Feature[Model, Msg] {
{
init,
update,
view: (model, _env) => view(model),
subscriptions,
commands: _model => [],
view_key: None,
}
}
///|
pub fn[Model, Msg] Feature::new_with_environment(
init~ : () -> (Model, Effect[Msg]),
update~ : (Model, Msg) -> (Model, Effect[Msg]),
view~ : (Model, ViewEnvironment) -> View[Msg],
subscriptions~ : (Model) -> Subscription[Msg] = _model => Subscription::none(),
) -> Feature[Model, Msg] {
{ init, update, view, subscriptions, commands: _model => [], view_key: None, }
}
///|
pub fn[Model, Msg] Feature::with_commands(
self : Feature[Model, Msg],
commands~ : (Model) -> Array[ProgramCommand[Msg]],
) -> Feature[Model, Msg] {
{ ..self, commands, }
}
///|
pub fn[Model, Msg] Feature::runtime_init(
self : Feature[Model, Msg],
) -> (Model, Effect[Msg]) {
(self.init)()
}
///|
pub fn[Model, Msg] Feature::runtime_update(
self : Feature[Model, Msg],
model : Model,
message : Msg,
) -> (Model, Effect[Msg]) {
(self.update)(model, message)
}
///|
/// Declare every input `view` reads as a canonical key (RFC 0008).
///
/// This is a pure declaration: it stores one function on the feature value and
/// creates no cache. The key belongs next to the `view` body that reads the
/// inputs, so each mount reuses one reviewable dependency set instead of
/// re-deriving it at every call site.
///
/// The contract is `ViewBuilderCache`'s (ADR 0057). An omitted model field,
/// environment value, branch payload, or handler capture serves a stale view.
pub fn[Model, Msg] Feature::with_view_key(
self : Feature[Model, Msg],
view_key~ : (Model, ViewEnvironment) -> DeclarationKey,
) -> Feature[Model, Msg] {
{ ..self, view_key: Some(view_key), }
}
///|
pub fn[Model, Msg] Feature::runtime_view(
self : Feature[Model, Msg],
model : Model,
environment : ViewEnvironment,
) -> View[Msg] {
(self.view)(model, environment)
}
///|
/// Build through a caller-owned cache, skipping `view` when the declared key is
/// unchanged (RFC 0008).
///
/// `builder_key` identifies this mount within the cache and must stay stable
/// while the subtree remains mounted; it is what keeps two instances of the
/// same feature from sharing an entry. The cache cannot live on the feature
/// itself, because `Feature::scope` is called on one feature value per sibling.
///
/// A feature with no `with_view_key` declaration builds unconditionally.
/// Opting in is per feature, so forgetting costs performance, not correctness.
pub fn[Model, Msg] Feature::runtime_view_cached(
self : Feature[Model, Msg],
model : Model,
environment : ViewEnvironment,
cache~ : ViewBuilderCache[Msg],
builder_key~ : DeclarationKey,
) -> View[Msg] {
match self.view_key {
Some(key) =>
cache.view(builder_key~, content_key=key(model, environment), build=() => {
(self.view)(model, environment)
})
None => (self.view)(model, environment)
}
}
///|
pub fn[Model, Msg] Feature::runtime_subscriptions(
self : Feature[Model, Msg],
model : Model,
) -> Subscription[Msg] {
(self.subscriptions)(model)
}
///|
pub fn[Model, Msg] Feature::runtime_commands(
self : Feature[Model, Msg],
model : Model,
) -> Array[ProgramCommand[Msg]] {
(self.commands)(model)
}
///|
/// Mount `self` into a parent model through a `read`/`write` lens pair and a
/// `wrap` message constructor. The returned handle lifts child messages,
/// effects, views, subscriptions, and commands into the parent's types; the
/// parent `update` routes through one match arm per wrapper variant, exactly
/// like the hand-written delegation this replaces.
///
/// `scope_id` is a stable identity for this mount, not a display label.
/// Reusable or multi-instance features should provide a model-derived id such
/// as `"settings"` or `"settings/" + id`; it namespaces structured Effect and
/// Subscription descriptor keys so sibling scopes can reuse local keys without
/// superseding or deduplicating each other. An empty id keeps the root
/// namespace for compatibility with an unscoped single mount. The scope is
/// still pure composition: it adds no state, mount hook, or lifecycle machine.
pub fn[Child, ChildMsg, Parent, ParentMsg] Feature::scope(
self : Feature[Child, ChildMsg],
scope_id~ : String = "",
read~ : (Parent) -> Child,
write~ : (Child, Parent) -> Parent,
wrap~ : (ChildMsg) -> ParentMsg,
) -> ScopedFeature[Parent, Child, ChildMsg, ParentMsg] {
{
read,
write,
feature: self,
wrap,
scope_prefix: scope_identity_prefix(scope_id),
}
}
///|
/// A `Feature` mounted into a parent model, produced by `Feature::scope`.
/// Holds only pure functions plus a stable scope id; it carries no state and
/// can be rebuilt at any time.
pub struct ScopedFeature[Parent, Child, ChildMsg, ParentMsg] {
priv read : (Parent) -> Child
priv write : (Child, Parent) -> Parent
priv feature : Feature[Child, ChildMsg]
priv wrap : (ChildMsg) -> ParentMsg
priv scope_prefix : String
}
///|
/// Run the child feature's `init`, returning the initial child model for the
/// parent to place and the startup effect already lifted to the parent's
/// message type. Batch several scopes' startup effects with
/// `Effect::batch`.
pub fn[Parent, Child, ChildMsg, ParentMsg] ScopedFeature::init(
self : ScopedFeature[Parent, Child, ChildMsg, ParentMsg],
) -> (Child, Effect[ParentMsg]) {
let (child, effect) = (self.feature.init)()
(child, effect.map(self.wrap).prefix_scope(self.scope_prefix))
}
///|
/// Delegate one child message: read the child model through the lens, run
/// the child update, write the result back, and lift the effect to the
/// parent's message type.
pub fn[Parent, Child, ChildMsg, ParentMsg] ScopedFeature::update(
self : ScopedFeature[Parent, Child, ChildMsg, ParentMsg],
parent : Parent,
message : ChildMsg,
) -> (Parent, Effect[ParentMsg]) {
let (child, effect) = (self.feature.update)((self.read)(parent), message)
(
(self.write)(child, parent),
effect.map(self.wrap).prefix_scope(self.scope_prefix),
)
}
///|
/// Build the child view from the parent model and lift its messages to the
/// parent's message type.
pub fn[Parent, Child, ChildMsg, ParentMsg] ScopedFeature::view(
self : ScopedFeature[Parent, Child, ChildMsg, ParentMsg],
parent : Parent,
environment : ViewEnvironment,
) -> View[ParentMsg] {
(self.feature.view)((self.read)(parent), environment).map(self.wrap)
}
///|
/// Build the child view through a caller-owned cache and lift its messages to
/// the parent's message type (RFC 0008).
///
/// The cached entry is the already-mapped view. `wrap` is fixed for this
/// `ScopedFeature`'s lifetime and `builder_key` identifies the mount, so the
/// map capture ADR 0057 requires in the content key is covered by the builder
/// key rather than by the child's declared `view_key`.
pub fn[Parent, Child, ChildMsg, ParentMsg] ScopedFeature::view_cached(
self : ScopedFeature[Parent, Child, ChildMsg, ParentMsg],
parent : Parent,
environment : ViewEnvironment,
cache~ : ViewBuilderCache[ParentMsg],
builder_key~ : DeclarationKey,
) -> View[ParentMsg] {
let child = (self.read)(parent)
match self.feature.view_key {
Some(key) =>
cache.view(builder_key~, content_key=key(child, environment), build=() => {
(self.feature.view)(child, environment).map(self.wrap)
})
None => (self.feature.view)(child, environment).map(self.wrap)
}
}
///|
/// Derive the child subscriptions from the parent model and lift their
/// messages to the parent's message type.
pub fn[Parent, Child, ChildMsg, ParentMsg] ScopedFeature::subscriptions(
self : ScopedFeature[Parent, Child, ChildMsg, ParentMsg],
parent : Parent,
) -> Subscription[ParentMsg] {
(self.feature.subscriptions)((self.read)(parent))
.map(self.wrap)
.prefix_scope(self.scope_prefix)
}
///|
/// Derive the child program commands from the parent model and lift their
/// messages to the parent's message type.
pub fn[Parent, Child, ChildMsg, ParentMsg] ScopedFeature::commands(
self : ScopedFeature[Parent, Child, ChildMsg, ParentMsg],
parent : Parent,
) -> Array[ProgramCommand[ParentMsg]] {
(self.feature.commands)((self.read)(parent)).map(command => {
command.map(self.wrap)
})
}
///|
/// Bridge a `Feature` into the runtime-facing `Program` root. The two types
/// are field-isomorphic; this is a record copy, not a wrapper with extra
/// behavior. App roots stay `Program`; children stay `Feature` until they
/// are scoped.
pub fn[Model, Msg] Program::from_feature(
feature~ : Feature[Model, Msg],
) -> Program[Model, Msg] {
{
init: feature.init,
update: feature.update,
view: feature.view,
subscriptions: feature.subscriptions,
commands: feature.commands,
state_persistence: None,
}
}