nightshade 0.14.0

A cross-platform data-oriented game engine.
Documentation
use nalgebra_glm::Vec2;
use winit::keyboard::KeyCode;

use crate::ecs::world::World;

use super::snapshot_interaction;

pub(super) struct ScrollFrameInputs {
    pub entity: freecs::Entity,
    pub content_entity: freecs::Entity,
    pub thumb_entity: freecs::Entity,
    pub track_entity: freecs::Entity,
    pub scroll_offset: f32,
    pub thumb_dragging: bool,
    pub thumb_drag_start_offset: f32,
    pub snap_interval: Option<f32>,
    pub scroll_delta: Vec2,
    pub mouse_position: Vec2,
    pub dpi_scale: f32,
}

pub(super) struct ScrollFrameResult {
    pub scroll_offset: f32,
    pub thumb_dragging: bool,
    pub thumb_drag_start_offset: f32,
    pub content_height: f32,
    pub visible_height: f32,
}

fn is_innermost_scroll_for(
    world: &World,
    hovered: freecs::Entity,
    scroll_entity: freecs::Entity,
) -> bool {
    let mut current = hovered;
    loop {
        if world.ui.get_ui_scroll_area(current).is_some() {
            return current == scroll_entity;
        }
        match world.core.get_parent(current).and_then(|parent| parent.0) {
            Some(parent) => current = parent,
            None => return false,
        }
    }
}

pub(super) fn apply_scroll_frame(
    world: &mut World,
    inputs: ScrollFrameInputs,
) -> ScrollFrameResult {
    let visible_height = world
        .ui
        .get_ui_layout_node(inputs.entity)
        .map(|node| node.computed_rect.height())
        .unwrap_or(0.0);

    let children: &[freecs::Entity] = world
        .resources
        .transform_state
        .children_cache
        .get(&inputs.content_entity)
        .map(|v| v.as_slice())
        .unwrap_or(&[]);

    let content_flow = world
        .ui
        .get_ui_layout_node(inputs.content_entity)
        .and_then(|node| node.flow_layout);

    let mut total_content_height = 0.0f32;
    let mut visible_count = 0usize;
    for child in children {
        if let Some(node) = world.ui.get_ui_layout_node(*child)
            && node.visible
        {
            total_content_height += node.computed_rect.height();
            visible_count += 1;
        }
    }

    if let Some(flow) = content_flow {
        if visible_count > 1 {
            total_content_height += flow.spacing * inputs.dpi_scale * (visible_count - 1) as f32;
        }
        total_content_height += flow.padding * inputs.dpi_scale * 2.0;
    }

    let max_scroll = (total_content_height - visible_height).max(0.0) / inputs.dpi_scale;
    let mut scroll_offset = inputs.scroll_offset;
    let mut thumb_dragging = inputs.thumb_dragging;
    let mut thumb_drag_start_offset = inputs.thumb_drag_start_offset;

    let scroll_under_cursor = world
        .resources
        .retained_ui
        .interaction
        .hovered_entity
        .is_some_and(|hovered| is_innermost_scroll_for(world, hovered, inputs.entity));

    if scroll_under_cursor && inputs.scroll_delta.y.abs() > 0.0 {
        scroll_offset -= inputs.scroll_delta.y * 40.0;
        scroll_offset = scroll_offset.clamp(0.0, max_scroll);
    }

    let thumb_interaction = snapshot_interaction(world, inputs.thumb_entity);

    if thumb_interaction.pressed && !thumb_dragging {
        thumb_dragging = true;
        thumb_drag_start_offset = scroll_offset;
    }

    if thumb_dragging
        && thumb_interaction.pressed
        && let Some(drag_start) = thumb_interaction.drag_start
    {
        let track_rect = world
            .ui
            .get_ui_layout_node(inputs.track_entity)
            .map(|n| n.computed_rect);
        if let Some(track) = track_rect {
            let track_height = track.height();
            let thumb_ratio = if total_content_height > 0.0 {
                visible_height / total_content_height
            } else {
                1.0
            };
            let scrollable_track = track_height * (1.0 - thumb_ratio);
            if scrollable_track > 0.0 {
                let mouse_delta = inputs.mouse_position.y - drag_start.y;
                let scroll_per_pixel = max_scroll / scrollable_track;
                scroll_offset = (thumb_drag_start_offset + mouse_delta * scroll_per_pixel)
                    .clamp(0.0, max_scroll);
            }
        }
    }

    if !thumb_interaction.pressed {
        thumb_dragging = false;
    }

    if let Some(snap) = inputs.snap_interval
        && snap > 0.0
        && !thumb_dragging
        && inputs.scroll_delta.y.abs() < f32::EPSILON
    {
        let target = (scroll_offset / snap).round() * snap;
        let target = target.clamp(0.0, max_scroll);
        let delta_time = world.resources.retained_ui.timing.delta_time;
        let diff = target - scroll_offset;
        if diff.abs() > 0.1 {
            scroll_offset += diff * (10.0 * delta_time).min(1.0);
        } else {
            scroll_offset = target;
        }
    }

    if let Some(content_node) = world.ui.get_ui_layout_node_mut(inputs.content_entity) {
        content_node.scroll_offset = Vec2::new(0.0, scroll_offset);
    }

    let show_scrollbar = total_content_height > visible_height;
    if let Some(track_node) = world.ui.get_ui_layout_node_mut(inputs.track_entity) {
        track_node.visible = show_scrollbar;
    }

    if show_scrollbar {
        let thumb_ratio = (visible_height / total_content_height).clamp(0.05, 1.0);
        let track_rect = world
            .ui
            .get_ui_layout_node(inputs.track_entity)
            .map(|n| n.computed_rect);
        if let Some(track) = track_rect {
            let track_height = track.height();
            let thumb_height_physical = (track_height * thumb_ratio).max(20.0 * inputs.dpi_scale);
            let scrollable_track = track_height - thumb_height_physical;
            let scroll_ratio = if max_scroll > 0.0 {
                scroll_offset / max_scroll
            } else {
                0.0
            };
            let thumb_y = scroll_ratio * scrollable_track / inputs.dpi_scale;
            let thumb_height = thumb_height_physical / inputs.dpi_scale;

            if let Some(thumb_node) = world.ui.get_ui_layout_node_mut(inputs.thumb_entity)
                && let Some(crate::ecs::ui::layout_types::UiLayoutType::Window(window)) =
                    thumb_node.base_layout.as_mut()
            {
                window.position = crate::ecs::ui::units::Ab(Vec2::new(0.0, thumb_y)).into();
                window.size = crate::ecs::ui::units::Ab(Vec2::new(8.0, thumb_height)).into();
            }
        }
    }

    ScrollFrameResult {
        scroll_offset,
        thumb_dragging,
        thumb_drag_start_offset,
        content_height: total_content_height,
        visible_height,
    }
}

pub(super) fn handle_scroll_area(
    world: &mut World,
    entity: freecs::Entity,
    data: &crate::ecs::ui::components::UiScrollAreaData,
    scroll_delta: Vec2,
    mouse_position: Vec2,
    dpi_scale: f32,
) {
    let result = apply_scroll_frame(
        world,
        ScrollFrameInputs {
            entity,
            content_entity: data.content_entity,
            thumb_entity: data.thumb_entity,
            track_entity: data.track_entity,
            scroll_offset: data.scroll_offset,
            thumb_dragging: data.thumb_dragging,
            thumb_drag_start_offset: data.thumb_drag_start_offset,
            snap_interval: data.snap_interval,
            scroll_delta,
            mouse_position,
            dpi_scale,
        },
    );

    if let Some(widget_data) = world.ui.get_ui_scroll_area_mut(entity) {
        widget_data.scroll_offset = result.scroll_offset;
        widget_data.content_height = result.content_height;
        widget_data.visible_height = result.visible_height;
        widget_data.thumb_dragging = result.thumb_dragging;
        widget_data.thumb_drag_start_offset = result.thumb_drag_start_offset;
    }
}

pub(super) fn handle_virtual_list(
    world: &mut World,
    entity: freecs::Entity,
    data: &crate::ecs::ui::components::UiVirtualListData,
    dpi_scale: f32,
    frame_keys: &[(KeyCode, bool)],
    focused_entity: Option<freecs::Entity>,
) {
    let scroll_offset = if let Some(scroll_data) = world.ui.get_ui_scroll_area(data.scroll_entity) {
        scroll_data.scroll_offset
    } else {
        0.0
    };

    let window = super::pool_window::compute(
        scroll_offset,
        data.item_height,
        data.total_items,
        data.pool_size,
    );
    let visible_start = window.visible_start;
    let top_height = window.top_height;
    let bottom_height = window.bottom_height;

    if let Some(top_node) = world.ui.get_ui_layout_node_mut(data.top_spacer) {
        top_node.flow_child_size = Some(
            crate::ecs::ui::units::Rl(Vec2::new(100.0, 0.0))
                + crate::ecs::ui::units::Ab(Vec2::new(0.0, top_height)),
        );
    }
    if let Some(bottom_node) = world.ui.get_ui_layout_node_mut(data.bottom_spacer) {
        bottom_node.flow_child_size = Some(
            crate::ecs::ui::units::Rl(Vec2::new(100.0, 0.0))
                + crate::ecs::ui::units::Ab(Vec2::new(0.0, bottom_height)),
        );
    }

    let mut selection = data.selection;
    let mut selection_changed = false;

    for (pool_index, item) in data.pool_items.iter().enumerate() {
        let item_index = visible_start + pool_index;
        let is_visible = item_index < data.total_items;

        if let Some(node) = world.ui.get_ui_layout_node_mut(item.container_entity) {
            node.visible = is_visible;
        }

        if is_visible {
            let interaction = snapshot_interaction(world, item.container_entity);
            if interaction.clicked {
                selection = Some(item_index);
                selection_changed = true;
                world.resources.retained_ui.events_for_active_mut().push(
                    crate::ecs::ui::resources::UiEvent::VirtualListItemClicked {
                        entity,
                        item_index,
                    },
                );
            }
            if interaction.right_clicked {
                let screen_position = world.resources.input.mouse.position;
                world.resources.retained_ui.events_for_active_mut().push(
                    crate::ecs::ui::resources::UiEvent::VirtualListItemRightClicked {
                        entity,
                        item_index,
                        screen_position,
                    },
                );
            }

            let accent_color = world
                .resources
                .retained_ui
                .theme_state
                .active_theme()
                .accent_color;
            let is_selected = selection == Some(item_index);
            if let Some(crate::ecs::ui::components::UiNodeContent::Rect {
                border_width,
                border_color,
                ..
            }) = world.ui.get_ui_node_content_mut(item.container_entity)
            {
                if is_selected {
                    *border_width = 1.0 / dpi_scale;
                    *border_color = accent_color;
                } else {
                    *border_width = 0.0;
                }
            }
        }
    }

    let is_focused = focused_entity == Some(entity)
        || data
            .pool_items
            .iter()
            .any(|item| focused_entity == Some(item.container_entity));
    if is_focused && data.total_items > 0 {
        for &(key, pressed) in frame_keys {
            if !pressed {
                continue;
            }
            match key {
                KeyCode::ArrowUp => {
                    let current = selection.unwrap_or(0);
                    if current > 0 {
                        selection = Some(current - 1);
                        selection_changed = true;
                    }
                }
                KeyCode::ArrowDown => {
                    let current = selection.map_or(0, |s| s + 1);
                    if current < data.total_items {
                        selection = Some(current);
                        selection_changed = true;
                    }
                }
                KeyCode::Home => {
                    selection = Some(0);
                    selection_changed = true;
                }
                KeyCode::End => {
                    selection = Some(data.total_items - 1);
                    selection_changed = true;
                }
                _ => {}
            }
        }
    }

    if let Some(widget_data) = world.ui.get_ui_virtual_list_mut(entity) {
        widget_data.visible_start = visible_start;
        widget_data.selection = selection;
        widget_data.selection_changed = selection_changed;
    }
}