use std::{
collections::{HashMap, VecDeque},
sync::{Arc, LazyLock, RwLock},
};
use crate::layout::EguiCalc;
use async_trait::async_trait;
use eframe::egui::{self, Color32, Response, Ui, Widget};
use flume::{Receiver, Sender};
use hyperchad_actions::handler::{
ActionContext, ActionHandler, BTreeMapStyleManager, ElementFinder, LogLevel as ActionLogLevel,
StyleTrigger,
};
use hyperchad_actions::{ActionTrigger, logic::Value};
use hyperchad_renderer::canvas::CanvasUpdate;
use hyperchad_router::{ClientInfo, Router};
use hyperchad_transformer::{
Container, Element, Input,
models::{TextOverflow, Visibility},
};
pub use eframe;
pub use hyperchad_renderer::*;
pub enum RenderView {
View(Container),
}
#[derive(Debug)]
enum AppEvent {
LoadImage {
source: String,
},
}
#[derive(Clone)]
enum AppImage {
Loading,
Bytes(Arc<[u8]>),
}
#[derive(Clone)]
pub struct EguiRenderer<C: EguiCalc + Clone + Send + Sync> {
width: Option<f32>,
height: Option<f32>,
x: Option<i32>,
y: Option<i32>,
app: EguiApp<C>,
receiver: Receiver<String>,
}
impl<C: EguiCalc + Clone + Send + Sync + 'static> EguiRenderer<C> {
#[must_use]
pub fn new(
_router: Router,
request_action: Sender<(String, Option<Value>)>,
on_resize: Sender<(f32, f32)>,
_client_info: Arc<ClientInfo>,
calculator: C,
) -> Self {
let (tx, rx) = flume::unbounded();
Self {
width: None,
height: None,
x: None,
y: None,
app: EguiApp::new(tx, request_action, on_resize, calculator),
receiver: rx,
}
}
pub async fn wait_for_navigation(&self) -> Option<String> {
self.receiver.recv_async().await.ok()
}
}
pub struct EguiRenderRunner<C: EguiCalc + Clone + Send + Sync> {
width: f32,
height: f32,
x: Option<i32>,
y: Option<i32>,
app: EguiApp<C>,
}
impl<C: EguiCalc + Clone + Send + Sync + 'static> hyperchad_renderer::RenderRunner
for EguiRenderRunner<C>
{
fn run(&mut self) -> Result<(), Box<dyn std::error::Error + Send>> {
let mut viewport =
egui::ViewportBuilder::default().with_inner_size([self.width, self.height]);
#[allow(clippy::cast_precision_loss)]
if let (Some(x), Some(y)) = (self.x, self.y) {
viewport = viewport.with_position((x as f32, y as f32));
}
#[cfg(feature = "wgpu")]
let renderer = eframe::Renderer::Wgpu;
#[cfg(not(feature = "wgpu"))]
let renderer = eframe::Renderer::Glow;
let options = eframe::NativeOptions {
viewport,
centered: true,
renderer,
..Default::default()
};
log::debug!("EguiRenderer: starting");
if let Err(e) = eframe::run_native(
self.app.title.as_deref().unwrap_or("MoosicBox"),
options,
Box::new(|cc| {
let _ = cc.egui_ctx.run(egui::RawInput::default(), |_| {});
egui_extras::install_image_loaders(&cc.egui_ctx);
*self.app.ctx.write().unwrap() = Some(cc.egui_ctx.clone());
let mut calculator = self.app.calculator.write().unwrap();
*calculator = calculator.clone().with_context(cc.egui_ctx.clone());
drop(calculator);
log::debug!("EguiRenderer: initialized");
Ok(Box::new(self.app.clone()))
}),
) {
log::error!("EguiRenderer: eframe error: {e:?}");
}
log::debug!("EguiRenderer: finished");
Ok(())
}
}
impl<C: EguiCalc + Clone + Send + Sync + 'static> hyperchad_renderer::ToRenderRunner
for EguiRenderer<C>
{
fn to_runner(
self,
_handle: hyperchad_renderer::Handle,
) -> Result<Box<dyn hyperchad_renderer::RenderRunner>, Box<dyn std::error::Error + Send>> {
Ok(Box::new(EguiRenderRunner {
width: self.width.unwrap(),
height: self.height.unwrap(),
x: self.x,
y: self.y,
app: self.app,
}))
}
}
#[derive(Clone)]
struct EguiApp<C: EguiCalc + Clone + Send + Sync> {
ctx: Arc<RwLock<Option<egui::Context>>>,
calculator: Arc<RwLock<C>>,
container: Arc<RwLock<Option<Container>>>,
width: Arc<RwLock<Option<f32>>>,
height: Arc<RwLock<Option<f32>>>,
render_queue: Arc<RwLock<Option<VecDeque<RenderView>>>>,
title: Option<String>,
description: Option<String>,
background: Option<Color32>,
sender: Sender<String>,
request_action: Sender<(String, Option<Value>)>,
on_resize: Sender<(f32, f32)>,
event: Sender<AppEvent>,
event_receiver: flume::Receiver<AppEvent>,
checkboxes: Arc<RwLock<HashMap<egui::Id, bool>>>,
text_inputs: Arc<RwLock<HashMap<egui::Id, String>>>,
images: Arc<RwLock<HashMap<String, AppImage>>>,
}
impl<C: EguiCalc + Clone + Send + Sync + 'static> EguiApp<C> {
fn new(
sender: Sender<String>,
request_action: Sender<(String, Option<Value>)>,
on_resize: Sender<(f32, f32)>,
calculator: C,
) -> Self {
let (event_tx, event_rx) = flume::unbounded();
Self {
ctx: Arc::new(RwLock::new(None)),
calculator: Arc::new(RwLock::new(calculator)),
container: Arc::new(RwLock::new(None)),
width: Arc::new(RwLock::new(None)),
height: Arc::new(RwLock::new(None)),
render_queue: Arc::new(RwLock::new(Some(VecDeque::new()))),
title: None,
description: None,
background: None,
sender,
request_action,
on_resize,
event: event_tx,
event_receiver: event_rx,
checkboxes: Arc::new(RwLock::new(HashMap::new())),
text_inputs: Arc::new(RwLock::new(HashMap::new())),
images: Arc::new(RwLock::new(HashMap::new())),
}
}
fn check_frame_resize(&self, ctx: &egui::Context) -> bool {
let (width, height) = ctx.input(move |i| {
let content_rect = i.content_rect();
(content_rect.width(), content_rect.height())
});
let current_width = *self.width.read().unwrap();
let current_height = *self.height.read().unwrap();
if current_width.is_none_or(|x| (x - width).abs() >= 0.01)
|| current_height.is_none_or(|x| (x - height).abs() >= 0.01)
{
self.update_frame_size(width, height);
if let Err(e) = self.on_resize.send((width, height)) {
log::error!("Failed to send resize event: {e:?}");
}
true
} else {
false
}
}
fn update_frame_size(&self, width: f32, height: f32) {
log::debug!("Frame size changed to: {width}x{height}");
if let Some(container) = self.container.write().unwrap().as_mut() {
container.calculated_width = Some(width);
container.calculated_height = Some(height);
self.calculator.read().unwrap().calc(container);
}
*self.width.write().unwrap() = Some(width);
*self.height.write().unwrap() = Some(height);
}
fn render_container(&self, ui: &mut Ui, container: &Container) -> Option<Response> {
if container.is_hidden() || container.visibility == Some(Visibility::Hidden) {
return None;
}
if let Some(font_size) = container.calculated_font_size {
Self::set_font_size(font_size, ui.ctx());
}
if let Some(opacity) = container.calculated_opacity {
ui.set_opacity(opacity);
}
match &container.element {
Element::Raw { value } | Element::Text { value } => {
let font_size = container.calculated_font_size.unwrap_or(14.0);
let mut label = egui::Label::new(egui::RichText::new(value).size(font_size));
if matches!(container.text_overflow, Some(TextOverflow::Ellipsis)) {
label = label.truncate();
}
return Some(label.ui(ui));
}
Element::Input { input, .. } => {
return self.render_input(ui, input, container);
}
Element::Button { .. } => {
if let Some(text) = Self::get_container_text(container) {
let font_size = container.calculated_font_size.unwrap_or(14.0);
return Some(ui.button(egui::RichText::new(text).size(font_size)));
}
}
Element::Anchor { href, .. } => {
if let Some(text) = Self::get_container_text(container) {
let font_size = container.calculated_font_size.unwrap_or(14.0);
let mut label = egui::Label::new(egui::RichText::new(text).size(font_size))
.sense(egui::Sense::click());
if matches!(container.text_overflow, Some(TextOverflow::Ellipsis)) {
label = label.truncate();
}
let response = label.ui(ui);
if response.clicked()
&& let Some(href) = href
{
let _ = self.sender.send(href.clone());
}
return Some(response);
}
}
Element::Image {
source: Some(source),
..
} => {
let mut images = self.images.write().unwrap();
return Some(Self::render_image(
&mut images,
ui,
source,
container,
&self.event,
));
}
_ => {}
}
self.render_container_with_children(ui, container)
}
fn render_container_with_children(
&self,
ui: &mut Ui,
container: &Container,
) -> Option<Response> {
if container.children.is_empty() {
return None;
}
let mut frame = egui::Frame::new();
if let Some(background) = container.background {
frame = frame.fill(background.into());
}
#[allow(clippy::cast_possible_truncation)]
let padding = egui::Margin {
left: container.calculated_padding_left.unwrap_or(0.0) as i8,
right: container.calculated_padding_right.unwrap_or(0.0) as i8,
top: container.calculated_padding_top.unwrap_or(0.0) as i8,
bottom: container.calculated_padding_bottom.unwrap_or(0.0) as i8,
};
frame = frame.inner_margin(padding);
if let Some(radius) = container.calculated_border_top_left_radius {
frame = frame.corner_radius(radius);
}
let response = frame.show(ui, |ui| {
if let Some(width) = container.calculated_width {
ui.set_width(width);
}
if let Some(height) = container.calculated_height {
ui.set_height(height);
}
match container.direction {
hyperchad_transformer::models::LayoutDirection::Row => {
ui.horizontal(|ui| {
for child in &container.children {
self.render_container(ui, child);
}
});
}
hyperchad_transformer::models::LayoutDirection::Column => {
ui.vertical(|ui| {
for child in &container.children {
self.render_container(ui, child);
}
});
}
}
});
self.handle_actions(ui, container, &response.response);
Some(response.response)
}
#[allow(clippy::significant_drop_tightening)]
fn render_input(&self, ui: &mut Ui, input: &Input, container: &Container) -> Option<Response> {
let id = ui.next_auto_id();
match input {
Input::Text { value, .. } => {
let mut text_inputs = self.text_inputs.write().unwrap();
let text = text_inputs
.entry(id)
.or_insert_with(|| value.clone().unwrap_or_default());
let mut text_edit = egui::TextEdit::singleline(text).id(id);
if let Some(width) = container.calculated_width {
text_edit = text_edit.desired_width(width);
}
Some(text_edit.ui(ui))
}
Input::Password { value, .. } => {
let mut text_inputs = self.text_inputs.write().unwrap();
let text = text_inputs
.entry(id)
.or_insert_with(|| value.clone().unwrap_or_default());
let mut text_edit = egui::TextEdit::singleline(text).id(id).password(true);
if let Some(width) = container.calculated_width {
text_edit = text_edit.desired_width(width);
}
Some(text_edit.ui(ui))
}
Input::Checkbox { checked, .. } => {
let mut checkboxes = self.checkboxes.write().unwrap();
let checked_value = checkboxes
.entry(id)
.or_insert_with(|| checked.unwrap_or(false));
Some(egui::Checkbox::without_text(checked_value).ui(ui))
}
Input::Hidden { .. } => None,
}
}
fn handle_actions(&self, _ui: &Ui, container: &Container, response: &Response) {
for action in &container.actions {
let should_trigger = match action.trigger {
ActionTrigger::Click => response.clicked(),
ActionTrigger::Hover => response.hovered(),
ActionTrigger::Change => response.changed(),
_ => false,
};
if should_trigger {
self.handle_action_with_handler(action, container);
}
}
}
fn handle_action_with_handler(
&self,
action: &hyperchad_actions::Action,
root_container: &Container,
) {
let action_context = EguiActionContext {
ctx: Arc::new(RwLock::new(None)), navigation_sender: Some(self.sender.clone()),
action_sender: Some(self.request_action.clone()),
};
let element_finder = EguiElementFinder::new(root_container);
let visibility_manager = BTreeMapStyleManager::default();
let background_manager = BTreeMapStyleManager::default();
let display_manager = BTreeMapStyleManager::default();
let mut action_handler = ActionHandler::new(
element_finder,
visibility_manager,
background_manager,
display_manager,
);
let style_trigger = match action.trigger {
ActionTrigger::Event(_)
| ActionTrigger::HttpBeforeRequest
| ActionTrigger::HttpAfterRequest
| ActionTrigger::HttpRequestSuccess
| ActionTrigger::HttpRequestError
| ActionTrigger::HttpRequestAbort
| ActionTrigger::HttpRequestTimeout => StyleTrigger::CustomEvent,
ActionTrigger::Click
| ActionTrigger::Hover
| ActionTrigger::Change
| ActionTrigger::ClickOutside
| ActionTrigger::MouseDown
| ActionTrigger::KeyDown
| ActionTrigger::Resize
| ActionTrigger::Immediate => StyleTrigger::UiEvent,
};
action_handler.handle_action(
&action.effect.action,
Some(&action.effect),
style_trigger,
0, &action_context,
None, None, );
}
fn get_container_text(container: &Container) -> Option<String> {
if let Some(child) = container.children.first()
&& let Element::Raw { value } | Element::Text { value } = &child.element
{
return Some(value.clone());
}
None
}
fn set_font_size(font_size: f32, ctx: &egui::Context) {
ctx.style_mut(|style| {
for font in style.text_styles.values_mut() {
font.size = font_size;
}
});
}
fn render_image(
images: &mut HashMap<String, AppImage>,
ui: &mut Ui,
source: &str,
container: &Container,
event: &Sender<AppEvent>,
) -> Response {
egui::Frame::new()
.show(ui, |ui| {
ui.set_width(container.calculated_width.unwrap());
ui.set_height(container.calculated_height.unwrap());
match images.get(source) {
Some(AppImage::Bytes(bytes)) => {
log::trace!(
"render_image: showing image for source={source} ({}, {})",
container.calculated_width.unwrap(),
container.calculated_height.unwrap(),
);
egui::Image::from_bytes(
format!("bytes://{source}"),
egui::load::Bytes::Shared(bytes.clone()),
)
.max_width(container.calculated_width.unwrap())
.max_height(container.calculated_height.unwrap())
.ui(ui);
}
Some(AppImage::Loading) => {
log::trace!("render_image: image loading for source={source}");
ui.label("Loading...");
}
None => {
log::trace!("render_image: triggering image load for source={source}");
images.insert(source.to_string(), AppImage::Loading);
if let Err(e) = event.send(AppEvent::LoadImage {
source: source.to_string(),
}) {
log::error!("Failed to send LoadImage event: {e:?}");
}
ui.label("Loading...");
}
}
})
.response
}
async fn listen(&self) {
while let Ok(event) = self.event_receiver.recv_async().await {
log::trace!("received event {event:?}");
match event {
AppEvent::LoadImage { source } => {
let images = self.images.clone();
let ctx = self.ctx.clone();
if let Some(file) = moosicbox_app_native_image::Asset::get(&source) {
log::trace!("loading image {source}");
images
.write()
.unwrap()
.insert(source, AppImage::Bytes(file.data.to_vec().into()));
if let Some(ctx) = &*ctx.read().unwrap() {
ctx.request_repaint();
}
} else {
switchy_async::runtime::Handle::current().spawn_with_name(
"renderer: load_image",
async move {
static CLIENT: LazyLock<switchy_http::Client> =
LazyLock::new(switchy_http::Client::new);
log::trace!("loading image {source}");
match CLIENT.get(&source).send().await {
Ok(response) => {
if !response.status().is_success() {
log::error!(
"Failed to load image: {}",
response.text().await.unwrap_or_else(|e| {
format!("(failed to get response text: {e:?})")
})
);
return;
}
match response.bytes().await {
Ok(bytes) => {
let bytes = bytes.to_vec().into();
let mut binding = images.write().unwrap();
binding.insert(source, AppImage::Bytes(bytes));
drop(binding);
if let Some(ctx) = &*ctx.read().unwrap() {
ctx.request_repaint();
}
}
Err(e) => {
log::error!(
"Failed to fetch image ({source}): {e:?}"
);
}
}
}
Err(e) => {
log::error!("Failed to fetch image ({source}): {e:?}");
}
}
},
);
}
}
}
}
}
}
#[async_trait]
impl<C: EguiCalc + Clone + Send + Sync + 'static> hyperchad_renderer::Renderer for EguiRenderer<C> {
fn add_responsive_trigger(
&mut self,
_name: String,
_trigger: hyperchad_transformer::ResponsiveTrigger,
) {
}
async fn init(
&mut self,
width: f32,
height: f32,
x: Option<i32>,
y: Option<i32>,
background: Option<hyperchad_renderer::Color>,
title: Option<&str>,
description: Option<&str>,
_viewport: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + 'static>> {
self.width = Some(width);
self.height = Some(height);
self.x = x;
self.y = y;
self.app.title = title.map(Into::into);
self.app.description = description.map(Into::into);
self.app.background = background.map(Into::into);
log::debug!("EguiRenderer: initialized with size {width}x{height}");
log::debug!("EguiRenderer: spawning listen thread");
switchy_async::runtime::Handle::current().spawn_with_name("renderer_egui::init: listen", {
let app = self.app.clone();
async move {
log::debug!("EguiRenderer: listening");
app.listen().await;
Ok::<_, Box<dyn std::error::Error + Send + 'static>>(())
}
});
Ok(())
}
async fn emit_event(
&self,
event_name: String,
event_value: Option<String>,
) -> Result<(), Box<dyn std::error::Error + Send + 'static>> {
log::debug!("EguiRenderer: emit_event {event_name} = {event_value:?}");
Ok(())
}
async fn render(
&self,
view: hyperchad_renderer::View,
) -> Result<(), Box<dyn std::error::Error + Send + 'static>> {
log::debug!("EguiRenderer: render called");
let Some(primary) = view.primary else {
return Ok(());
};
if self.app.ctx.read().unwrap().is_none() {
log::debug!("EguiRenderer: context not ready, queuing render");
self.app
.render_queue
.write()
.unwrap()
.as_mut()
.unwrap()
.push_back(RenderView::View(primary));
return Ok(());
}
let mut container = primary;
container.calculated_width = self.app.width.read().unwrap().or(self.width);
container.calculated_height = self.app.height.read().unwrap().or(self.height);
self.app.calculator.read().unwrap().calc(&mut container);
*self.app.container.write().unwrap() = Some(container);
if let Some(ctx) = &*self.app.ctx.read().unwrap() {
ctx.request_repaint();
}
Ok(())
}
async fn render_canvas(
&self,
_update: CanvasUpdate,
) -> Result<(), Box<dyn std::error::Error + Send + 'static>> {
log::debug!("EguiRenderer: render_canvas called");
Ok(())
}
}
impl<C: EguiCalc + Clone + Send + Sync + 'static> eframe::App for EguiApp<C> {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let render_queue = self.render_queue.write().unwrap().take();
if let Some(render_queue) = render_queue {
for render_view in render_queue {
match render_view {
RenderView::View(view) => {
let mut container = view;
container.calculated_width = self.width.read().unwrap().or(Some(800.0));
container.calculated_height = self.height.read().unwrap().or(Some(600.0));
self.calculator.read().unwrap().calc(&mut container);
*self.container.write().unwrap() = Some(container);
}
}
}
*self.render_queue.write().unwrap() = Some(VecDeque::new());
}
self.check_frame_resize(ctx);
ctx.style_mut(|style| {
style.spacing.item_spacing = egui::Vec2::ZERO;
style.spacing.window_margin = egui::Margin::ZERO;
style.spacing.button_padding = egui::Vec2::ZERO;
});
egui::CentralPanel::default()
.frame(
egui::Frame::new().fill(
self.background
.unwrap_or_else(|| Color32::from_hex("#181a1b").unwrap()),
),
)
.show(ctx, |ui| {
if let Some(container) = &*self.container.read().unwrap() {
self.render_container(ui, container);
}
});
}
}
#[derive(Clone)]
struct EguiActionContext {
ctx: Arc<RwLock<Option<egui::Context>>>,
navigation_sender: Option<Sender<String>>,
action_sender: Option<Sender<(String, Option<Value>)>>,
}
impl ActionContext for EguiActionContext {
fn request_repaint(&self) {
if let Some(ctx) = &*self.ctx.read().unwrap() {
ctx.request_repaint();
}
}
fn get_mouse_position(&self) -> Option<(f32, f32)> {
None
}
fn get_mouse_position_relative(&self, _element_id: usize) -> Option<(f32, f32)> {
None
}
fn navigate(&self, url: String) -> Result<(), Box<dyn std::error::Error + Send>> {
self.navigation_sender.as_ref().map_or_else(
|| {
Err(Box::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Navigation sender not available",
)) as Box<dyn std::error::Error + Send>)
},
|sender| {
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.as_ref().map_or_else(
|| {
Err(Box::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Action sender not available",
)) as Box<dyn std::error::Error + Send>)
},
|sender| {
sender
.send((action, value))
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send>)
},
)
}
fn log(&self, level: ActionLogLevel, message: &str) {
match level {
ActionLogLevel::Error => log::error!("{message}"),
ActionLogLevel::Warn => log::warn!("{message}"),
ActionLogLevel::Info => log::info!("{message}"),
ActionLogLevel::Debug => log::debug!("{message}"),
ActionLogLevel::Trace => log::trace!("{message}"),
}
}
}
struct EguiElementFinder<'a> {
container: &'a Container,
}
impl<'a> EguiElementFinder<'a> {
const fn new(container: &'a Container) -> Self {
Self { container }
}
fn find_element_recursive(
container: &Container,
predicate: &dyn Fn(&Container) -> bool,
) -> Option<usize> {
if predicate(container) {
return Some(container.id);
}
for child in &container.children {
if let Some(id) = Self::find_element_recursive(child, predicate) {
return Some(id);
}
}
None
}
fn find_by_id(container: &Container, id: usize) -> Option<&Container> {
if container.id == id {
return Some(container);
}
for child in &container.children {
if let Some(found) = Self::find_by_id(child, id) {
return Some(found);
}
}
None
}
}
impl ElementFinder for EguiElementFinder<'_> {
fn find_by_str_id(&self, str_id: &str) -> Option<usize> {
Self::find_element_recursive(self.container, &|container| {
container.str_id.as_ref().is_some_and(|id| id == str_id)
})
}
fn find_by_class(&self, class: &str) -> Option<usize> {
Self::find_element_recursive(self.container, &|container| {
container.classes.iter().any(|c| c == class)
})
}
fn find_child_by_class(&self, parent_id: usize, class: &str) -> Option<usize> {
let parent = Self::find_by_id(self.container, parent_id)?;
for child in &parent.children {
if child.classes.iter().any(|c| c == class) {
return Some(child.id);
}
}
None
}
fn get_last_child(&self, parent_id: usize) -> Option<usize> {
let parent = Self::find_by_id(self.container, parent_id)?;
parent.children.last().map(|child| child.id)
}
fn get_data_attr(&self, element_id: usize, attr: &str) -> Option<String> {
let element = Self::find_by_id(self.container, element_id)?;
element.data.get(attr).cloned()
}
fn get_str_id(&self, element_id: usize) -> Option<String> {
let element = Self::find_by_id(self.container, element_id)?;
element.str_id.clone()
}
fn get_dimensions(&self, element_id: usize) -> Option<(f32, f32)> {
let element = Self::find_by_id(self.container, element_id)?;
Some((
element.calculated_width.unwrap_or(0.0),
element.calculated_height.unwrap_or(0.0),
))
}
fn get_position(&self, element_id: usize) -> Option<(f32, f32)> {
let element = Self::find_by_id(self.container, element_id)?;
Some((
element.calculated_x.unwrap_or(0.0),
element.calculated_y.unwrap_or(0.0),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_container(
id: usize,
str_id: Option<&str>,
classes: Vec<&str>,
children: Vec<Container>,
) -> Container {
Container {
id,
str_id: str_id.map(String::from),
classes: classes.into_iter().map(String::from).collect(),
children,
..Default::default()
}
}
#[test_log::test]
fn test_find_by_id_finds_root_element() {
let container = make_container(1, None, vec![], vec![]);
let result = EguiElementFinder::find_by_id(&container, 1);
assert!(result.is_some());
assert_eq!(result.unwrap().id, 1);
}
#[test_log::test]
fn test_find_by_id_finds_nested_element() {
let grandchild = make_container(3, None, vec![], vec![]);
let child = make_container(2, None, vec![], vec![grandchild]);
let container = make_container(1, None, vec![], vec![child]);
let result = EguiElementFinder::find_by_id(&container, 3);
assert!(result.is_some());
assert_eq!(result.unwrap().id, 3);
}
#[test_log::test]
fn test_find_by_id_returns_none_when_not_found() {
let child = make_container(2, None, vec![], vec![]);
let container = make_container(1, None, vec![], vec![child]);
let result = EguiElementFinder::find_by_id(&container, 999);
assert!(result.is_none());
}
#[test_log::test]
fn test_find_by_id_searches_all_siblings() {
let grandchild = make_container(5, None, vec![], vec![]);
let child1 = make_container(2, None, vec![], vec![]);
let child2 = make_container(3, None, vec![], vec![]);
let child3 = make_container(4, None, vec![], vec![grandchild]);
let container = make_container(1, None, vec![], vec![child1, child2, child3]);
let result = EguiElementFinder::find_by_id(&container, 5);
assert!(result.is_some());
assert_eq!(result.unwrap().id, 5);
}
#[test_log::test]
fn test_find_element_recursive_with_str_id_predicate() {
let grandchild = make_container(3, Some("target"), vec![], vec![]);
let child = make_container(2, Some("child"), vec![], vec![grandchild]);
let container = make_container(1, Some("root"), vec![], vec![child]);
let result = EguiElementFinder::find_element_recursive(&container, &|c| {
c.str_id.as_deref() == Some("target")
});
assert!(result.is_some());
assert_eq!(result.unwrap(), 3);
}
#[test_log::test]
fn test_find_element_recursive_with_class_predicate() {
let grandchild = make_container(3, None, vec!["target-class"], vec![]);
let child = make_container(2, None, vec!["wrapper"], vec![grandchild]);
let container = make_container(1, None, vec!["root"], vec![child]);
let result = EguiElementFinder::find_element_recursive(&container, &|c| {
c.classes.iter().any(|class| class == "target-class")
});
assert!(result.is_some());
assert_eq!(result.unwrap(), 3);
}
#[test_log::test]
fn test_find_element_recursive_returns_none_when_predicate_never_matches() {
let child = make_container(2, None, vec!["other"], vec![]);
let container = make_container(1, None, vec!["root"], vec![child]);
let result = EguiElementFinder::find_element_recursive(&container, &|c| c.id == 999);
assert!(result.is_none());
}
#[test_log::test]
fn test_find_element_recursive_returns_first_match_dfs() {
let child1 = make_container(2, None, vec!["shared"], vec![]);
let child2 = make_container(3, None, vec!["shared"], vec![]);
let container = make_container(1, None, vec![], vec![child1, child2]);
let result = EguiElementFinder::find_element_recursive(&container, &|c| {
c.classes.iter().any(|class| class == "shared")
});
assert!(result.is_some());
assert_eq!(result.unwrap(), 2); }
#[test_log::test]
fn test_find_by_str_id_finds_element() {
let grandchild = make_container(3, Some("target-id"), vec![], vec![]);
let child = make_container(2, None, vec![], vec![grandchild]);
let container = make_container(1, None, vec![], vec![child]);
let finder = EguiElementFinder::new(&container);
let result = finder.find_by_str_id("target-id");
assert!(result.is_some());
assert_eq!(result.unwrap(), 3);
}
#[test_log::test]
fn test_find_by_str_id_returns_none_when_not_found() {
let container = make_container(1, Some("root"), vec![], vec![]);
let finder = EguiElementFinder::new(&container);
let result = finder.find_by_str_id("nonexistent");
assert!(result.is_none());
}
#[test_log::test]
fn test_find_by_class_finds_element() {
let grandchild = make_container(3, None, vec!["target-class"], vec![]);
let child = make_container(2, None, vec!["wrapper"], vec![grandchild]);
let container = make_container(1, None, vec![], vec![child]);
let finder = EguiElementFinder::new(&container);
let result = finder.find_by_class("target-class");
assert!(result.is_some());
assert_eq!(result.unwrap(), 3);
}
#[test_log::test]
fn test_find_by_class_returns_none_when_not_found() {
let container = make_container(1, None, vec!["root-class"], vec![]);
let finder = EguiElementFinder::new(&container);
let result = finder.find_by_class("nonexistent");
assert!(result.is_none());
}
#[test_log::test]
fn test_find_child_by_class_finds_direct_child_only() {
let grandchild = make_container(3, None, vec!["deep-target"], vec![]);
let child = make_container(2, None, vec!["direct-target"], vec![grandchild]);
let container = make_container(1, None, vec![], vec![child]);
let finder = EguiElementFinder::new(&container);
let result = finder.find_child_by_class(1, "direct-target");
assert!(result.is_some());
assert_eq!(result.unwrap(), 2);
let result = finder.find_child_by_class(1, "deep-target");
assert!(result.is_none());
let result = finder.find_child_by_class(2, "deep-target");
assert!(result.is_some());
assert_eq!(result.unwrap(), 3);
}
#[test_log::test]
fn test_find_child_by_class_returns_none_for_invalid_parent() {
let child = make_container(2, None, vec!["target"], vec![]);
let container = make_container(1, None, vec![], vec![child]);
let finder = EguiElementFinder::new(&container);
let result = finder.find_child_by_class(999, "target");
assert!(result.is_none());
}
#[test_log::test]
fn test_get_last_child_returns_last_child_id() {
let child1 = make_container(2, None, vec![], vec![]);
let child2 = make_container(3, None, vec![], vec![]);
let child3 = make_container(4, None, vec![], vec![]);
let container = make_container(1, None, vec![], vec![child1, child2, child3]);
let finder = EguiElementFinder::new(&container);
let result = finder.get_last_child(1);
assert!(result.is_some());
assert_eq!(result.unwrap(), 4);
}
#[test_log::test]
fn test_get_last_child_returns_none_for_childless_element() {
let container = make_container(1, None, vec![], vec![]);
let finder = EguiElementFinder::new(&container);
let result = finder.get_last_child(1);
assert!(result.is_none());
}
#[test_log::test]
fn test_get_data_attr_returns_attribute_value() {
let mut container = make_container(1, None, vec![], vec![]);
container
.data
.insert("test-key".to_string(), "test-value".to_string());
let finder = EguiElementFinder::new(&container);
let result = finder.get_data_attr(1, "test-key");
assert_eq!(result, Some("test-value".to_string()));
}
#[test_log::test]
fn test_get_data_attr_returns_none_for_missing_key() {
let container = make_container(1, None, vec![], vec![]);
let finder = EguiElementFinder::new(&container);
let result = finder.get_data_attr(1, "nonexistent");
assert!(result.is_none());
}
#[test_log::test]
fn test_get_str_id_returns_string_id() {
let container = make_container(1, Some("element-id"), vec![], vec![]);
let finder = EguiElementFinder::new(&container);
let result = finder.get_str_id(1);
assert_eq!(result, Some("element-id".to_string()));
}
#[test_log::test]
fn test_get_str_id_returns_none_when_missing() {
let container = make_container(1, None, vec![], vec![]);
let finder = EguiElementFinder::new(&container);
let result = finder.get_str_id(1);
assert!(result.is_none());
}
#[test_log::test]
fn test_get_dimensions_returns_calculated_values() {
let mut container = make_container(1, None, vec![], vec![]);
container.calculated_width = Some(200.0);
container.calculated_height = Some(100.0);
let finder = EguiElementFinder::new(&container);
let result = finder.get_dimensions(1);
assert!(result.is_some());
let (width, height) = result.unwrap();
assert!((width - 200.0).abs() < f32::EPSILON);
assert!((height - 100.0).abs() < f32::EPSILON);
}
#[test_log::test]
fn test_get_dimensions_defaults_to_zero() {
let container = make_container(1, None, vec![], vec![]);
let finder = EguiElementFinder::new(&container);
let result = finder.get_dimensions(1);
assert!(result.is_some());
let (width, height) = result.unwrap();
assert!((width - 0.0).abs() < f32::EPSILON);
assert!((height - 0.0).abs() < f32::EPSILON);
}
#[test_log::test]
fn test_get_position_returns_calculated_values() {
let mut container = make_container(1, None, vec![], vec![]);
container.calculated_x = Some(50.0);
container.calculated_y = Some(150.0);
let finder = EguiElementFinder::new(&container);
let result = finder.get_position(1);
assert!(result.is_some());
let (x, y) = result.unwrap();
assert!((x - 50.0).abs() < f32::EPSILON);
assert!((y - 150.0).abs() < f32::EPSILON);
}
#[test_log::test]
fn test_get_position_defaults_to_zero() {
let container = make_container(1, None, vec![], vec![]);
let finder = EguiElementFinder::new(&container);
let result = finder.get_position(1);
assert!(result.is_some());
let (x, y) = result.unwrap();
assert!((x - 0.0).abs() < f32::EPSILON);
assert!((y - 0.0).abs() < f32::EPSILON);
}
}