beuvy-runtime 0.1.0

A low-level Bevy UI kit with reusable controls and utility-class styling.
Documentation
use super::metrics::node_logical_rect;
use super::{keep_caret_visible, shift_pressed};
use crate::input::{
    DisabledInput, InputClickState, InputField, InputScrollOffset, InputTextEngine,
    InputValueChangedMessage, clear_input_focus, push_value_changed, set_input_focus,
};
use crate::text::FontResource;
use bevy::input::{ButtonInput, keyboard::KeyCode};
use bevy::input_focus::InputFocus;
use bevy::picking::pointer::PointerButton;
use bevy::prelude::*;
use bevy::text::ComputedTextBlock;

const DRAG_SELECT_THRESHOLD: f32 = 3.0;

pub(crate) fn input_click(
    mut event: On<Pointer<Click>>,
    mut input_focus: ResMut<InputFocus>,
    keys: Res<ButtonInput<KeyCode>>,
    time: Res<Time>,
    text_engine: Res<InputTextEngine>,
    mut value_changed: MessageWriter<InputValueChangedMessage>,
    mut fields: Query<
        (&mut InputField, &mut InputClickState),
        (With<InputField>, Without<DisabledInput>),
    >,
    text_blocks: Query<(&ComputedTextBlock, &InputScrollOffset), With<crate::input::InputText>>,
    viewports: Query<(&ComputedNode, &UiGlobalTransform), With<crate::input::InputViewport>>,
) {
    if event.button != PointerButton::Primary {
        return;
    }
    let Ok((mut field, mut click_state)) = fields.get_mut(event.entity) else {
        return;
    };

    set_input_focus(&mut input_focus, event.entity);
    if field.is_toggle() {
        match field.input_type {
            crate::input::InputType::Checkbox => field.checked = !field.checked,
            crate::input::InputType::Radio => field.checked = true,
            _ => {}
        }
        push_value_changed(&mut value_changed, event.entity, &field);
        event.propagate(false);
        return;
    }

    let (Some(viewport_entity), Some(text_entity)) = (field.viewport_entity, field.text_entity)
    else {
        return;
    };
    let Ok((block, scroll_offset)) = text_blocks.get(text_entity) else {
        return;
    };
    let Ok((computed, transform)) = viewports.get(viewport_entity) else {
        return;
    };
    let inverse_scale_factor = computed.inverse_scale_factor();

    let now = time.elapsed_secs_f64();
    let is_chained_click = now - click_state.last_click_time <= 0.5;
    click_state.click_count = if is_chained_click {
        click_state.click_count.saturating_add(1).min(3)
    } else {
        1
    };
    click_state.last_click_time = now;

    let logical_rect = node_logical_rect(computed, transform);
    let local_x = (event.pointer_location.position.x - logical_rect.min.x).max(0.0);
    let local_y = (event.pointer_location.position.y - logical_rect.min.y).max(0.0);
    let click_position = event.pointer_location.position;
    let is_same_click_spot = click_state
        .last_click_position
        .is_some_and(|last| last.distance(click_position) <= DRAG_SELECT_THRESHOLD);
    let byte = text_engine.hit_byte(
        block,
        inverse_scale_factor,
        local_x + scroll_offset.x,
        local_y + scroll_offset.y,
    );
    match click_state.click_count {
        1 => field.edit_state.set_caret(byte, shift_pressed(&keys)),
        2 => {
            if is_same_click_spot {
                field.edit_state.select_word_at(byte);
            } else {
                click_state.click_count = 1;
                field.edit_state.set_caret(byte, shift_pressed(&keys));
            }
        }
        _ => {
            if is_same_click_spot {
                field.edit_state.select_all();
            } else {
                click_state.click_count = 1;
                field.edit_state.set_caret(byte, shift_pressed(&keys));
            }
        }
    }
    click_state.last_click_position = Some(click_position);
    keep_caret_visible(&mut field, &time);
    event.propagate(false);
}

pub(crate) fn input_drag_start(
    mut event: On<Pointer<DragStart>>,
    mut input_focus: ResMut<InputFocus>,
    time: Res<Time>,
    text_engine: Res<InputTextEngine>,
    mut fields: Query<&mut InputField, (With<InputField>, Without<DisabledInput>)>,
    text_blocks: Query<(&ComputedTextBlock, &InputScrollOffset), With<crate::input::InputText>>,
    viewports: Query<(&ComputedNode, &UiGlobalTransform), With<crate::input::InputViewport>>,
) {
    if event.button != PointerButton::Primary {
        return;
    }
    let Ok(mut field) = fields.get_mut(event.entity) else {
        return;
    };
    set_input_focus(&mut input_focus, event.entity);
    let (Some(viewport_entity), Some(text_entity)) = (field.viewport_entity, field.text_entity)
    else {
        return;
    };
    let Ok((block, scroll_offset)) = text_blocks.get(text_entity) else {
        return;
    };
    let Ok((computed, transform)) = viewports.get(viewport_entity) else {
        return;
    };
    let inverse_scale_factor = computed.inverse_scale_factor();

    let logical_rect = node_logical_rect(computed, transform);
    let local_x = (event.pointer_location.position.x - logical_rect.min.x).max(0.0);
    let local_y = (event.pointer_location.position.y - logical_rect.min.y).max(0.0);
    let byte = text_engine.hit_byte(
        block,
        inverse_scale_factor,
        local_x + scroll_offset.x,
        local_y + scroll_offset.y,
    );
    field.edit_state.set_caret(byte, false);
    field.preferred_caret_x = Some(local_x);
    keep_caret_visible(&mut field, &time);
    event.propagate(false);
}

pub(crate) fn input_drag(
    mut event: On<Pointer<Drag>>,
    time: Res<Time>,
    text_engine: Res<InputTextEngine>,
    mut fields: Query<&mut InputField, (With<InputField>, Without<DisabledInput>)>,
    text_blocks: Query<(&ComputedTextBlock, &InputScrollOffset), With<crate::input::InputText>>,
    viewports: Query<(&ComputedNode, &UiGlobalTransform), With<crate::input::InputViewport>>,
) {
    if event.button != PointerButton::Primary || event.distance.length() < DRAG_SELECT_THRESHOLD {
        return;
    }
    let Ok(mut field) = fields.get_mut(event.entity) else {
        return;
    };
    let (Some(viewport_entity), Some(text_entity)) = (field.viewport_entity, field.text_entity)
    else {
        return;
    };
    let Ok((block, scroll_offset)) = text_blocks.get(text_entity) else {
        return;
    };
    let Ok((computed, transform)) = viewports.get(viewport_entity) else {
        return;
    };
    let inverse_scale_factor = computed.inverse_scale_factor();

    let logical_rect = node_logical_rect(computed, transform);
    let local_x = (event.pointer_location.position.x - logical_rect.min.x).max(0.0);
    let local_y = (event.pointer_location.position.y - logical_rect.min.y).max(0.0);
    let byte = text_engine.hit_byte(
        block,
        inverse_scale_factor,
        local_x + scroll_offset.x,
        local_y + scroll_offset.y,
    );
    field.edit_state.set_caret(byte, true);
    field.preferred_caret_x = Some(local_x);
    keep_caret_visible(&mut field, &time);
    event.propagate(false);
}

pub(crate) fn input_drag_end(mut event: On<Pointer<DragEnd>>) {
    if event.button != PointerButton::Primary {
        return;
    }
    event.propagate(false);
}

pub(crate) fn clear_input_focus_on_foreign_click(
    mut commands: Commands,
    mut pointer_clicks: MessageReader<Pointer<Click>>,
    mut fields: Query<(&mut InputField, Has<DisabledInput>)>,
    mut input_focus: ResMut<InputFocus>,
    font_resource: Res<FontResource>,
    mut value_changed: MessageWriter<InputValueChangedMessage>,
) {
    let Some(active) = input_focus.get().filter(|entity| fields.contains(*entity)) else {
        return;
    };

    for click in pointer_clicks.read() {
        if click.entity == active {
            return;
        }
        if let Ok((mut field, disabled)) = fields.get_mut(active) {
            if super::commit_numeric_field(active, &mut field, &mut value_changed) {
                crate::input::text::update_input_text(
                    &mut commands,
                    &font_resource,
                    &field,
                    disabled,
                );
            }
        }
        clear_input_focus(&mut input_focus);
        break;
    }
}