nightshade 0.14.1

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::ui::types::Rect;
use crate::ecs::world::World;

#[cfg(feature = "gamepad")]
pub fn ui_gamepad_navigation_system(world: &mut World) {
    if !world.resources.retained_ui.enabled {
        return;
    }
    if !world.resources.retained_ui.gamepad_nav.enabled {
        return;
    }
    if world.resources.input.gamepad.gilrs.is_none() {
        return;
    }

    let now = world.resources.retained_ui.timing.current_time;
    let deadzone = world.resources.retained_ui.gamepad_nav.deadzone;
    let initial_delay = world.resources.retained_ui.gamepad_nav.initial_repeat_delay;
    let repeat_interval = world.resources.retained_ui.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.resources.retained_ui.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
            .resources
            .retained_ui
            .interaction
            .focused_entity
            .is_none()
        {
            let focusable = collect_focusable(world);
            if let Some((entity, _, _)) = focusable.first() {
                world
                    .resources
                    .retained_ui
                    .interaction_for_active_mut()
                    .focused_entity = Some(*entity);
                world.resources.retained_ui.overlays.focus_ring_visible = true;
            }
        }
        if let Some(focused) = world
            .resources
            .retained_ui
            .interaction_for_active_mut()
            .focused_entity
            && let Some(interaction) = world.ui.get_ui_node_interaction_mut(focused)
            && !interaction.disabled
        {
            interaction.clicked = true;
        }
    }

    if cancel || pause {
        world
            .resources
            .input
            .keyboard
            .just_pressed_keys
            .insert(winit::keyboard::KeyCode::Escape);
        world.resources.input.keyboard.keystates.insert(
            winit::keyboard::KeyCode::Escape,
            winit::event::ElementState::Pressed,
        );
        world
            .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> {
    let gamepad = active_gamepad(world)?;
    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)
    }
}

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

#[cfg(feature = "gamepad")]
fn active_gamepad(world: &World) -> Option<gilrs::Gamepad<'_>> {
    let gilrs = world.resources.input.gamepad.gilrs.as_ref()?;
    let id = world.resources.input.gamepad.gamepad?;
    Some(gilrs.gamepad(id))
}

#[cfg(feature = "gamepad")]
fn collect_focusable(world: &World) -> Vec<(freecs::Entity, Rect, Option<i32>)> {
    let mut focusable = Vec::new();
    for entity in world
        .ui
        .query_entities(crate::ecs::world::UI_NODE_INTERACTION)
    {
        let Some(interaction) = world.ui.get_ui_node_interaction(entity) else {
            continue;
        };
        if interaction.disabled {
            continue;
        }
        let Some(node) = world.ui.get_ui_layout_node(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.resources.retained_ui.overlays.active_modal {
        focusable.retain(|(entity, _, _)| is_descendant_of(world, *entity, modal));
    } else if !world
        .resources
        .retained_ui
        .overlays
        .popup_entities
        .is_empty()
    {
        let popups = world.resources.retained_ui.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
        .resources
        .retained_ui
        .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
            .resources
            .retained_ui
            .interaction_for_active_mut()
            .focused_entity = Some(entity);
        world.resources.retained_ui.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
        .resources
        .retained_ui
        .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
        .resources
        .retained_ui
        .interaction_for_active_mut()
        .focused_entity = Some(focused);
    world.resources.retained_ui.overlays.focus_ring_visible = true;
}