use crate::json::EventHandlerContext;
use crate::WidgetTriggerEvent;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JsonEventBinding {
Published {
name: &'static str,
has_payload: bool,
marker: JsonTriggerMarker,
},
Marker {
key: &'static str,
marker: JsonTriggerMarker,
},
}
impl JsonEventBinding {
pub fn uses_value_callback(self) -> bool {
match self {
Self::Published { marker, .. } => marker.uses_value_callback(),
Self::Marker { marker, .. } => marker.uses_value_callback(),
}
}
pub fn marker(self) -> JsonTriggerMarker {
match self {
Self::Published { marker, .. } => marker,
Self::Marker { marker, .. } => marker,
}
}
pub fn published_name(self) -> Option<&'static str> {
match self {
Self::Published { name, .. } => Some(name),
Self::Marker { key, .. } => {
let _ = key;
None
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JsonTriggerMarker {
Clicked,
DoubleClicked,
FocusGained,
FocusLost,
ValueChanged,
SelectionChanged,
Closed,
}
impl JsonTriggerMarker {
pub fn uses_value_callback(self) -> bool {
matches!(
self.trigger_kind(),
crate::platform::WidgetTriggerKind::ValueChanged
| crate::platform::WidgetTriggerKind::SelectionChanged
)
}
pub fn uses_close_callback(self) -> bool {
matches!(self, Self::Closed)
}
pub fn unroutable_reason(self) -> Option<&'static str> {
match self {
Self::DoubleClicked => Some(
"`on_double_click` has no double-click signal to connect to; a control's click \
signal does not distinguish one click from two, so binding it there would fire \
the handler on a single click",
),
Self::FocusGained | Self::FocusLost => Some(
"`on_focus` / `on_blur` have no focus signal to connect to through this route; \
the value callback fires on a value change, not on focus",
),
Self::Clicked | Self::ValueChanged | Self::SelectionChanged | Self::Closed => None,
}
}
pub fn trigger_kind(self) -> crate::platform::WidgetTriggerKind {
use crate::platform::WidgetTriggerKind;
match self {
Self::Clicked | Self::DoubleClicked => WidgetTriggerKind::Clicked,
Self::FocusGained | Self::FocusLost | Self::ValueChanged => {
WidgetTriggerKind::ValueChanged
}
Self::SelectionChanged => WidgetTriggerKind::SelectionChanged,
Self::Closed => WidgetTriggerKind::Closed,
}
}
pub fn for_published_name(name: &str, has_payload: bool) -> Self {
let lower = crate::widget::capability::normalize_key(name);
if lower.contains("doubleclick") {
return Self::DoubleClicked;
}
if lower == "closed" || lower == "close" || lower.ends_with("closed") {
return Self::Closed;
}
if lower.contains("focusgained") || lower == "focused" || lower == "focus" {
return Self::FocusGained;
}
if lower.contains("focuslost") || lower == "blurred" || lower == "blur" {
return Self::FocusLost;
}
if lower.contains("selectionchanged")
|| lower.contains("selectchanged")
|| lower.contains("selectedchanged")
{
return Self::SelectionChanged;
}
if has_payload {
Self::ValueChanged
} else {
Self::Clicked
}
}
}
pub const MARKER_KEYS: &[(&str, JsonTriggerMarker)] = &[
("on_click", JsonTriggerMarker::Clicked),
("on_change", JsonTriggerMarker::ValueChanged),
("on_close", JsonTriggerMarker::Closed),
("on_double_click", JsonTriggerMarker::DoubleClicked),
("on_focus", JsonTriggerMarker::FocusGained),
("on_blur", JsonTriggerMarker::FocusLost),
("on_selection_changed", JsonTriggerMarker::SelectionChanged),
("on_value_changed", JsonTriggerMarker::ValueChanged),
];
pub const EVENTS_KEY: &str = "events";
pub fn marker_key_names() -> impl Iterator<Item = &'static str> {
MARKER_KEYS.iter().map(|(key, _)| *key)
}
pub fn is_marker_key(key: &str) -> bool {
MARKER_KEYS.iter().any(|(candidate, _)| *candidate == key)
}
pub fn marker_for_key(key: &str) -> Option<JsonTriggerMarker> {
MARKER_KEYS.iter().find(|(candidate, _)| *candidate == key).map(|(_, marker)| *marker)
}
pub fn json_event_binding(widget_type: &str, name: &str) -> JsonEventBinding {
let factory = crate::widget::capability::WidgetFactory::new_with_defaults();
let normalized = crate::widget::capability::normalize_key(name);
let has_payload = factory
.capability(widget_type)
.and_then(|capability| {
capability
.events
.iter()
.find(|schema| crate::widget::capability::normalize_key(schema.name) == normalized)
})
.is_some_and(|schema| schema.payload.is_some());
JsonEventBinding::Published {
name: published_event_name(widget_type, name).unwrap_or("clicked"),
has_payload,
marker: JsonTriggerMarker::for_published_name(name, has_payload),
}
}
pub fn published_event_name(widget_type: &str, name: &str) -> Option<&'static str> {
let factory = crate::widget::capability::WidgetFactory::new_with_defaults();
let normalized = crate::widget::capability::normalize_key(name);
factory.capability(widget_type).and_then(|capability| {
capability
.events
.iter()
.find(|schema| crate::widget::capability::normalize_key(schema.name) == normalized)
.map(|schema| schema.name)
})
}
pub fn marker_key_for(marker: JsonTriggerMarker) -> Option<&'static str> {
MARKER_KEYS.iter().find(|(_, m)| *m == marker).map(|(key, _)| *key)
}
pub fn context_for(
widget_id: crate::core::ObjectId,
marker: JsonTriggerMarker,
) -> EventHandlerContext {
EventHandlerContext::new(WidgetTriggerEvent { widget_id, kind: marker.trigger_kind() })
}
pub fn context_for_with_payload(
widget_id: crate::core::ObjectId,
marker: JsonTriggerMarker,
payload: crate::widget::capability::CapabilityValue,
) -> EventHandlerContext {
EventHandlerContext::new(WidgetTriggerEvent { widget_id, kind: marker.trigger_kind() })
.with_payload(payload)
}
pub struct DynamicBinding {
release: Option<alloc::boxed::Box<dyn FnOnce() + Send>>,
}
impl DynamicBinding {
pub(crate) fn new(release: alloc::boxed::Box<dyn FnOnce() + Send>) -> Self {
Self { release: Some(release) }
}
pub fn release(&mut self) {
if let Some(release) = self.release.take() {
release();
}
}
pub fn is_live(&self) -> bool {
self.release.is_some()
}
}
impl Drop for DynamicBinding {
fn drop(&mut self) {
self.release();
}
}
impl core::fmt::Debug for DynamicBinding {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("DynamicBinding").field("live", &self.is_live()).finish()
}
}
std::thread_local! {
static LOADED_BINDINGS: core::cell::RefCell<
crate::compat::HashMap<crate::core::ObjectId, alloc::vec::Vec<DynamicBinding>>,
> = core::cell::RefCell::new(crate::compat::HashMap::new());
}
pub(crate) fn retain_binding(widget_id: crate::core::ObjectId, binding: DynamicBinding) {
LOADED_BINDINGS.with(|map| {
map.borrow_mut().entry(widget_id).or_default().push(binding);
});
}
pub fn release_widget_bindings(widget_id: crate::core::ObjectId) {
LOADED_BINDINGS.with(|map| {
let _ = map.borrow_mut().remove(&widget_id);
});
}
pub fn retained_binding_count(widget_id: crate::core::ObjectId) -> usize {
LOADED_BINDINGS.with(|map| map.borrow().get(&widget_id).map(Vec::len).unwrap_or(0))
}
pub fn bind_published_event(
widget_id: crate::core::ObjectId,
widget_type: &str,
name: &str,
handler: &str,
) -> bool {
if published_event_name(widget_type, name).is_none() {
log::warn!(
"[{widget_type}] `events.{name}` is not a published event; the binding is skipped, \
so the handler `{handler}` would never run"
);
return false;
}
crate::json::bind_event_binding(
widget_id,
json_event_binding(widget_type, name),
handler.to_owned(),
)
}
pub fn bind_marker_event(
widget_id: crate::core::ObjectId,
key: &str,
marker: JsonTriggerMarker,
handler: &str,
) -> bool {
let Some(canonical) = MARKER_KEYS.iter().find(|(candidate, _)| *candidate == key) else {
log::warn!(
"`{key}` is not an `on_*` event key the marker table lists; the binding for \
`{handler}` is skipped"
);
return false;
};
crate::json::bind_event_binding(
widget_id,
JsonEventBinding::Marker { key: canonical.0, marker },
handler.to_owned(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_marker_key_resolves_to_its_marker() {
for (key, marker) in MARKER_KEYS {
assert_eq!(marker_for_key(key), Some(*marker), "`{key}` must resolve to its marker");
assert!(is_marker_key(key));
}
assert_eq!(marker_for_key("on_click"), Some(JsonTriggerMarker::Clicked));
assert_eq!(marker_for_key("on_close"), Some(JsonTriggerMarker::Closed));
}
#[test]
fn a_non_event_key_is_not_a_marker_key() {
for key in ["text", "width", "on_", "clicked", "events"] {
assert!(!is_marker_key(key), "`{key}` is not an `on_*` event key");
assert_eq!(marker_for_key(key), None);
}
}
#[test]
fn the_marker_key_list_has_no_duplicates() {
let mut keys: Vec<&str> = marker_key_names().collect();
let count = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), count, "a duplicated key would be extracted twice");
}
#[test]
fn markers_report_distinct_trigger_kinds() {
assert_ne!(
JsonTriggerMarker::SelectionChanged.trigger_kind(),
JsonTriggerMarker::ValueChanged.trigger_kind()
);
}
#[test]
fn markers_that_share_a_callback_share_their_kind() {
assert_eq!(
JsonTriggerMarker::DoubleClicked.trigger_kind(),
JsonTriggerMarker::Clicked.trigger_kind()
);
assert_eq!(
JsonTriggerMarker::FocusGained.trigger_kind(),
JsonTriggerMarker::ValueChanged.trigger_kind()
);
assert_eq!(
JsonTriggerMarker::FocusLost.trigger_kind(),
JsonTriggerMarker::ValueChanged.trigger_kind()
);
}
#[test]
fn a_marker_with_no_signal_to_reach_is_refused() {
for marker in [
JsonTriggerMarker::DoubleClicked,
JsonTriggerMarker::FocusGained,
JsonTriggerMarker::FocusLost,
] {
let reason = marker.unroutable_reason();
assert!(reason.is_some(), "{marker:?} must state why it cannot be routed");
let reason = reason.expect("checked above");
assert!(
reason.contains("on_"),
"{marker:?}'s reason must name the key an author wrote: {reason}"
);
}
}
#[test]
fn the_routable_markers_stay_routable() {
assert!(JsonTriggerMarker::Clicked.unroutable_reason().is_none());
assert!(JsonTriggerMarker::ValueChanged.unroutable_reason().is_none());
assert!(JsonTriggerMarker::SelectionChanged.unroutable_reason().is_none());
assert!(
JsonTriggerMarker::Closed.unroutable_reason().is_none(),
"`closed` has a real signal (the widget's `closed`), so it must be routable"
);
}
#[test]
fn closed_uses_the_close_channel_and_not_click_or_value() {
assert!(JsonTriggerMarker::Closed.uses_close_callback());
assert!(!JsonTriggerMarker::Closed.uses_value_callback(), "a close is not a value change");
assert!(!JsonTriggerMarker::Clicked.uses_close_callback());
assert!(!JsonTriggerMarker::ValueChanged.uses_close_callback());
assert!(!JsonTriggerMarker::SelectionChanged.uses_close_callback());
}
#[test]
fn a_published_name_carries_its_own_intent() {
assert_eq!(
JsonTriggerMarker::for_published_name("clicked", false),
JsonTriggerMarker::Clicked
);
assert_eq!(
JsonTriggerMarker::for_published_name("value_changed", true),
JsonTriggerMarker::ValueChanged
);
assert_eq!(
JsonTriggerMarker::for_published_name("closed", false),
JsonTriggerMarker::Closed
);
assert_eq!(
JsonTriggerMarker::for_published_name("double_clicked", false),
JsonTriggerMarker::DoubleClicked
);
assert!(
JsonTriggerMarker::for_published_name("closed", false).uses_close_callback(),
"`closed` must route to the close channel"
);
assert!(
JsonTriggerMarker::for_published_name("closed", false).unroutable_reason().is_none(),
"`closed` has a producer now, so it must not be refused"
);
assert!(JsonTriggerMarker::for_published_name("double_clicked", false)
.unroutable_reason()
.is_some());
}
#[test]
fn the_double_click_check_precedes_the_click_check() {
assert_ne!(
JsonTriggerMarker::for_published_name("double_clicked", false),
JsonTriggerMarker::Clicked
);
}
#[test]
fn a_published_selection_event_is_a_selection_change() {
assert_eq!(
JsonTriggerMarker::for_published_name("selection_changed", false),
JsonTriggerMarker::SelectionChanged
);
assert_eq!(
JsonTriggerMarker::for_published_name("selected_changed", true),
JsonTriggerMarker::SelectionChanged
);
}
#[test]
fn every_accepted_spelling_of_one_name_resolves_to_the_same_marker() {
for spelling in [
"selection_changed",
"selection-changed",
"selection changed",
"SelectionChanged",
"SELECTION_CHANGED",
] {
assert_eq!(
JsonTriggerMarker::for_published_name(spelling, false),
JsonTriggerMarker::SelectionChanged,
"`{spelling}` must classify as a selection change"
);
}
for spelling in ["double_clicked", "double-clicked", "DoubleClicked"] {
assert_eq!(
JsonTriggerMarker::for_published_name(spelling, false),
JsonTriggerMarker::DoubleClicked,
"`{spelling}` must classify as a double click"
);
}
for spelling in ["closed", "close", "_closed", "page_closed"] {
assert_eq!(
JsonTriggerMarker::for_published_name(spelling, false),
JsonTriggerMarker::Closed,
"`{spelling}` must classify as a close"
);
}
}
}