mod events;
mod handle_basic;
mod handle_context_menu;
mod handle_data_grid;
mod handle_drag_value;
mod handle_input;
mod handle_navigation;
mod handle_panel;
mod handle_rich_text;
mod handle_scroll;
mod handle_slider;
mod handle_text_area;
mod handle_tiles;
mod handle_tree;
mod input_sync;
mod properties;
pub mod text_cursor;
pub use events::{ui_event_bubble_system, ui_event_dispatch_system};
pub use input_sync::ui_retained_input_sync_system;
pub use properties::ui_property_sync_system;
pub use text_cursor::measure_text_width;
use nalgebra_glm::Vec2;
use winit::keyboard::KeyCode;
use crate::ecs::ui::components::UiWidgetState;
use crate::ecs::world::World;
use handle_basic::{handle_checkbox, handle_collapsing_header, handle_radio, handle_toggle};
use handle_context_menu::{close_submenu_defs_state, close_submenu_popups, handle_context_menu};
use handle_data_grid::{
DataGridContext, handle_command_palette, handle_data_grid, handle_property_grid,
};
use handle_drag_value::handle_drag_value;
use handle_input::{handle_selectable_label, handle_text_input};
use handle_navigation::{
handle_date_picker, handle_dropdown, handle_menu, handle_multi_select, handle_tab_bar,
};
use handle_panel::{PanelContext, handle_color_picker, handle_panel};
use handle_rich_text::{RichTextEditorContext, handle_rich_text_editor};
use handle_scroll::{handle_scroll_area, handle_virtual_list};
use handle_slider::{handle_breadcrumb, handle_range_slider, handle_slider, handle_splitter};
use handle_text_area::handle_text_area;
use handle_tiles::{TileContainerContext, handle_tile_container};
use handle_tree::{handle_modal_dialog, handle_tree_view};
struct TextEditContext<'a> {
focused_entity: Option<freecs::Entity>,
frame_chars: &'a [char],
frame_keys: &'a [(KeyCode, bool)],
ctrl_held: bool,
shift_held: bool,
mouse_position: Vec2,
current_time: f64,
dpi_scale: f32,
}
struct InteractionSnapshot {
clicked: bool,
pressed: bool,
dragging: bool,
hovered: bool,
double_clicked: bool,
right_clicked: bool,
drag_start: Option<Vec2>,
}
fn snapshot_interaction(world: &World, entity: freecs::Entity) -> InteractionSnapshot {
world
.ui
.get_ui_node_interaction(entity)
.map(|interaction| InteractionSnapshot {
clicked: interaction.clicked,
pressed: interaction.pressed,
dragging: interaction.dragging,
hovered: interaction.hovered,
double_clicked: interaction.double_clicked,
right_clicked: interaction.right_clicked,
drag_start: interaction.drag_start,
})
.unwrap_or(InteractionSnapshot {
clicked: false,
pressed: false,
dragging: false,
hovered: false,
double_clicked: false,
right_clicked: false,
drag_start: None,
})
}
pub fn ui_widget_interaction_system(world: &mut World) {
if !world.resources.retained_ui.enabled {
return;
}
let delta_time = world.resources.retained_ui.delta_time;
let current_time = world.resources.retained_ui.current_time;
let mouse_position = world.resources.input.mouse.position;
let frame_chars = std::mem::take(&mut world.resources.retained_ui.frame_chars);
let frame_keys = std::mem::take(&mut world.resources.retained_ui.frame_keys);
let ctrl_held = world.resources.retained_ui.ctrl_held;
let shift_held = world.resources.retained_ui.shift_held;
let scroll_delta = world.resources.retained_ui.scroll_delta;
let focused_entity = world.resources.retained_ui.focused_entity;
let mouse_state = world.resources.input.mouse.state;
let mouse_just_pressed =
mouse_state.contains(crate::ecs::input::resources::MouseState::LEFT_JUST_PRESSED);
let mouse_just_released =
mouse_state.contains(crate::ecs::input::resources::MouseState::LEFT_JUST_RELEASED);
let mouse_down = mouse_state.contains(crate::ecs::input::resources::MouseState::LEFT_CLICKED);
let dpi_scale = world.resources.window.cached_scale_factor;
let viewport_size = world
.resources
.window
.cached_viewport_size
.map(|(width, height)| Vec2::new(width as f32, height as f32))
.unwrap_or(Vec2::new(800.0, 600.0));
let entities: Vec<freecs::Entity> = world
.ui
.query_entities(crate::ecs::world::UI_WIDGET_STATE)
.collect();
let has_text_focus = focused_entity.is_some_and(|fe| {
matches!(
world.ui.get_ui_widget_state(fe),
Some(UiWidgetState::TextInput(_))
| Some(UiWidgetState::TextArea(_))
| Some(UiWidgetState::DragValue(_))
)
});
if !has_text_focus {
let bindings = world.resources.retained_ui.shortcut_bindings.clone();
let alt_held = world
.resources
.input
.keyboard
.is_key_pressed(KeyCode::AltLeft)
|| world
.resources
.input
.keyboard
.is_key_pressed(KeyCode::AltRight);
for &(ref key, pressed) in &frame_keys {
if !pressed {
continue;
}
for (binding, command_index) in &bindings {
if binding.key == *key
&& binding.ctrl == ctrl_held
&& binding.shift == shift_held
&& binding.alt == alt_held
{
world.resources.retained_ui.frame_events.push(
crate::ecs::ui::resources::UiEvent::ShortcutTriggered {
command_index: *command_index,
},
);
}
}
}
}
let mut panel_rects: Vec<(freecs::Entity, crate::ecs::ui::types::Rect, f32)> = Vec::new();
for entity in &entities {
if let Some(UiWidgetState::Panel(_)) = world.ui.get_ui_widget_state(*entity)
&& let Some(node) = world.ui.get_ui_layout_node(*entity)
&& node.visible
{
panel_rects.push((*entity, node.computed_rect, node.computed_depth));
}
}
let text_edit_ctx = TextEditContext {
focused_entity,
frame_chars: &frame_chars,
frame_keys: &frame_keys,
ctrl_held,
shift_held,
mouse_position,
current_time,
dpi_scale,
};
for entity in entities {
let interaction = snapshot_interaction(world, entity);
let widget_clone = match world.ui.get_ui_widget_state(entity) {
Some(widget) => widget.clone(),
None => continue,
};
match widget_clone {
UiWidgetState::Button(_) => {
if let Some(UiWidgetState::Button(data)) = world.ui.get_ui_widget_state_mut(entity)
{
data.clicked = interaction.clicked;
}
if interaction.clicked {
world
.resources
.retained_ui
.frame_events
.push(crate::ecs::ui::resources::UiEvent::ButtonClicked(entity));
}
}
UiWidgetState::Slider(data) => {
handle_slider(
world,
entity,
&interaction,
&data,
mouse_position,
focused_entity,
&frame_keys,
);
}
UiWidgetState::Toggle(data) => {
handle_toggle(world, entity, &interaction, &data, delta_time);
}
UiWidgetState::Checkbox(data) => {
handle_checkbox(world, entity, &interaction, &data);
}
UiWidgetState::Radio(data) => {
handle_radio(world, entity, &interaction, &data);
}
UiWidgetState::ProgressBar(_) => {}
UiWidgetState::CollapsingHeader(data) => {
handle_collapsing_header(world, entity, &interaction, &data);
}
UiWidgetState::ScrollArea(data) => {
handle_scroll_area(
world,
entity,
&interaction,
&data,
scroll_delta,
mouse_position,
dpi_scale,
);
}
UiWidgetState::TabBar(data) => {
handle_tab_bar(world, entity, &data, &frame_keys, focused_entity);
}
UiWidgetState::TextInput(data) => {
handle_text_input(world, entity, &interaction, &data, &text_edit_ctx);
}
UiWidgetState::Dropdown(data) => {
handle_dropdown(
world,
entity,
&interaction,
&data,
&frame_keys,
focused_entity,
);
}
UiWidgetState::Menu(data) => {
handle_menu(world, entity, &interaction, &data);
}
UiWidgetState::Panel(data) => {
let panel_depth = world
.ui
.get_ui_layout_node(entity)
.map(|n| n.computed_depth)
.unwrap_or(0.0);
let mouse_occluded =
panel_rects
.iter()
.any(|(other_entity, other_rect, other_depth)| {
*other_entity != entity
&& *other_depth > panel_depth
&& other_rect.contains(mouse_position)
});
handle_panel(
world,
entity,
&data,
&PanelContext {
mouse_position,
mouse_just_pressed,
mouse_just_released,
mouse_down,
dpi_scale,
viewport_size,
mouse_occluded,
},
);
}
UiWidgetState::ColorPicker(data) => {
handle_color_picker(world, entity, &data);
}
UiWidgetState::SelectableLabel(data) => {
handle_selectable_label(world, entity, &interaction, &data);
}
UiWidgetState::DragValue(data) => {
handle_drag_value(world, entity, &interaction, &data, &text_edit_ctx);
}
UiWidgetState::ContextMenu(data) => {
handle_context_menu(world, entity, &data);
}
UiWidgetState::TreeView(data) => {
handle_tree_view(
world,
entity,
&data,
ctrl_held,
shift_held,
&frame_keys,
focused_entity,
);
}
UiWidgetState::TreeNode(_) => {}
UiWidgetState::ModalDialog(data) => {
handle_modal_dialog(world, entity, &data, &frame_keys);
}
UiWidgetState::RichText(_) => {}
UiWidgetState::DataGrid(data) => {
handle_data_grid(
world,
entity,
&data,
&DataGridContext {
ctrl_held,
shift_held,
frame_keys: &frame_keys,
mouse_position,
mouse_just_pressed,
mouse_down,
},
);
}
UiWidgetState::PropertyGrid(data) => {
handle_property_grid(
world,
entity,
&data,
mouse_position,
mouse_just_pressed,
mouse_down,
);
}
UiWidgetState::CommandPalette(data) => {
handle_command_palette(world, entity, &data, &frame_keys);
}
UiWidgetState::Canvas(data) => {
if interaction.clicked && data.hit_test_enabled {
let mouse_pos = world.resources.input.mouse.position;
if let Some(node) = world.ui.get_ui_layout_node(entity) {
let local_pos = mouse_pos - node.computed_rect.min;
for &(command_id, ref rect) in data.command_bounds.iter().rev() {
if rect.contains(local_pos) {
world.resources.retained_ui.frame_events.push(
crate::ecs::ui::resources::UiEvent::CanvasClicked {
entity,
command_id,
position: local_pos,
},
);
break;
}
}
}
}
}
UiWidgetState::TextArea(data) => {
handle_text_area(world, entity, &interaction, &data, &text_edit_ctx);
}
UiWidgetState::TileContainer(data) => {
handle_tile_container(
world,
entity,
&data,
&TileContainerContext {
mouse_position,
mouse_just_pressed,
mouse_just_released,
mouse_down,
},
);
}
UiWidgetState::VirtualList(data) => {
handle_virtual_list(world, entity, &data, dpi_scale, &frame_keys, focused_entity);
}
UiWidgetState::RangeSlider(data) => {
handle_range_slider(
world,
entity,
&interaction,
&data,
mouse_position,
focused_entity,
&frame_keys,
);
}
UiWidgetState::Breadcrumb(data) => {
handle_breadcrumb(world, entity, &data);
}
UiWidgetState::Splitter(data) => {
handle_splitter(world, entity, &data, mouse_position);
}
UiWidgetState::RichTextEditor(data) => {
handle_rich_text_editor(
world,
RichTextEditorContext {
entity,
interaction: &interaction,
data: &data,
focused_entity,
frame_chars: &frame_chars,
frame_keys: &frame_keys,
ctrl_held,
shift_held,
mouse_position,
current_time,
dpi_scale,
},
);
}
UiWidgetState::MultiSelect(data) => {
handle_multi_select(world, entity, &interaction, &data);
}
UiWidgetState::DatePicker(data) => {
handle_date_picker(world, entity, &interaction, &data);
}
}
}
if scroll_delta.y.abs() > f32::EPSILON && !is_mouse_over_popup(world, mouse_position) {
close_open_popups(world);
}
let error_color = world
.resources
.retained_ui
.theme_state
.active_theme()
.error_color;
let error_entities: Vec<(freecs::Entity, bool)> = world
.ui
.query_entities(crate::ecs::world::UI_NODE_INTERACTION)
.map(|entity| {
let has_error = world
.ui
.get_ui_node_interaction(entity)
.is_some_and(|interaction| interaction.error_text.is_some());
(entity, has_error)
})
.filter(|(entity, _)| {
world.ui.get_ui_node_content(*entity).is_some_and(|c| {
matches!(c, crate::ecs::ui::components::UiNodeContent::Rect { .. })
})
})
.collect();
for (entity, has_error) in error_entities {
if has_error
&& let Some(crate::ecs::ui::components::UiNodeContent::Rect {
border_color,
border_width,
..
}) = world.ui.get_ui_node_content_mut(entity)
{
*border_color = error_color;
if *border_width < 1.0 {
*border_width = 1.0;
}
}
}
world.resources.retained_ui.frame_chars = frame_chars;
world.resources.retained_ui.frame_keys = frame_keys;
}
fn is_mouse_over_popup(world: &World, mouse_position: Vec2) -> bool {
for entity in world.ui.query_entities(crate::ecs::world::UI_WIDGET_STATE) {
let popup_container = match world.ui.get_ui_widget_state(entity) {
Some(UiWidgetState::Dropdown(data)) if data.open => Some(data.popup_container_entity),
Some(UiWidgetState::Menu(data)) if data.open => Some(data.popup_container_entity),
Some(UiWidgetState::ContextMenu(data)) if data.open => Some(data.popup_entity),
_ => None,
};
if let Some(popup) = popup_container
&& let Some(node) = world.ui.get_ui_layout_node(popup)
&& node.computed_rect.contains(mouse_position)
{
return true;
}
}
false
}
fn close_open_popups(world: &mut World) {
let entities: Vec<freecs::Entity> = world
.ui
.query_entities(crate::ecs::world::UI_WIDGET_STATE)
.collect();
for entity in entities {
let widget = match world.ui.get_ui_widget_state(entity) {
Some(widget) => widget.clone(),
None => continue,
};
match widget {
UiWidgetState::Dropdown(data) if data.open => {
if world
.ui
.get_ui_layout_node(data.popup_container_entity)
.is_some_and(|node| node.visible)
{
world.resources.retained_ui.layout_dirty = true;
}
if let Some(node) = world.ui.get_ui_layout_node_mut(data.popup_container_entity) {
node.visible = false;
}
if let Some(UiWidgetState::Dropdown(widget_data)) =
world.ui.get_ui_widget_state_mut(entity)
{
widget_data.open = false;
}
}
UiWidgetState::Menu(data) if data.open => {
if world
.ui
.get_ui_layout_node(data.popup_container_entity)
.is_some_and(|node| node.visible)
{
world.resources.retained_ui.layout_dirty = true;
}
if let Some(node) = world.ui.get_ui_layout_node_mut(data.popup_container_entity) {
node.visible = false;
}
if let Some(UiWidgetState::Menu(widget_data)) =
world.ui.get_ui_widget_state_mut(entity)
{
widget_data.open = false;
}
}
UiWidgetState::ContextMenu(data) if data.open => {
if let Some(node) = world.ui.get_ui_layout_node_mut(data.popup_entity) {
node.visible = false;
}
close_submenu_popups(world, &data.item_defs);
if let Some(UiWidgetState::ContextMenu(widget_data)) =
world.ui.get_ui_widget_state_mut(entity)
{
widget_data.open = false;
close_submenu_defs_state(&mut widget_data.item_defs);
}
world.resources.retained_ui.active_context_menu = None;
}
_ => {}
}
}
}