jackdaw_feathers 0.3.0

Internal crate for the jackdaw editor
Documentation
use bevy::{feathers::theme::ThemedText, prelude::*, ui::ui_transform::UiGlobalTransform};
use jackdaw_widgets::menu_bar::{
    MenuAction, MenuBar, MenuBarDropdown, MenuBarDropdownItem, MenuBarItem, MenuBarState,
};

use crate::button::{ButtonClickEvent, ButtonProps, ButtonVariant, button};
use crate::tokens;

pub fn plugin(app: &mut App) {
    app.add_observer(on_dropdown_item_click)
        .add_observer(on_menu_bar_item_click)
        .add_observer(on_menu_bar_item_over)
        .add_observer(on_menu_bar_item_out);
}

/// When a dropdown item is clicked, fire the MenuAction.
fn on_dropdown_item_click(
    event: On<ButtonClickEvent>,
    items: Query<&MenuBarDropdownItem>,
    mut commands: Commands,
) {
    let Ok(item) = items.get(event.entity) else {
        return;
    };
    commands.trigger(MenuAction {
        action: item.action.clone(),
    });
}

/// Handle click on a MenuBarItem: find the item by walking up from the event target.
fn on_menu_bar_item_click(
    mut click: On<Pointer<Click>>,
    mut commands: Commands,
    mut state: ResMut<MenuBarState>,
    items: Query<(&MenuBarItem, &ComputedNode, &UiGlobalTransform)>,
    item_check: Query<Entity, With<MenuBarItem>>,
    parents: Query<&ChildOf>,
) {
    let Some(entity) = find_ancestor(click.event_target(), &item_check, &parents) else {
        return;
    };
    let Ok((item, computed, global_tf)) = items.get(entity) else {
        return;
    };

    click.propagate(false);

    // Close existing dropdown
    if let Some(dropdown) = state.dropdown_entity.take() {
        commands.entity(dropdown).despawn();
    }

    if state.open_menu == Some(entity) {
        // Toggle off
        state.open_menu = None;
        return;
    }

    // Open dropdown
    state.open_menu = Some(entity);

    let (_, _, pos) = global_tf.to_scale_angle_translation();
    let size = computed.size() * computed.inverse_scale_factor();
    let x = pos.x - size.x / 2.0;
    let y = pos.y + size.y / 2.0;

    let dropdown = spawn_dropdown(&mut commands, x, y, &item.actions);
    state.dropdown_entity = Some(dropdown);
}

fn on_menu_bar_item_over(
    hover: On<Pointer<Over>>,
    items: Query<Entity, With<MenuBarItem>>,
    parents: Query<&ChildOf>,
    mut bg_query: Query<&mut BackgroundColor>,
) {
    if let Some(entity) = find_ancestor(hover.event_target(), &items, &parents) {
        if let Ok(mut bg) = bg_query.get_mut(entity) {
            bg.0 = tokens::HOVER_BG;
        }
    }
}

fn on_menu_bar_item_out(
    out: On<Pointer<Out>>,
    items: Query<Entity, With<MenuBarItem>>,
    parents: Query<&ChildOf>,
    mut bg_query: Query<&mut BackgroundColor>,
) {
    if let Some(entity) = find_ancestor(out.event_target(), &items, &parents) {
        if let Ok(mut bg) = bg_query.get_mut(entity) {
            bg.0 = Color::NONE;
        }
    }
}

/// Walk up from `start` through ChildOf to find an entity with `MenuBarItem`.
fn find_ancestor(
    start: Entity,
    items: &Query<Entity, With<MenuBarItem>>,
    parents: &Query<&ChildOf>,
) -> Option<Entity> {
    let mut entity = start;
    for _ in 0..10 {
        if items.contains(entity) {
            return Some(entity);
        }
        if let Ok(child_of) = parents.get(entity) {
            entity = child_of.parent();
        } else {
            return None;
        }
    }
    None
}

/// Marker for the menu bar root so we can find and populate it.
#[derive(Component)]
pub struct MenuBarRoot;

/// Build the styled menu bar shell. Items are spawned by `populate_menu_bar` system.
pub fn menu_bar_shell() -> impl Bundle {
    (
        MenuBarRoot,
        MenuBar,
        Node {
            flex_direction: FlexDirection::Row,
            align_items: AlignItems::Center,
            width: Val::Percent(100.0),
            height: Val::Px(tokens::MENU_BAR_HEIGHT),
            flex_shrink: 0.0,
            padding: UiRect::horizontal(Val::Px(tokens::SPACING_SM)),
            ..Default::default()
        },
        BackgroundColor(tokens::WINDOW_BG),
    )
}

/// Populate a menu bar entity with items. Call from the app layer after spawning the shell.
pub fn populate_menu_bar(
    world: &mut World,
    menu_bar_entity: Entity,
    menus: Vec<(&str, Vec<(&str, &str)>)>,
) {
    for (label, actions) in menus {
        let actions_owned: Vec<(String, String)> = actions
            .into_iter()
            .map(|(a, l)| (a.to_string(), l.to_string()))
            .collect();

        spawn_menu_bar_item(world, menu_bar_entity, label, actions_owned);
    }
}

fn spawn_menu_bar_item(
    world: &mut World,
    parent: Entity,
    label: &str,
    actions: Vec<(String, String)>,
) {
    world.spawn((
        MenuBarItem {
            label: label.to_string(),
            actions,
        },
        Node {
            padding: UiRect::axes(Val::Px(tokens::SPACING_MD), Val::Px(tokens::SPACING_XS)),
            border_radius: BorderRadius::all(Val::Px(tokens::BORDER_RADIUS_SM)),
            ..Default::default()
        },
        BackgroundColor(Color::NONE),
        children![(
            Text::new(label),
            TextFont {
                font_size: tokens::FONT_MD,
                ..Default::default()
            },
            ThemedText,
        )],
        ChildOf(parent),
    ));
}

fn spawn_dropdown(commands: &mut Commands, x: f32, y: f32, actions: &[(String, String)]) -> Entity {
    let dropdown = commands
        .spawn((
            MenuBarDropdown,
            Node {
                position_type: PositionType::Absolute,
                left: Val::Px(x),
                top: Val::Px(y),
                flex_direction: FlexDirection::Column,
                min_width: Val::Px(180.0),
                padding: UiRect::axes(Val::Px(tokens::SPACING_XS), Val::Px(tokens::SPACING_SM)),
                border: UiRect::all(Val::Px(1.0)),
                border_radius: BorderRadius::all(Val::Px(tokens::BORDER_RADIUS_MD)),
                ..Default::default()
            },
            BackgroundColor(tokens::MENU_BG),
            BorderColor::all(tokens::BORDER_SUBTLE),
            ZIndex(1000),
        ))
        .id();

    for (action, label) in actions {
        if action == "---" {
            // Separator
            commands.spawn((
                Node {
                    width: Val::Percent(100.0),
                    height: Val::Px(1.0),
                    margin: UiRect::axes(Val::Px(0.0), Val::Px(tokens::SPACING_XS)),
                    ..Default::default()
                },
                BackgroundColor(tokens::BORDER_SUBTLE),
                ChildOf(dropdown),
            ));
            continue;
        }

        commands.entity(dropdown).with_child((
            MenuBarDropdownItem {
                action: action.clone(),
            },
            button(
                ButtonProps::new(label.clone())
                    .with_variant(ButtonVariant::Ghost)
                    .align_left(),
            ),
        ));
    }

    dropdown
}