Current section
Files
Jump to
Current section
Files
src/bright.gleam
import gleam/bool
import gleam/dynamic.{type Dynamic}
import gleam/function
import gleam/list
import gleam/pair
import lustre/effect.{type Effect}
@external(erlang, "bright_ffi", "coerce")
@external(javascript, "./bright.ffi.mjs", "coerce")
fn coerce(a: a) -> b
/// Optimization on JS, to ensure two data sharing the referential equality
/// will shortcut the comparison. Useful when performance are a thing in client
/// browser.
@external(javascript, "./bright.ffi.mjs", "areReferentiallyEqual")
fn are_referentially_equal(a: a, b: b) -> Bool {
dynamic.from(a) == dynamic.from(b)
}
/// `Bright` holds raw data and computed data, and is used to compute caching.
/// `Bright` is instanciated using `init`, with initial data and computed data.
pub opaque type Bright(data, computed) {
Bright(
data: data,
computed: computed,
selections: List(Dynamic),
past_selections: List(Dynamic),
effects: List(Dynamic),
)
}
/// Creates the initial `Bright`. `data` & `computed` should be initialised with
/// their correct empty initial state.
pub fn init(
data data: data,
computed computed: computed,
) -> Bright(data, computed) {
Bright(data:, computed:, selections: [], past_selections: [], effects: [])
}
/// Entrypoint for the update cycle. Use it a way to trigger the start of `Bright`
/// computations, and chain them with other `bright` calls.
///
/// ```gleam
/// pub fn update(model: Bright(data, computed), msg: Msg) {
/// // Starts the update cycle, and returns #(Bright(data, computed), Effect(msg)).
/// use model <- bright.update(model, update_data(_, msg))
/// bright.return(model)
/// }
/// ```
pub fn update(
bright: Bright(data, computed),
update_: fn(data) -> #(data, Effect(msg)),
next: fn(Bright(data, computed)) -> Bright(data, computed),
) -> #(Bright(data, computed), Effect(msg)) {
let old_computations = bright.past_selections
let #(data, effs) = update_(bright.data)
let bright = Bright(..bright, data:)
let new_data = next(bright)
let all_effects = dynamic.from(new_data.effects) |> coerce |> list.reverse
panic_if_different_computations_count(old_computations, new_data.selections)
let past_selections = list.reverse(new_data.selections)
Bright(..new_data, past_selections:, selections: [], effects: [])
|> pair.new(effect.batch([effs, effect.batch(all_effects)]))
}
/// Derives data from the `data` state, and potentially the current `computed`
/// state. `compute` will run **at every render**, so be careful with computations
/// as they can block paint or actors.
///
/// ```gleam
/// pub fn update(model: Bright(data, computed), msg: Msg) {
/// use model <- bright.update(model, update_data(_, msg))
/// model
/// |> bright.compute(fn (d, c) { Computed(..c, field1: computation1(d)) })
/// |> bright.compute(fn (d, c) { Computed(..c, field2: computation2(d)) })
/// |> bright.compute(fn (d, c) { Computed(..c, field3: computation3(d)) })
/// }
/// ```
pub fn compute(
bright: Bright(data, computed),
compute_: fn(data, computed) -> computed,
) -> Bright(data, computed) {
compute_(bright.data, bright.computed)
|> fn(computed) { Bright(..bright, computed:) }
}
/// Plugs in existing `data` and `computed` state, to issue some side-effects,
/// when your application needs to run side-effects depending on the current state.
///
/// ```gleam
/// pub fn update(model: Bright(data, computed), msg: Msg) {
/// use model <- bright.update(model, update_data(_, msg))
/// use d, c <- bright.guard(model)
/// use dispatch <- effect.from
/// case d.field == 10 {
/// True -> dispatch(my_msg)
/// False -> Nil
/// }
/// }
/// ```
pub fn guard(
bright: Bright(data, computed),
guard_: fn(data, computed) -> Effect(msg),
) -> Bright(data, computed) {
guard_(bright.data, bright.computed)
|> dynamic.from
|> list.prepend(bright.effects, _)
|> fn(effects) { Bright(..bright, effects:) }
}
/// Derives data like [`compute`](#compute) lazily. `lazy_compute` accepts a
/// selector as second argument. Each time the selector returns a different data
/// than previous run, the computation will run. Otherwise, nothing happens.
///
/// ```gleam
/// pub fn update(model: Bright(data, computed), msg: Msg) {
/// use model <- bright.update(model, update_data(_, msg))
/// model
/// |> bright.lazy_compute(selector, fn (d, c) { Computed(..c, field1: computation1(d)) })
/// |> bright.lazy_compute(selector, fn (d, c) { Computed(..c, field2: computation2(d)) })
/// |> bright.lazy_compute(selector, fn (d, c) { Computed(..c, field3: computation3(d)) })
/// }
///
/// /// Use it with lazy_compute to recompute only when the field when
/// /// { old_data.field / 10 } != { data.field / 10 }
/// fn selector(d, _) {
/// d.field / 10
/// }
/// ```
pub fn lazy_compute(
bright: Bright(data, computed),
selector: fn(data) -> a,
compute_: fn(data, computed) -> computed,
) -> Bright(data, computed) {
lazy_wrap(bright, selector, compute, compute_)
}
/// Plugs in existing `data` like [`guard`](#guard) lazily. `lazy_guard` accepts
/// a selector as second argument. Each time the selector returns a different data
/// than previous run, the computation will run. Otherwise, nothing happens.
///
/// ```gleam
/// pub fn update(model: Bright(data, computed), msg: Msg) {
/// use model <- bright.update(model, update_data(_, msg))
/// use d, c <- bright.lazy_guard(model, selector)
/// use dispatch <- effect.from
/// case d.field == 10 {
/// True -> dispatch(my_msg)
/// False -> Nil
/// }
/// }
///
/// /// Use it with lazy_guard to recompute only when the field when
/// /// { old_data.field / 10 } != { data.field / 10 }
/// fn selector(d, _) {
/// d.field / 10
/// }
/// ```
pub fn lazy_guard(
bright: Bright(data, computed),
selector: fn(data) -> a,
guard_: fn(data, computed) -> Effect(msg),
) -> Bright(data, computed) {
lazy_wrap(bright, selector, guard, guard_)
}
/// Injects `Bright(data, computed)` in the `view` function, like a middleware.
/// Used to extract `data` & `computed` states from `Bright`.
///
/// ```gleam
/// pub fn view(model: Bright(data, computed)) {
/// use data, computed <- bright.view(model)
/// html.div([], [
/// // Use data or computed here.
/// ])
/// }
/// ```
pub fn view(
bright: Bright(data, computed),
viewer: fn(data, computed) -> a,
) -> a {
viewer(bright.data, bright.computed)
}
/// Allows to run multiple `update` on multiple `Bright` in the same update cycle.
/// Every call to step with compute a new `Bright`, and will let you chain the
/// steps.
///
/// ```gleam
/// pub type Model {
/// Model(
/// fst_bright: Bright(data, computed),
/// snd_bright: Bright(data, computed),
/// )
/// }
///
/// fn update(model: Model, msg: Msg) {
/// use fst_bright <- bright.step(update_fst(model.fst_bright, msg))
/// use snd_bright <- bright.step(update_snd(model.snd_bright, msg))
/// bright.return(Model(fst_bright:, snd_bright:))
/// }
/// ```
pub fn step(
bright: #(Bright(data, computed), Effect(msg)),
next: fn(Bright(data, computed)) -> #(model, Effect(msg)),
) {
let #(bright, effs) = bright
let #(model, effs_) = next(bright)
#(model, effect.batch([effs, effs_]))
}
/// Helper to write `bright` update cycle. Equivalent to `#(a, effect.none())`.
pub fn return(a) {
#(a, effect.none())
}
fn lazy_wrap(
bright: Bright(data, computed),
selector: fn(data) -> a,
setter: fn(Bright(data, computed), fn(data, computed) -> c) ->
Bright(data, computed),
compute_: fn(data, computed) -> c,
) -> Bright(data, computed) {
let selected_data = selector(bright.data)
let selections = [dynamic.from(selected_data), ..bright.selections]
let bright = Bright(..bright, selections:)
case bright.past_selections {
[] -> setter(bright, compute_)
[value, ..past_selections] -> {
Bright(..bright, past_selections:)
|> case are_referentially_equal(value, selected_data) {
True -> function.identity
False -> setter(_, compute_)
}
}
}
}
fn panic_if_different_computations_count(
old_computations: List(c),
computations: List(d),
) -> Nil {
let count = list.length(old_computations)
use <- bool.guard(when: count == 0, return: Nil)
let is_same_count = count == list.length(computations)
use <- bool.guard(when: is_same_count, return: Nil)
panic as "Memoized computed should be consistent over time, otherwise memo can not work."
}