nightshade 0.55.0

A cross-platform data-oriented game engine.
Documentation
#[cfg(feature = "gamepad")]
use crate::ecs::ui::picking::is_descendant_of;
#[cfg(feature = "gamepad")]
use crate::ecs::ui::resources::NavDirection;
#[cfg(feature = "gamepad")]
use crate::ecs::world::UI;
use crate::ecs::world::World;
#[cfg(feature = "gamepad")]
use crate::render::ui_data::Rect;

#[cfg(feature = "gamepad")]
pub fn ui_gamepad_navigation_system(world: &mut World) {
    if !world
        .res::<crate::ecs::ui::resources::RetainedUiState>()
        .visible
    {
        return;
    }
    if !world
        .res::<crate::ecs::ui::resources::RetainedUiState>()
        .gamepad_nav
        .enabled
    {
        return;
    }
    if world
        .res::<crate::plugins::gamepad::Gamepad>()
        .gilrs
        .lock()
        .unwrap()
        .is_none()
    {
        return;
    }

    let now = world
        .res::<crate::ecs::ui::resources::RetainedUiState>()
        .timing
        .current_time;
    let deadzone = world
        .res::<crate::ecs::ui::resources::RetainedUiState>()
        .gamepad_nav
        .deadzone;
    let initial_delay = world
        .res::<crate::ecs::ui::resources::RetainedUiState>()
        .gamepad_nav
        .initial_repeat_delay;
    let repeat_interval = world
        .res::<crate::ecs::ui::resources::RetainedUiState>()
        .gamepad_nav
        .repeat_interval;

    let direction = read_direction(world, deadzone);
    let activate = read_button_just_pressed(world, gilrs::Button::South);
    let cancel = read_button_just_pressed(world, gilrs::Button::East);
    let pause = read_button_just_pressed(world, gilrs::Button::Start);
    let prev_tab = read_button_just_pressed(world, gilrs::Button::LeftTrigger)
        || read_button_just_pressed(world, gilrs::Button::LeftTrigger2);
    let next_tab = read_button_just_pressed(world, gilrs::Button::RightTrigger)
        || read_button_just_pressed(world, gilrs::Button::RightTrigger2);

    let nav_state = &mut world
        .res_mut::<crate::ecs::ui::resources::RetainedUiState>()
        .gamepad_nav;
    let should_navigate = match direction {
        Some(dir) => {
            if nav_state.held_direction == Some(dir) {
                if now >= nav_state.next_repeat_at {
                    nav_state.next_repeat_at = now + repeat_interval;
                    true
                } else {
                    false
                }
            } else {
                nav_state.held_direction = Some(dir);
                nav_state.next_repeat_at = now + initial_delay;
                true
            }
        }
        None => {
            nav_state.held_direction = None;
            false
        }
    };

    if should_navigate && let Some(dir) = direction {
        focus_in_direction(world, dir);
    }

    if next_tab {
        cycle_tab_focus(world, false);
    }
    if prev_tab {
        cycle_tab_focus(world, true);
    }

    if activate {
        if world
            .res::<crate::ecs::ui::resources::RetainedUiState>()
            .interaction
            .focused_entity
            .is_none()
        {
            let focusable = collect_focusable(world);
            if let Some((entity, _, _)) = focusable.first() {
                world
                    .res_mut::<crate::ecs::ui::resources::RetainedUiState>()
                    .interaction_for_active_mut()
                    .focused_entity = Some(*entity);
                world
                    .res_mut::<crate::ecs::ui::resources::RetainedUiState>()
                    .overlays
                    .focus_ring_visible = true;
            }
        }
        if let Some(focused) = world
            .res_mut::<crate::ecs::ui::resources::RetainedUiState>()
            .interaction_for_active_mut()
            .focused_entity
            && let Some(interaction) =
                world.get_mut::<crate::ecs::ui::components::UiNodeInteraction>(focused)
            && !interaction.disabled
        {
            interaction.clicked = true;
        }
    }

    if cancel || pause {
        world
            .res_mut::<crate::ecs::input::resources::Input>()
            .keyboard
            .just_pressed_keys
            .insert(winit::keyboard::KeyCode::Escape);
        world
            .res_mut::<crate::ecs::input::resources::Input>()
            .keyboard
            .keystates
            .insert(
                winit::keyboard::KeyCode::Escape,
                winit::event::ElementState::Pressed,
            );
        world
            .res_mut::<crate::ecs::input::resources::Input>()
            .keyboard
            .frame_keys
            .push((winit::keyboard::KeyCode::Escape, true));
    }
}

#[cfg(not(feature = "gamepad"))]
pub fn ui_gamepad_navigation_system(_world: &mut World) {}

#[cfg(feature = "gamepad")]
fn read_direction(world: &World, deadzone: f32) -> Option<NavDirection> {
    crate::plugins::gamepad::with_active_gamepad(world, |gamepad| {
        let stick_x = gamepad.value(gilrs::Axis::LeftStickX);
        let stick_y = gamepad.value(gilrs::Axis::LeftStickY);

        let dpad_up = gamepad.is_pressed(gilrs::Button::DPadUp);
        let dpad_down = gamepad.is_pressed(gilrs::Button::DPadDown);
        let dpad_left = gamepad.is_pressed(gilrs::Button::DPadLeft);
        let dpad_right = gamepad.is_pressed(gilrs::Button::DPadRight);

        let vertical = if dpad_up || stick_y > deadzone {
            Some(NavDirection::Up)
        } else if dpad_down || stick_y < -deadzone {
            Some(NavDirection::Down)
        } else {
            None
        };

        let horizontal = if dpad_right || stick_x > deadzone {
            Some(NavDirection::Right)
        } else if dpad_left || stick_x < -deadzone {
            Some(NavDirection::Left)
        } else {
            None
        };

        if vertical.is_some() && horizontal.is_some() {
            if stick_y.abs() >= stick_x.abs() {
                vertical
            } else {
                horizontal
            }
        } else {
            vertical.or(horizontal)
        }
    })
    .flatten()
}

#[cfg(feature = "gamepad")]
fn read_button_just_pressed(world: &World, button: gilrs::Button) -> bool {
    world
        .res::<crate::plugins::gamepad::Gamepad>()
        .just_pressed_buttons
        .contains(&button)
}

#[cfg(feature = "gamepad")]
fn collect_focusable(world: &World) -> Vec<(freecs::Entity, Rect, Option<i32>)> {
    let mut focusable = Vec::new();
    for entity in world.ecs.worlds[UI].query_entities(crate::ecs::world::UI_NODE_INTERACTION) {
        let Some(interaction) = world.get::<crate::ecs::ui::components::UiNodeInteraction>(entity)
        else {
            continue;
        };
        if interaction.disabled {
            continue;
        }
        let Some(node) = world.get::<crate::ecs::ui::components::UiLayoutNode>(entity) else {
            continue;
        };
        if !node.visible {
            continue;
        }
        if !crate::ecs::ui::widgets::world_layout::ui_node_effectively_visible(world, entity) {
            continue;
        }
        let rect = node.computed_rect;
        if rect.width() <= 0.0 || rect.height() <= 0.0 {
            continue;
        }
        focusable.push((entity, rect, interaction.tab_index));
    }
    scope_to_active_overlay(world, &mut focusable);
    focusable
}

#[cfg(feature = "gamepad")]
fn scope_to_active_overlay(
    world: &World,
    focusable: &mut Vec<(freecs::Entity, Rect, Option<i32>)>,
) {
    if let Some(modal) = world
        .res::<crate::ecs::ui::resources::RetainedUiState>()
        .overlays
        .active_modal
    {
        focusable.retain(|(entity, _, _)| is_descendant_of(world, *entity, modal));
    } else if !world
        .res::<crate::ecs::ui::resources::RetainedUiState>()
        .overlays
        .popup_entities
        .is_empty()
    {
        let popups = world
            .res::<crate::ecs::ui::resources::RetainedUiState>()
            .overlays
            .popup_entities
            .clone();
        focusable.retain(|(entity, _, _)| {
            popups
                .iter()
                .any(|popup| is_descendant_of(world, *entity, *popup))
        });
    }
}

#[cfg(feature = "gamepad")]
fn focus_in_direction(world: &mut World, direction: NavDirection) {
    let focusable = collect_focusable(world);
    if focusable.is_empty() {
        return;
    }
    let current = world
        .res_mut::<crate::ecs::ui::resources::RetainedUiState>()
        .interaction_for_active_mut()
        .focused_entity;
    let next = match current {
        Some(entity) => match focusable.iter().find(|(e, _, _)| *e == entity) {
            Some((_, rect, _)) => find_neighbor(&focusable, entity, *rect, direction)
                .or_else(|| fallback_endpoint(&focusable, direction)),
            None => fallback_endpoint(&focusable, direction),
        },
        None => fallback_endpoint(&focusable, direction),
    };
    if let Some(entity) = next {
        world
            .res_mut::<crate::ecs::ui::resources::RetainedUiState>()
            .interaction_for_active_mut()
            .focused_entity = Some(entity);
        world
            .res_mut::<crate::ecs::ui::resources::RetainedUiState>()
            .overlays
            .focus_ring_visible = true;
    }
}

#[cfg(feature = "gamepad")]
fn find_neighbor(
    focusable: &[(freecs::Entity, Rect, Option<i32>)],
    current: freecs::Entity,
    current_rect: Rect,
    direction: NavDirection,
) -> Option<freecs::Entity> {
    let current_center = current_rect.center();
    let mut best: Option<(freecs::Entity, f32)> = None;
    for (entity, rect, _) in focusable {
        if *entity == current {
            continue;
        }
        let center = rect.center();
        let delta = center - current_center;
        let in_direction = match direction {
            NavDirection::Up => delta.y < -1.0,
            NavDirection::Down => delta.y > 1.0,
            NavDirection::Left => delta.x < -1.0,
            NavDirection::Right => delta.x > 1.0,
        };
        if !in_direction {
            continue;
        }
        let (primary, secondary) = match direction {
            NavDirection::Up | NavDirection::Down => (delta.y.abs(), delta.x.abs()),
            NavDirection::Left | NavDirection::Right => (delta.x.abs(), delta.y.abs()),
        };
        let score = primary + secondary * 2.5;
        match best {
            Some((_, current_score)) if score >= current_score => {}
            _ => best = Some((*entity, score)),
        }
    }
    best.map(|(entity, _)| entity)
}

#[cfg(feature = "gamepad")]
fn fallback_endpoint(
    focusable: &[(freecs::Entity, Rect, Option<i32>)],
    direction: NavDirection,
) -> Option<freecs::Entity> {
    focusable
        .iter()
        .min_by(|a, b| {
            let key_a = endpoint_key(direction, a.1.center());
            let key_b = endpoint_key(direction, b.1.center());
            key_a
                .partial_cmp(&key_b)
                .unwrap_or(std::cmp::Ordering::Equal)
        })
        .map(|(entity, _, _)| *entity)
}

#[cfg(feature = "gamepad")]
fn endpoint_key(direction: NavDirection, center: nalgebra_glm::Vec2) -> f32 {
    match direction {
        NavDirection::Up => -center.y,
        NavDirection::Down => center.y,
        NavDirection::Left => -center.x,
        NavDirection::Right => center.x,
    }
}

#[cfg(feature = "gamepad")]
fn cycle_tab_focus(world: &mut World, reverse: bool) {
    let mut tabbable: Vec<(freecs::Entity, i32)> = collect_focusable(world)
        .into_iter()
        .filter_map(|(entity, _, tab_index)| tab_index.map(|index| (entity, index)))
        .collect();
    tabbable.sort_by_key(|(_, index)| *index);
    if tabbable.is_empty() {
        return;
    }
    let current = world
        .res_mut::<crate::ecs::ui::resources::RetainedUiState>()
        .interaction_for_active_mut()
        .focused_entity;
    let current_index =
        current.and_then(|entity| tabbable.iter().position(|(stored, _)| *stored == entity));
    let next_index = if reverse {
        match current_index {
            Some(0) => tabbable.len() - 1,
            Some(index) => index - 1,
            None => tabbable.len() - 1,
        }
    } else {
        match current_index {
            Some(index) => (index + 1) % tabbable.len(),
            None => 0,
        }
    };
    let focused = tabbable[next_index].0;
    world
        .res_mut::<crate::ecs::ui::resources::RetainedUiState>()
        .interaction_for_active_mut()
        .focused_entity = Some(focused);
    world
        .res_mut::<crate::ecs::ui::resources::RetainedUiState>()
        .overlays
        .focus_ring_visible = true;
}