/// Valence — Coupled Controls
///
/// The owned form controls the DOM never had. Each control is **one signal with
/// two hands on it**: a human operates it (click/drag), and an instance reads its
/// state in the narrative and sets the *same* signal. Both the pixels and the
/// narrative render from that one signal, so a change from either side is seen by
/// both. A self-narrating, self-operating control does not exist anywhere to
/// import — this is the body the dual-render contract was always missing.
///
/// Every control returns a `Dual` — `(DomNode, () -> NarrativeNode)`: the visual
/// for a human, a callable narrative for an instance (evaluated on demand, so it
/// never goes stale). The control sets the signal and nothing else; how an
/// instance *invokes* a control from outside (the operate-channel) is a host
/// binding, deliberately not baked in here — the signal is the only channel the
/// component knows about.
///
/// Styling is via semantic classes (`valence-*`), never hard-coded colors, so a
/// consuming surface (the gesture lab, the dashboard, a uMyo dev's own app)
/// themes them. A default stylesheet ships alongside; the classes are the seam.
///
/// Extracted from the gesture-lab setup screen, where these were proven by
/// running (the first owned, coupled controls — Jun 18). Locked by use, then
/// lifted, per the framework's own law.
// Note: `div`, `text`, `DomNode`, `events`, `signal`, `Signal` are already in
// scope package-wide (the `using` declarations in patterns.mbt are package-scoped),
// so this file uses them directly without re-importing.
///|
/// A control's narrative as a node — the Dual contract. A control's value carries no
/// *intrinsic* salience: a slider is load-bearing in one room and fog in another, and
/// only the room knows which. So every control reads at a neutral mid-presence by
/// default, and a composing Surface re-weights it by context (the focus model's job —
/// the single place to change it is here). The text stays the control's own read.
fn control_node(text : String) -> NarrativeNode {
ntext(text, 0.6)
}
///|
/// Pure narrative for a segmented selection — the selected option's label, or the
/// raw value if the current value isn't in the list. Separated from the visual so
/// the oracle tests it with no DOM (building a component touches `document`; a
/// pure narrative doesn't). An instance can also call it directly.
pub fn segmented_narrative(
current : String,
options : Array[(String, String)],
) -> String {
let mut label = current
for opt in options {
if opt.0 == current {
label = opt.1
}
}
label
}
///|
/// Segmented control — pick exactly one of N options. `options` is a list of
/// `(value, label)`: `value` is what's stored in the signal, `label` is shown.
/// Clicking an option sets the signal; the selected option carries the
/// `is-selected` class. The narrative reads back the *selected option's label*
/// (falling back to the raw value if the signal holds something not in the list).
///
/// One component, every pick-one need: L/R arm (2 options), muscle face
/// (extensor/flexor/radial/ulnar), mode (stream/record/replay), theme, …
///
/// ```
/// let side = signal("ext")
/// let (vis, narrate) = segmented(side, [("ext", "Extensor"), ("flex", "Flexor")])
/// // human clicks "Flexor" → side == "flex"; narrate() == "Flexor"
/// // instance: side.set("ext") → the human's control moves to Extensor
/// ```
pub fn segmented(
sig : Signal[String],
options : Array[(String, String)],
) -> Dual {
let visual = div(class="valence-segmented") <| options.map(fn(opt) {
let value = opt.0
div(
dyn_class=fn() {
if sig.get() == value {
"valence-segmented-option is-selected"
} else {
"valence-segmented-option"
}
},
on=events().click(fn(_) { sig.set(value) }),
)
<| [text(opt.1)]
})
let narrative = fn() { control_node(segmented_narrative(sig.get(), options)) }
(visual, narrative)
}
// ── Oracle — the narrative is the test (SPEC §10) ──
///|
test "segmented narrative reads back the selected option's label" {
let opts = [("ext", "Extensor"), ("flex", "Flexor"), ("rad", "Radial")]
assert_eq(segmented_narrative("ext", opts), "Extensor")
assert_eq(segmented_narrative("flex", opts), "Flexor")
}
///|
test "segmented narrative falls back to the raw value when off-list" {
assert_eq(segmented_narrative("paused", [("on", "On"), ("off", "Off")]), "paused")
}
///|
/// Pure narrative for a toggle — the active label. `on_label`/`off_label` are the
/// words an instance reads (e.g. "recording"/"idle", "wire"/"skin"), so the state
/// reads as meaning, not a bare boolean.
pub fn toggle_narrative(on : Bool, on_label : String, off_label : String) -> String {
if on {
on_label
} else {
off_label
}
}
///|
/// Toggle / switch — one boolean, two hands. A human clicks it; an instance reads
/// the labeled state and sets the same signal. Clicking flips the signal (via
/// `peek`, so the handler doesn't subscribe). The `is-on` class carries the state
/// to the theme.
///
/// ```
/// let rec = signal(false)
/// let (vis, narrate) = toggle(rec, "recording", "idle")
/// // human clicks → rec == true; narrate() == "recording"
/// // instance: rec.set(false) → the switch slides off on the human's screen
/// ```
pub fn toggle(
sig : Signal[Bool],
on_label : String,
off_label : String,
) -> Dual {
let visual = div(
dyn_class=fn() { if sig.get() { "valence-toggle is-on" } else { "valence-toggle" } },
on=events().click(fn(_) { sig.set(!sig.peek()) }),
)
<| [div(class="valence-toggle-knob") <| []]
let narrative = fn() { control_node(toggle_narrative(sig.get(), on_label, off_label)) }
(visual, narrative)
}
///|
test "toggle narrative reads the labeled state, not a bare bool" {
assert_eq(toggle_narrative(true, "recording", "idle"), "recording")
assert_eq(toggle_narrative(false, "recording", "idle"), "idle")
}
///|
/// Pure narrative for a slider value within `[lo, hi]` — the value with its unit,
/// plus a position hint at the extremes ("near min"/"near max") so an instance
/// reads *where* it sits, not just the number.
pub fn slider_narrative(
value : Double,
lo : Double,
hi : Double,
unit : String,
) -> String {
let span = hi - lo
let frac = if span <= 0.0 { 0.0 } else { (value - lo) / span }
let hint = if frac <= 0.12 {
" (near min)"
} else if frac >= 0.88 {
" (near max)"
} else {
""
}
"\{value}\{unit}\{hint}"
}
// Tiny value-accessor FFIs — pull numbers out of DOM objects (the mizchi binding
// doesn't surface clientX or the element rect). These read a coordinate; they
// never build markup, so they're legitimate FFI, not the HTML-string disease.
// They're what lets the slider own its own drag.
extern "js" fn pe_client_x(e : @dom.PointerEvent) -> Double = "(e) => e.clientX"
extern "js" fn pe_pointer_id(e : @dom.PointerEvent) -> Int = "(e) => e.pointerId"
extern "js" fn el_rect_left(el : @dom.Element) -> Double =
"(el) => el.getBoundingClientRect().left"
extern "js" fn el_rect_width(el : @dom.Element) -> Double =
"(el) => el.getBoundingClientRect().width"
extern "js" fn el_capture_pointer(el : @dom.Element, id : Int) -> Unit =
"(el, id) => { try { el.setPointerCapture(id) } catch (_e) {} }"
///|
/// Coupled slider — a numeric value on a track. One signal, two hands, and it
/// **slides**: press or drag anywhere on the track and the value follows the
/// pointer, snapped to `step`. An instance reads the value in the narrative and
/// `set`s the same signal to move the thumb the other way — same state, both
/// directions.
///
/// The drag is owned by the component via Luna pointer events, with pointer
/// capture so a fast drag doesn't slip off the track. This works in a browser AND
/// in the WebKitGTK webview — the lab's own pointer-drag proves pointer events
/// fire there; the lab only polls because its arm SVG is JS-string-rendered, not
/// Luna. `id` is a stable element id (useful for tests / external hooks).
pub fn slider(
id : String,
label : String,
sig : Signal[Double],
lo : Double,
hi : Double,
step : Double,
unit : String,
) -> Dual {
let frac = fn() {
let f = (sig.get() - lo) / (hi - lo)
if f < 0.0 {
0.0
} else if f > 1.0 {
1.0
} else {
f
}
}
let dragging = signal(false)
// pointer position over the track → value snapped to `step`, onto the signal
fn set_from(e : @dom.PointerEvent) -> Unit {
let track = e.currentTarget()
let width = el_rect_width(track)
if width > 0.0 {
let raw = (pe_client_x(e) - el_rect_left(track)) / width
let f = if raw < 0.0 { 0.0 } else if raw > 1.0 { 1.0 } else { raw }
let value = lo + f * (hi - lo)
let steps = ((value - lo) / step + 0.5).to_int()
sig.set(lo + steps.to_double() * step)
}
}
let visual = div(class="valence-slider")
<| [
div(class="valence-slider-head")
<| [
span(class="valence-slider-label") <| [text(label)],
span(class="valence-slider-value")
<| [text_dyn(fn() { "\{sig.get()}\{unit}" })],
],
div(
class="valence-slider-track",
id=id,
on=events()
.pointerdown(fn(e) {
dragging.set(true)
el_capture_pointer(e.currentTarget(), pe_pointer_id(e))
e.preventDefault()
set_from(e)
})
.pointermove(fn(e) { if dragging.peek() { set_from(e) } })
.pointerup(fn(_e) { dragging.set(false) })
.pointercancel(fn(_e) { dragging.set(false) }),
)
<| [
div(
class="valence-slider-fill",
dyn_style=fn() { "width:\{frac() * 100.0}%" },
)
<| [],
div(
class="valence-slider-thumb",
dyn_style=fn() { "left:\{frac() * 100.0}%" },
)
<| [],
],
]
let narrative = fn() { control_node(slider_narrative(sig.get(), lo, hi, unit)) }
(visual, narrative)
}
///|
test "slider narrative carries a position hint only at the extremes" {
assert_true(slider_narrative(20.0, 20.0, 32.0, " cm").contains("near min"))
assert_true(slider_narrative(32.0, 20.0, 32.0, " cm").contains("near max"))
assert_false(slider_narrative(26.0, 20.0, 32.0, " cm").contains("near"))
assert_true(slider_narrative(26.0, 20.0, 32.0, " cm").contains("cm"))
}
///|
/// Pure narrative for a listbox — the selected option's label and the count, so
/// an instance reads what's chosen and how many it could pick.
pub fn listbox_narrative(
current : String,
options : Array[(String, String)],
) -> String {
let mut label = current
for opt in options {
if opt.0 == current {
label = opt.1
}
}
"\{label} · \{options.length()} options"
}
///|
/// Listbox / dropdown — pick one from many. One signal, two hands. A custom
/// **div-listbox** (NOT a native `