#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
use std::{collections::BTreeMap, fmt::Debug, time::Instant};
use crate::{
ActionEffect, ActionTrigger, ActionType, ElementTarget, StyleAction, Target, logic::Value,
};
use hyperchad_color::Color;
use hyperchad_transformer_models::Visibility;
pub trait StyleManager<T> {
fn add_override(&mut self, element_id: usize, trigger: StyleTrigger, value: T);
fn remove_overrides(&mut self, element_id: usize, trigger: StyleTrigger);
fn get_current_value(&self, element_id: usize) -> Option<&T>;
fn has_overrides(&self, element_id: usize) -> bool;
fn clear_element(&mut self, element_id: usize);
}
pub trait ElementFinder {
fn find_by_str_id(&self, str_id: &str) -> Option<usize>;
fn find_by_class(&self, class: &str) -> Option<usize>;
fn find_child_by_class(&self, parent_id: usize, class: &str) -> Option<usize>;
fn get_last_child(&self, parent_id: usize) -> Option<usize>;
fn get_data_attr(&self, element_id: usize, attr: &str) -> Option<String>;
fn get_str_id(&self, element_id: usize) -> Option<String>;
fn get_dimensions(&self, element_id: usize) -> Option<(f32, f32)>;
fn get_position(&self, element_id: usize) -> Option<(f32, f32)>;
}
pub trait ActionContainer {
fn find_element_by_id(&self, id: usize) -> Option<&Self>;
fn find_element_by_str_id(&self, str_id: &str) -> Option<&Self>;
fn find_element_by_class(&self, class: &str) -> Option<&Self>;
fn get_id(&self) -> usize;
fn get_str_id(&self) -> Option<&str>;
fn get_children(&self) -> &[Self]
where
Self: Sized;
fn get_data_attrs(&self) -> Option<&std::collections::BTreeMap<String, String>>;
fn get_calculated_dimensions(&self) -> Option<(f32, f32)>;
fn get_calculated_position(&self) -> Option<(f32, f32)>;
}
pub trait ActionContext {
fn request_repaint(&self);
fn get_mouse_position(&self) -> Option<(f32, f32)>;
fn get_mouse_position_relative(&self, element_id: usize) -> Option<(f32, f32)>;
fn navigate(&self, url: String) -> Result<(), Box<dyn std::error::Error + Send>>;
fn request_custom_action(
&self,
action: String,
value: Option<Value>,
) -> Result<(), Box<dyn std::error::Error + Send>>;
fn log(&self, level: LogLevel, message: &str);
}
pub trait ActionEventHandler {
fn handle_event(&self, event_name: &str, event_value: Option<&str>);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StyleTrigger {
UiEvent,
CustomEvent,
}
#[derive(Debug, Clone)]
pub struct StyleOverride<T> {
pub trigger: StyleTrigger,
pub value: T,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
Error,
Warn,
Info,
Debug,
Trace,
}
#[derive(Debug, Default)]
pub struct BTreeMapStyleManager<T> {
overrides: BTreeMap<usize, Vec<StyleOverride<T>>>,
}
impl<T> StyleManager<T> for BTreeMapStyleManager<T> {
fn add_override(&mut self, element_id: usize, trigger: StyleTrigger, value: T) {
let style_override = StyleOverride { trigger, value };
match self.overrides.entry(element_id) {
std::collections::btree_map::Entry::Occupied(mut entry) => {
entry.get_mut().push(style_override);
}
std::collections::btree_map::Entry::Vacant(entry) => {
entry.insert(vec![style_override]);
}
}
}
fn remove_overrides(&mut self, element_id: usize, trigger: StyleTrigger) {
if let Some(overrides) = self.overrides.get_mut(&element_id) {
overrides.retain(|x| x.trigger != trigger);
if overrides.is_empty() {
self.overrides.remove(&element_id);
}
}
}
fn get_current_value(&self, element_id: usize) -> Option<&T> {
self.overrides.get(&element_id)?.last().map(|x| &x.value)
}
fn has_overrides(&self, element_id: usize) -> bool {
self.overrides.contains_key(&element_id)
}
fn clear_element(&mut self, element_id: usize) {
self.overrides.remove(&element_id);
}
}
#[derive(Debug, Default)]
pub struct ActionTimingManager {
delay_off: BTreeMap<usize, (Instant, u64)>,
throttle: BTreeMap<usize, (Instant, u64)>,
}
impl ActionTimingManager {
pub fn should_throttle(&mut self, element_id: usize, throttle_ms: u64) -> bool {
if let Some((instant, throttle)) = self.throttle.get(&element_id) {
let ms = Instant::now().duration_since(*instant).as_millis();
if ms < u128::from(*throttle) {
return true;
}
}
self.throttle
.insert(element_id, (Instant::now(), throttle_ms));
false
}
pub fn start_delay_off(&mut self, element_id: usize, delay_ms: u64) {
self.delay_off
.insert(element_id, (Instant::now(), delay_ms));
}
#[must_use]
pub fn is_delay_off_expired(&self, element_id: usize) -> bool {
if let Some((instant, delay)) = self.delay_off.get(&element_id) {
let ms = Instant::now().duration_since(*instant).as_millis();
ms >= u128::from(*delay)
} else {
true
}
}
pub fn clear_throttle(&mut self, element_id: usize) {
self.throttle.remove(&element_id);
}
pub fn clear_delay_off(&mut self, element_id: usize) {
self.delay_off.remove(&element_id);
}
}
pub struct ActionHandler<F, V, B, D>
where
F: ElementFinder,
V: StyleManager<Option<Visibility>>,
B: StyleManager<Option<Color>>,
D: StyleManager<bool>,
{
finder: F,
visibility_manager: V,
background_manager: B,
display_manager: D,
timing_manager: ActionTimingManager,
}
impl<F, V, B, D> ActionHandler<F, V, B, D>
where
F: ElementFinder,
V: StyleManager<Option<Visibility>>,
B: StyleManager<Option<Color>>,
D: StyleManager<bool>,
{
pub fn new(
finder: F,
visibility_manager: V,
background_manager: B,
display_manager: D,
) -> Self {
Self {
finder,
visibility_manager,
background_manager,
display_manager,
timing_manager: ActionTimingManager::default(),
}
}
pub fn get_element_id(&self, target: &ElementTarget, self_id: usize) -> Option<usize> {
match target {
ElementTarget::StrId(str_id) => {
let Target::Literal(str_id) = str_id else {
return None;
};
self.finder.find_by_str_id(str_id)
}
ElementTarget::Class(class) => {
let Target::Literal(class) = class else {
return None;
};
self.finder.find_by_class(class)
}
ElementTarget::ChildClass(class) => {
let Target::Literal(class) = class else {
return None;
};
self.finder.find_child_by_class(self_id, class)
}
ElementTarget::Id(id) => Some(*id),
ElementTarget::SelfTarget => Some(self_id),
ElementTarget::LastChild => self.finder.get_last_child(self_id),
}
}
pub fn calc_value(
&self,
value: &Value,
self_id: usize,
context: &impl ActionContext,
event_value: Option<&str>,
) -> Option<Value> {
use crate::logic::{CalcValue, Value as LogicValue};
let calc_func = |calc_value: &CalcValue| match calc_value {
CalcValue::Visibility { target } => {
let element_id = self.get_element_id(target, self_id)?;
let visibility = self
.visibility_manager
.get_current_value(element_id)
.copied()
.flatten()
.unwrap_or_default();
Some(LogicValue::Visibility(visibility))
}
CalcValue::Display { target } => {
let element_id = self.get_element_id(target, self_id)?;
let display = self
.display_manager
.get_current_value(element_id)
.copied()
.unwrap_or_default();
Some(LogicValue::Display(display))
}
CalcValue::Id { target } => {
let element_id = self.get_element_id(target, self_id)?;
self.finder.get_str_id(element_id).map(LogicValue::String)
}
CalcValue::DataAttrValue { attr, target } => {
let element_id = self.get_element_id(target, self_id)?;
self.finder
.get_data_attr(element_id, attr)
.map(LogicValue::String)
}
CalcValue::Key { key } => Some(LogicValue::String(key.to_string())),
CalcValue::EventValue => event_value.map(ToString::to_string).map(LogicValue::String),
CalcValue::WidthPx { target } => {
let element_id = self.get_element_id(target, self_id)?;
self.finder
.get_dimensions(element_id)
.map(|(w, _)| LogicValue::Real(w))
}
CalcValue::HeightPx { target } => {
let element_id = self.get_element_id(target, self_id)?;
self.finder
.get_dimensions(element_id)
.map(|(_, h)| LogicValue::Real(h))
}
CalcValue::PositionX { target } => {
let element_id = self.get_element_id(target, self_id)?;
self.finder
.get_position(element_id)
.map(|(x, _)| LogicValue::Real(x))
}
CalcValue::PositionY { target } => {
let element_id = self.get_element_id(target, self_id)?;
self.finder
.get_position(element_id)
.map(|(_, y)| LogicValue::Real(y))
}
CalcValue::MouseX { target } => {
let pos = context.get_mouse_position()?.0;
if let Some(target) = target {
let element_id = self.get_element_id(target, self_id)?;
let element_pos = self.finder.get_position(element_id)?.0;
Some(LogicValue::Real(pos - element_pos))
} else {
Some(LogicValue::Real(pos))
}
}
CalcValue::MouseY { target } => {
let pos = context.get_mouse_position()?.1;
if let Some(target) = target {
let element_id = self.get_element_id(target, self_id)?;
let element_pos = self.finder.get_position(element_id)?.1;
Some(LogicValue::Real(pos - element_pos))
} else {
Some(LogicValue::Real(pos))
}
}
};
match value {
LogicValue::Calc(x) => calc_func(x),
LogicValue::Arithmetic(x) => x.as_f32(Some(&calc_func)).map(LogicValue::Real),
LogicValue::Real(..)
| LogicValue::Visibility(..)
| LogicValue::Display(..)
| LogicValue::String(..)
| LogicValue::Key(..)
| LogicValue::LayoutDirection(..) => Some(value.clone()),
}
}
pub fn handle_style_action(
&mut self,
action: &StyleAction,
target: &ElementTarget,
trigger: StyleTrigger,
self_id: usize,
) -> bool {
let Some(element_id) = self.get_element_id(target, self_id) else {
return false;
};
match action {
StyleAction::SetVisibility(visibility) => {
self.visibility_manager
.add_override(element_id, trigger, Some(*visibility));
true
}
StyleAction::SetDisplay(display) => {
self.display_manager
.add_override(element_id, trigger, *display);
true
}
StyleAction::SetFocus(_focus) => {
true
}
StyleAction::SetBackground(background) => {
let color = if let Some(background) = background {
match Color::try_from_hex(background) {
Ok(color) => Some(color),
Err(_) => return false,
}
} else {
None
};
self.background_manager
.add_override(element_id, trigger, color);
true
}
}
}
pub fn unhandle_style_action(
&mut self,
action: &StyleAction,
target: &ElementTarget,
trigger: StyleTrigger,
self_id: usize,
) {
let Some(element_id) = self.get_element_id(target, self_id) else {
return;
};
match action {
StyleAction::SetVisibility(..) => {
self.visibility_manager
.remove_overrides(element_id, trigger);
}
StyleAction::SetDisplay(..) => {
self.display_manager.remove_overrides(element_id, trigger);
}
StyleAction::SetFocus(..) => {
}
StyleAction::SetBackground(..) => {
self.background_manager
.remove_overrides(element_id, trigger);
}
}
}
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
pub fn handle_action(
&mut self,
action: &ActionType,
effect: Option<&ActionEffect>,
trigger: StyleTrigger,
self_id: usize,
context: &impl ActionContext,
event_value: Option<&str>,
value: Option<&Value>,
) -> bool {
if let Some(ActionEffect {
throttle: Some(throttle),
..
}) = effect
{
if self.timing_manager.should_throttle(self_id, *throttle) {
context.request_repaint();
return true;
}
}
match action {
ActionType::Style { target, action } => {
if let Some(ActionEffect {
delay_off: Some(delay),
..
}) = effect
{
self.timing_manager.start_delay_off(self_id, *delay);
}
self.handle_style_action(action, target, trigger, self_id)
}
ActionType::Navigate { url } => {
if let Err(e) = context.navigate(url.clone()) {
context.log(LogLevel::Error, &format!("Failed to navigate: {e:?}"));
false
} else {
true
}
}
ActionType::Log { message, level } => {
let log_level = match level {
crate::LogLevel::Error => LogLevel::Error,
crate::LogLevel::Warn => LogLevel::Warn,
crate::LogLevel::Info => LogLevel::Info,
crate::LogLevel::Debug => LogLevel::Debug,
crate::LogLevel::Trace => LogLevel::Trace,
};
context.log(log_level, message);
true
}
ActionType::Custom { action } => {
if let Err(e) = context.request_custom_action(action.clone(), value.cloned()) {
context.log(
LogLevel::Error,
&format!("Failed to request custom action: {e:?}"),
);
false
} else {
true
}
}
ActionType::Logic(eval) => {
let success = match &eval.condition {
crate::logic::Condition::Eq(a, b) => {
let a = self.calc_value(a, self_id, context, event_value);
let b = self.calc_value(b, self_id, context, event_value);
a == b
}
crate::logic::Condition::Bool(b) => *b,
};
let actions = if success {
&eval.actions
} else {
&eval.else_actions
};
for action in actions {
if !self.handle_action(
&action.action,
Some(action),
trigger,
self_id,
context,
event_value,
value,
) {
return false;
}
}
true
}
ActionType::Multi(actions) => {
for action in actions {
if !self.handle_action(
action,
effect,
trigger,
self_id,
context,
event_value,
value,
) {
return false;
}
}
true
}
ActionType::MultiEffect(effects) => {
for effect in effects {
if !self.handle_action(
&effect.action,
Some(effect),
trigger,
self_id,
context,
event_value,
value,
) {
return false;
}
}
true
}
ActionType::Parameterized { action, value } => {
let calculated_value = self.calc_value(value, self_id, context, event_value);
self.handle_action(
action,
effect,
trigger,
self_id,
context,
event_value,
calculated_value.as_ref(),
)
}
ActionType::Event { .. } | ActionType::NoOp | ActionType::Input(..) | ActionType::Let { .. } => {
true
}
}
}
pub fn unhandle_action(
&mut self,
action: &ActionType,
trigger: StyleTrigger,
self_id: usize,
context: &impl ActionContext,
) {
self.timing_manager.clear_throttle(self_id);
match action {
ActionType::Style { target, action } => {
if !self.timing_manager.is_delay_off_expired(self_id) {
context.request_repaint();
return;
}
self.unhandle_style_action(action, target, trigger, self_id);
}
ActionType::Multi(actions) => {
for action in actions {
self.unhandle_action(action, trigger, self_id, context);
}
}
ActionType::MultiEffect(effects) => {
for effect in effects {
self.unhandle_action(&effect.action, trigger, self_id, context);
}
}
ActionType::Parameterized { action, .. } => {
self.unhandle_action(action, trigger, self_id, context);
}
_ => {}
}
}
pub fn get_visibility_override(&self, element_id: usize) -> Option<&Option<Visibility>> {
self.visibility_manager.get_current_value(element_id)
}
pub fn get_background_override(&self, element_id: usize) -> Option<&Option<Color>> {
self.background_manager.get_current_value(element_id)
}
pub fn get_display_override(&self, element_id: usize) -> Option<&bool> {
self.display_manager.get_current_value(element_id)
}
pub fn clear_element_overrides(&mut self, element_id: usize) {
self.visibility_manager.clear_element(element_id);
self.background_manager.clear_element(element_id);
self.display_manager.clear_element(element_id);
self.timing_manager.clear_delay_off(element_id);
self.timing_manager.clear_throttle(element_id);
}
}
#[must_use]
pub fn should_trigger_action(trigger: &ActionTrigger, event_type: &str) -> bool {
match trigger {
ActionTrigger::Click => event_type == "click",
ActionTrigger::ClickOutside => event_type == "click_outside",
ActionTrigger::MouseDown => event_type == "mouse_down",
ActionTrigger::KeyDown => event_type == "key_down",
ActionTrigger::Hover => event_type == "hover",
ActionTrigger::Change => event_type == "change",
ActionTrigger::Resize => event_type == "resize",
ActionTrigger::Event(name) => event_type == name,
ActionTrigger::Immediate => event_type == "immediate",
}
}
pub mod utils {
use super::{
ActionContainer, ActionContext, ActionHandler, ActionTrigger, BTreeMap,
BTreeMapStyleManager, Color, ElementFinder, StyleManager, StyleTrigger, Visibility,
should_trigger_action,
};
pub type DefaultActionHandler<F> = ActionHandler<
F,
BTreeMapStyleManager<Option<Visibility>>,
BTreeMapStyleManager<Option<Color>>,
BTreeMapStyleManager<bool>,
>;
pub fn create_default_handler<F: ElementFinder>(finder: F) -> DefaultActionHandler<F> {
ActionHandler::new(
finder,
BTreeMapStyleManager::default(),
BTreeMapStyleManager::default(),
BTreeMapStyleManager::default(),
)
}
pub struct ContainerElementFinder<'a, C: ActionContainer> {
container: &'a C,
positions: &'a BTreeMap<usize, (f32, f32)>,
dimensions: &'a BTreeMap<usize, (f32, f32)>,
}
impl<'a, C: ActionContainer> ContainerElementFinder<'a, C> {
pub const fn new(
container: &'a C,
positions: &'a BTreeMap<usize, (f32, f32)>,
dimensions: &'a BTreeMap<usize, (f32, f32)>,
) -> Self {
Self {
container,
positions,
dimensions,
}
}
}
impl<C: ActionContainer> ElementFinder for ContainerElementFinder<'_, C> {
fn find_by_str_id(&self, str_id: &str) -> Option<usize> {
self.container
.find_element_by_str_id(str_id)
.map(ActionContainer::get_id)
}
fn find_by_class(&self, class: &str) -> Option<usize> {
self.container
.find_element_by_class(class)
.map(ActionContainer::get_id)
}
fn find_child_by_class(&self, parent_id: usize, class: &str) -> Option<usize> {
self.container
.find_element_by_id(parent_id)?
.find_element_by_class(class)
.map(ActionContainer::get_id)
}
fn get_last_child(&self, parent_id: usize) -> Option<usize> {
self.container
.find_element_by_id(parent_id)?
.get_children()
.last()
.map(ActionContainer::get_id)
}
fn get_data_attr(&self, element_id: usize, attr: &str) -> Option<String> {
self.container
.find_element_by_id(element_id)?
.get_data_attrs()?
.get(attr)
.cloned()
}
fn get_str_id(&self, element_id: usize) -> Option<String> {
self.container
.find_element_by_id(element_id)?
.get_str_id()
.map(ToString::to_string)
}
fn get_dimensions(&self, element_id: usize) -> Option<(f32, f32)> {
self.dimensions.get(&element_id).copied().or_else(|| {
self.container
.find_element_by_id(element_id)?
.get_calculated_dimensions()
})
}
fn get_position(&self, element_id: usize) -> Option<(f32, f32)> {
self.positions.get(&element_id).copied().or_else(|| {
self.container
.find_element_by_id(element_id)?
.get_calculated_position()
})
}
}
#[must_use]
pub fn matches_trigger(
trigger: &ActionTrigger,
event_type: &str,
event_name: Option<&str>,
) -> bool {
match trigger {
ActionTrigger::Event(name) => event_name.is_some_and(|e| e == name),
_ => should_trigger_action(trigger, event_type),
}
}
#[allow(clippy::too_many_arguments)]
pub fn process_element_actions<F, V, B, D, C>(
handler: &mut ActionHandler<F, V, B, D>,
actions: &[crate::Action],
element_id: usize,
event_type: &str,
event_name: Option<&str>,
event_value: Option<&str>,
context: &C,
trigger_type: StyleTrigger,
) -> bool
where
F: ElementFinder,
V: StyleManager<Option<Visibility>>,
B: StyleManager<Option<Color>>,
D: StyleManager<bool>,
C: ActionContext,
{
let mut success = true;
for action in actions {
if matches_trigger(&action.trigger, event_type, event_name)
&& !handler.handle_action(
&action.effect.action,
Some(&action.effect),
trigger_type,
element_id,
context,
event_value,
None,
)
{
success = false;
break;
}
}
success
}
}
#[cfg(all(test, feature = "logic"))]
pub mod example_integration {
use super::*;
use flume::Sender;
use std::sync::{Arc, RwLock};
pub struct ExampleActionContext {
navigation_sender: Sender<String>,
action_sender: Sender<(String, Option<Value>)>,
repaint_fn: Arc<dyn Fn() + Send + Sync>,
mouse_position: Arc<RwLock<Option<(f32, f32)>>>,
}
impl ExampleActionContext {
pub fn new(
navigation_sender: Sender<String>,
action_sender: Sender<(String, Option<Value>)>,
repaint_fn: Arc<dyn Fn() + Send + Sync>,
) -> Self {
Self {
navigation_sender,
action_sender,
repaint_fn,
mouse_position: Arc::new(RwLock::new(None)),
}
}
pub fn update_mouse_position(&self, x: f32, y: f32) {
*self.mouse_position.write().unwrap() = Some((x, y));
}
}
impl ActionContext for ExampleActionContext {
fn request_repaint(&self) {
(self.repaint_fn)();
}
fn get_mouse_position(&self) -> Option<(f32, f32)> {
*self.mouse_position.read().unwrap()
}
fn get_mouse_position_relative(&self, _element_id: usize) -> Option<(f32, f32)> {
self.get_mouse_position()
}
fn navigate(&self, url: String) -> Result<(), Box<dyn std::error::Error + Send>> {
self.navigation_sender
.send(url)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send>)
}
fn request_custom_action(
&self,
action: String,
value: Option<Value>,
) -> Result<(), Box<dyn std::error::Error + Send>> {
self.action_sender
.send((action, value))
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send>)
}
fn log(&self, level: LogLevel, message: &str) {
match level {
LogLevel::Error => log::error!("{message}"),
LogLevel::Warn => log::warn!("{message}"),
LogLevel::Info => log::info!("{message}"),
LogLevel::Debug => log::debug!("{message}"),
LogLevel::Trace => log::trace!("{message}"),
}
}
}
pub struct ExampleRenderer<C: ActionContainer> {
positions: BTreeMap<usize, (f32, f32)>,
dimensions: BTreeMap<usize, (f32, f32)>,
container: Option<C>,
}
impl<C: ActionContainer> Default for ExampleRenderer<C> {
fn default() -> Self {
Self::new()
}
}
impl<C: ActionContainer> ExampleRenderer<C> {
#[must_use]
pub const fn new() -> Self {
Self {
container: None,
positions: BTreeMap::new(),
dimensions: BTreeMap::new(),
}
}
pub fn set_container(&mut self, container: C) {
self.container = Some(container);
}
pub fn create_action_handler(
&self,
) -> Option<utils::DefaultActionHandler<utils::ContainerElementFinder<C>>> {
let container = self.container.as_ref()?;
let finder =
utils::ContainerElementFinder::new(container, &self.positions, &self.dimensions);
Some(utils::create_default_handler(finder))
}
pub fn handle_ui_event_example(&self, element_id: usize, event_type: &str) -> bool {
log::debug!("UI event: {event_type} on element {element_id}");
true
}
pub fn update_element_position(&mut self, element_id: usize, x: f32, y: f32) {
self.positions.insert(element_id, (x, y));
}
pub fn update_element_dimensions(&mut self, element_id: usize, width: f32, height: f32) {
self.dimensions.insert(element_id, (width, height));
}
pub fn clear_element(&mut self, element_id: usize) {
self.positions.remove(&element_id);
self.dimensions.remove(&element_id);
}
}
}
pub use utils::{ContainerElementFinder, DefaultActionHandler};