beuvy-runtime 0.1.0

A low-level Bevy UI kit with reusable controls and utility-class styling.
Documentation
use crate::focus::UiFocused;
use crate::input::{
    DisabledInput, InputCaret, InputCursorPosition, InputField, InputScrollOffset, InputSelection,
    InputSelectionSegment, InputTextEngine, InputType, SelectionSegmentPool,
};
use crate::style::{input_caret_width, input_selection_color};
use bevy::math::Rect;
use bevy::picking::Pickable;
use bevy::prelude::*;
use bevy::text::ComputedTextBlock;
use bevy::window::PrimaryWindow;

pub(crate) fn sync_input_focus_visuals(
    mut commands: Commands,
    input_focus: Res<bevy::input_focus::InputFocus>,
    fields: Query<(Entity, Option<&UiGlobalTransform>, Option<&ComputedNode>), With<InputField>>,
    added_fields: Query<(), Added<InputField>>,
    focused: Query<Entity, With<UiFocused>>,
    primary_window: Query<Entity, With<PrimaryWindow>>,
) {
    if !input_focus.is_changed() && added_fields.is_empty() {
        return;
    }

    let focus_target = input_focus.get();

    for (entity, transform, computed) in &fields {
        let should_focus = focus_target == Some(entity);
        let is_focused = focused.contains(entity);

        if should_focus == is_focused {
            continue;
        }

        let Ok(mut entity_commands) = commands.get_entity(entity) else {
            continue;
        };

        if should_focus {
            entity_commands.try_insert(UiFocused);
            if let (Some(transform), Some(computed)) = (transform, computed) {
                let (_, _, center) = transform.to_scale_angle_translation();
                let rect = Rect::from_center_size(center, computed.size());
                let inverse = computed.inverse_scale_factor();
                let content_inset = computed.content_inset();
                let content_left = rect.min.x + content_inset.min_inset.x;
                entity_commands.try_insert(InputCursorPosition {
                    x: content_left * inverse,
                    y: rect.center().y * inverse,
                });
            }
        } else {
            entity_commands.try_remove::<UiFocused>();
        }
    }

    let primary_window = primary_window.iter().next();
    if focus_target.is_some() && focus_target == primary_window {
        for entity in &focused {
            let Ok(mut entity_commands) = commands.get_entity(entity) else {
                continue;
            };
            entity_commands.try_remove::<UiFocused>();
        }
    }
}

pub(crate) fn sync_input_edit_visuals(
    mut commands: Commands,
    time: Res<Time>,
    text_engine: Res<InputTextEngine>,
    fields: Query<(Entity, &InputField, Has<DisabledInput>, Has<UiFocused>), With<InputField>>,
    text_nodes: Query<&ComputedTextBlock, With<crate::input::InputText>>,
    viewports: Query<&ComputedNode, With<crate::input::InputViewport>>,
    mut visuals: ParamSet<(
        Query<(&mut InputScrollOffset, &mut Node)>,
        Query<(&mut Node, &mut Visibility), With<InputSelection>>,
        Query<(&mut Node, &mut Visibility), With<InputCaret>>,
        Query<(&ChildOf, &mut Node, &mut Visibility), With<InputSelectionSegment>>,
    )>,
    mut segment_pool: ResMut<SelectionSegmentPool>,
) {
    for (entity, field, disabled, focused) in &fields {
        if matches!(field.input_type, InputType::Checkbox | InputType::Radio) {
            if focused && !disabled {
                if let Ok(mut entity_commands) = commands.get_entity(entity) {
                    entity_commands.try_insert(InputCursorPosition { x: 9.0, y: 9.0 });
                }
            }
            continue;
        }
        if matches!(field.input_type, InputType::Range) {
            continue;
        }
        let (Some(viewport_entity), Some(text_entity)) = (field.viewport_entity, field.text_entity)
        else {
            continue;
        };
        let Ok(block) = text_nodes.get(text_entity) else {
            continue;
        };
        let Ok(viewport_computed) = viewports.get(viewport_entity) else {
            continue;
        };

        let caret_width = input_caret_width();
        let inverse_scale_factor = viewport_computed.inverse_scale_factor();
        let viewport_size = viewport_computed.size() * inverse_scale_factor;
        let viewport_width = viewport_size.x.max(0.0);
        let viewport_height = viewport_size.y.max(0.0);
        let text_layout = text_engine.layout(block, inverse_scale_factor);
        let text_size = text_layout.size();
        let caret_byte = field.edit_state.display_caret_byte();
        let caret_rect = text_layout.caret_rect(caret_byte);
        let raw_caret_x = caret_rect.x;
        let caret_line_top = caret_rect.top;
        let caret_line_height = caret_rect.height;
        let content_top = if field.is_multiline() {
            0.0
        } else {
            ((viewport_height - text_size.y) * 0.5).max(0.0)
        };

        let mut scroll_x = 0.0;
        let mut scroll_y = 0.0;
        if let Ok((mut scroll_offset, mut text_node)) = visuals.p0().get_mut(text_entity) {
            if focused && !disabled {
                let max_offset_x = (text_size.x - viewport_width).max(0.0);
                if raw_caret_x < scroll_offset.x {
                    scroll_offset.x = raw_caret_x.max(0.0);
                } else if raw_caret_x > scroll_offset.x + viewport_width {
                    scroll_offset.x = (raw_caret_x - viewport_width).max(0.0);
                }
                scroll_offset.x = scroll_offset.x.min(max_offset_x);
                if field.is_multiline() {
                    let max_offset_y = (text_size.y - viewport_height).max(0.0);
                    if caret_line_top < scroll_offset.y {
                        scroll_offset.y = caret_line_top.max(0.0);
                    } else if caret_line_top + caret_line_height > scroll_offset.y + viewport_height
                    {
                        scroll_offset.y =
                            (caret_line_top + caret_line_height - viewport_height).max(0.0);
                    }
                    scroll_offset.y = scroll_offset.y.min(max_offset_y);
                }
            }
            scroll_x = scroll_offset.x;
            scroll_y = scroll_offset.y;
            text_node.left = Val::Px(-scroll_x);
            text_node.top = Val::Px(content_top - scroll_y);
        }
        let scrolled_caret =
            crate::input::text_engine::scroll_caret_rect(caret_rect, Vec2::new(scroll_x, scroll_y));
        let caret_x = scrolled_caret.x;
        let caret_top = content_top + scrolled_caret.top;
        let caret_center_y = caret_top + caret_line_height * 0.5;

        if let Some(selection_entity) = field.selection_entity {
            if let Ok((mut node, mut visibility)) = visuals.p1().get_mut(selection_entity) {
                if let Some(range) = field.edit_state.selection_range() {
                    let raw_rects = text_layout.selection_rects(range.start, range.end);
                    let rects = crate::input::text_engine::scroll_selection_rects(
                        &raw_rects,
                        Vec2::new(scroll_x, scroll_y),
                    );
                    node.left = Val::Px(0.0);
                    node.top = Val::Px(0.0);
                    node.width = Val::Px(0.0);
                    node.height = Val::Px(0.0);
                    *visibility = if rects.is_empty() {
                        Visibility::Hidden
                    } else {
                        Visibility::Visible
                    };

                    let needed = rects.len();
                    segment_pool.max_needed = segment_pool.max_needed.max(needed);

                    let mut assigned = 0usize;
                    let mut segment_count = 0usize;
                    for (parent, mut segment_node, mut segment_visibility) in &mut visuals.p3() {
                        if parent.parent() != selection_entity {
                            continue;
                        }
                        segment_count += 1;
                        if let Some((left, top, width, height)) = rects.get(assigned).copied() {
                            segment_node.left = Val::Px(left);
                            segment_node.top = Val::Px(content_top + top);
                            segment_node.width = Val::Px(width);
                            segment_node.height = Val::Px(height.max(0.0));
                            *segment_visibility = if width > 0.0 {
                                Visibility::Visible
                            } else {
                                Visibility::Hidden
                            };
                            assigned += 1;
                        } else {
                            *segment_visibility = Visibility::Hidden;
                        }
                    }
                    segment_pool.available = segment_count;

                    if needed > segment_count {
                        let selection_color = input_selection_color();
                        let spawn_count = needed - segment_count;
                        if let Ok(mut entity_commands) = commands.get_entity(selection_entity) {
                            for _ in 0..spawn_count {
                                entity_commands.with_child((
                                    InputSelectionSegment,
                                    Pickable::IGNORE,
                                    Visibility::Hidden,
                                    Node {
                                        position_type: PositionType::Absolute,
                                        width: Val::Px(0.0),
                                        height: Val::Px(0.0),
                                        ..default()
                                    },
                                    BackgroundColor(selection_color),
                                ));
                            }
                            segment_pool.available += spawn_count;
                        }
                    }
                } else {
                    *visibility = Visibility::Hidden;
                    for (_parent, _, mut segment_visibility) in &mut visuals.p3() {
                        if _parent.parent() == selection_entity {
                            *segment_visibility = Visibility::Hidden;
                        }
                    }
                }
            }
        }

        if let Some(caret_entity) = field.caret_entity {
            if let Ok((mut node, mut visibility)) = visuals.p2().get_mut(caret_entity) {
                node.left = Val::Px((caret_x - caret_width * 0.5).max(0.0));
                node.top = Val::Px(caret_top);
                node.width = Val::Px(caret_width);
                node.height = Val::Px(caret_line_height.max(0.0));
                let blink_visible = time.elapsed_secs_f64() < field.caret_blink_resume_at
                    || (time.elapsed_secs_f64() * 2.0).floor() as i64 % 2 == 0;
                *visibility = if focused && !disabled && blink_visible {
                    Visibility::Visible
                } else {
                    Visibility::Hidden
                };
            }
        }

        if focused && !disabled {
            if let Ok(mut entity_commands) = commands.get_entity(entity) {
                entity_commands.try_insert(InputCursorPosition {
                    x: caret_x.max(0.0),
                    y: caret_center_y.max(0.0),
                });
            }
        }
    }
}