Skip to content
Snippets Groups Projects
text_box.rs 17.7 KiB
Newer Older
use instant::Instant;

use bevy::prelude::*;
use kayak_font::{KayakFont, TextProperties};
use kayak_ui_macros::{constructor, rsx};
Ygg01's avatar
Ygg01 committed
use crate::{
    context::WidgetName,
StarToaster's avatar
StarToaster committed
    event::{Event, EventType},
    event_dispatcher::EventDispatcherContext,
    on_event::OnEvent,
    on_layout::OnLayout,
    prelude::{KChildren, KayakWidgetContext, OnChange},
    render::font::FontMapping,
    styles::{ComputedStyles, Edge, KPositionType, KStyle, RenderCommand, StyleProp, Units},
    widget::Widget,
    widget_state::WidgetState,
StarToaster's avatar
StarToaster committed
    widgets::{
        text::{TextProps, TextWidgetBundle},
        BackgroundBundle, ClipBundle,
Ygg01's avatar
Ygg01 committed
    },
    Focusable, DEFAULT_FONT,
Ygg01's avatar
Ygg01 committed
};
StarArawn's avatar
StarArawn committed

StarToaster's avatar
StarToaster committed
use super::ElementBundle;

MrGVSV's avatar
MrGVSV committed
/// Props used by the [`TextBox`] widget
#[derive(Component, PartialEq, Eq, Default, Debug, Clone)]
pub struct TextBoxProps {
MrGVSV's avatar
MrGVSV committed
    /// If true, prevents the widget from being focused (and consequently edited)
MrGVSV's avatar
MrGVSV committed
    pub disabled: bool,
MrGVSV's avatar
MrGVSV committed
    /// The text to display when the user input is empty
    pub placeholder: Option<String>,
MrGVSV's avatar
MrGVSV committed
    /// The user input
    ///
    /// This is a controlled state. You _must_ set this to the value to you wish to be displayed.
    /// You can use the [`on_change`] callback to update this prop as the user types.
    pub value: String,
#[derive(Component, Clone, PartialEq)]
StarToaster's avatar
StarToaster committed
pub struct TextBoxState {
    pub focused: bool,
    pub graphemes: Vec<String>,
    pub cursor_x: f32,
    pub cursor_position: usize,
    pub cursor_visible: bool,
    pub cursor_last_update: Instant,
StarToaster's avatar
StarToaster committed
    pub current_value: String,
}

impl Default for TextBoxState {
    fn default() -> Self {
        Self {
            focused: Default::default(),
            graphemes: Default::default(),
            cursor_x: 0.0,
            cursor_position: Default::default(),
            cursor_visible: Default::default(),
            cursor_last_update: Instant::now(),
StarToaster's avatar
StarToaster committed
            current_value: String::new(),
StarToaster's avatar
StarToaster committed
pub struct TextBoxValue(pub String);

impl Widget for TextBoxProps {}
MrGVSV's avatar
MrGVSV committed
/// A widget that displays a text input field
/// A text box allows users to input text.
/// This text box is fairly simple and only supports basic input.
MrGVSV's avatar
MrGVSV committed
///
StarToaster's avatar
StarToaster committed
#[derive(Bundle)]
pub struct TextBoxBundle {
    pub text_box: TextBoxProps,
    pub styles: KStyle,
    pub computed_styles: ComputedStyles,
StarToaster's avatar
StarToaster committed
    pub on_event: OnEvent,
    pub on_layout: OnLayout,
    pub on_change: OnChange,
    pub focusable: Focusable,
    pub widget_name: WidgetName,
}

impl Default for TextBoxBundle {
    fn default() -> Self {
        Self {
            text_box: Default::default(),
            styles: Default::default(),
            computed_styles: ComputedStyles::default(),
StarToaster's avatar
StarToaster committed
            on_event: Default::default(),
            on_layout: Default::default(),
            on_change: Default::default(),
            focusable: Default::default(),
            widget_name: TextBoxProps::default().get_name(),
        }
    }
}

pub fn text_box_render(
    In((widget_context, entity)): In<(KayakWidgetContext, Entity)>,
StarToaster's avatar
StarToaster committed
    mut commands: Commands,
    mut query: Query<(
        &KStyle,
        &mut ComputedStyles,
        &TextBoxProps,
        &mut OnEvent,
        &OnChange,
    )>,
StarToaster's avatar
StarToaster committed
    mut state_query: ParamSet<(Query<&TextBoxState>, Query<&mut TextBoxState>)>,
    font_assets: Res<Assets<KayakFont>>,
    font_mapping: Res<FontMapping>,
StarToaster's avatar
StarToaster committed
) -> bool {
    if let Ok((styles, mut computed_styles, text_box, mut on_event, on_change)) =
        query.get_mut(entity)
    {
        let state_entity = widget_context.use_state::<TextBoxState>(
            &mut commands,
            entity,
            TextBoxState {
StarToaster's avatar
StarToaster committed
                current_value: text_box.value.clone(),
                ..TextBoxState::default()
            },
StarToaster's avatar
StarToaster committed
        let mut is_different = false;
        if let Ok(state) = state_query.p0().get(state_entity) {
            if state.current_value != text_box.value {
                is_different = true;
            }
        }

        let style_font = styles.font.clone();

StarToaster's avatar
StarToaster committed
        if is_different {
            if let Ok(mut state) = state_query.p1().get_mut(state_entity) {
                state.current_value = text_box.value.clone();
                // Update graphemes
                set_graphemes(&mut state, &font_assets, &font_mapping, &style_font);

                state.cursor_position = state.graphemes.len();

John Mitchell's avatar
John Mitchell committed
                set_new_cursor_position(&mut state, &font_assets, &font_mapping, &style_font);
StarToaster's avatar
StarToaster committed
            }
        }

        if let Ok(state) = state_query.p0().get(state_entity) {
            *computed_styles = KStyle::default()
                // Required styles
                .with_style(KStyle {
                    render_command: RenderCommand::Layout.into(),
                    ..Default::default()
                })
                // Apply any prop-given styles
                .with_style(styles)
                // If not set by props, apply these styles
                .with_style(KStyle {
                    top: Units::Pixels(0.0).into(),
                    bottom: Units::Pixels(0.0).into(),
                    height: Units::Pixels(26.0).into(),
                    // cursor: CursorIcon::Text.into(),
                    ..Default::default()

            let background_styles = KStyle {
                render_command: StyleProp::Value(RenderCommand::Quad),
                background_color: Color::rgba(0.160, 0.172, 0.235, 1.0).into(),
                border_color: if state.focused {
                    Color::rgba(0.933, 0.745, 0.745, 1.0).into()
                } else {
                    Color::rgba(0.360, 0.380, 0.474, 1.0).into()
                },
                border: Edge::new(0.0, 0.0, 0.0, 2.0).into(),
MrGVSV's avatar
MrGVSV committed
                height: Units::Pixels(26.0).into(),
                padding_left: Units::Pixels(5.0).into(),
                padding_right: Units::Pixels(5.0).into(),
MrGVSV's avatar
MrGVSV committed
                ..Default::default()
            let cloned_on_change = on_change.clone();
            *on_event = OnEvent::new(
                move |In((event_dispatcher_context, _, mut event, _entity)): In<(
                    EventDispatcherContext,
                    WidgetState,
                    Event,
                    Entity,
                )>,
                      font_assets: Res<Assets<KayakFont>>,
                      font_mapping: Res<FontMapping>,
                      mut state_query: Query<&mut TextBoxState>| {
                    match event.event_type {
                        EventType::KeyDown(key_event) => {
                            if key_event.key() == KeyCode::Right {
                                if let Ok(mut state) = state_query.get_mut(state_entity) {
                                    if state.cursor_position < state.graphemes.len() {
                                        state.cursor_position += 1;
                                    }
                                    set_new_cursor_position(
                                        &mut state,
                                        &font_assets,
                                        &font_mapping,
                                        &style_font,
                                    );
                                }
                            }
                            if key_event.key() == KeyCode::Left {
                                if let Ok(mut state) = state_query.get_mut(state_entity) {
                                    if state.cursor_position > 0 {
                                        state.cursor_position -= 1;
                                    }
                                    set_new_cursor_position(
                                        &mut state,
                                        &font_assets,
                                        &font_mapping,
                                        &style_font,
                                    );
                                }
                            }
                        }
                        EventType::CharInput { c } => {
StarToaster's avatar
StarToaster committed
                            if let Ok(mut state) = state_query.get_mut(state_entity) {
                                let cloned_on_change = cloned_on_change.clone();
                                if !state.focused {
                                    return (event_dispatcher_context, event);
                                }
StarToaster's avatar
StarToaster committed
                                let cursor_pos = state.cursor_position;
                                if is_backspace(c) {
                                    if !state.current_value.is_empty() {
John Mitchell's avatar
John Mitchell committed
                                        let char_pos: usize = state.graphemes[0..cursor_pos - 1]
                                            .iter()
                                            .map(|g| g.len())
                                            .sum();
                                        state.current_value.remove(char_pos);
                                        state.cursor_position -= 1;
                                    }
StarToaster's avatar
StarToaster committed
                                } else if !c.is_control() {
John Mitchell's avatar
John Mitchell committed
                                    let char_pos: usize = state.graphemes[0..cursor_pos]
                                        .iter()
                                        .map(|g| g.len())
                                        .sum();
                                    state.current_value.insert(char_pos, c);

                                    state.cursor_position += 1;
                                }

                                // Update graphemes
StarToaster's avatar
StarToaster committed
                                set_graphemes(&mut state, &font_assets, &font_mapping, &style_font);

                                set_new_cursor_position(
                                    &mut state,
                                    &font_assets,
                                    &font_mapping,
                                    &style_font,
                                );
StarToaster's avatar
StarToaster committed
                                cloned_on_change.set_value(state.current_value.clone());
                                event.add_system(cloned_on_change);
                        EventType::Focus => {
                            if let Ok(mut state) = state_query.get_mut(state_entity) {
                                state.focused = true;
                                // Update graphemes
StarToaster's avatar
StarToaster committed
                                set_graphemes(&mut state, &font_assets, &font_mapping, &style_font);

                                state.cursor_position = state.graphemes.len();

                                set_new_cursor_position(
                                    &mut state,
                                    &font_assets,
                                    &font_mapping,
                                    &style_font,
                                );
StarToaster's avatar
StarToaster committed
                        }
                        EventType::Blur => {
                            if let Ok(mut state) = state_query.get_mut(state_entity) {
                                state.focused = false;
                            }
                    (event_dispatcher_context, event)
                },
            );
            let cursor_styles = KStyle {
                background_color: Color::rgba(0.933, 0.745, 0.745, 1.0).into(),
StarToaster's avatar
StarToaster committed
                position_type: KPositionType::SelfDirected.into(),
                top: Units::Pixels(5.0).into(),
                left: Units::Pixels(state.cursor_x).into(),
                width: Units::Pixels(2.0).into(),
                height: Units::Pixels(26.0 - 10.0).into(),
                ..Default::default()
            };

StarToaster's avatar
StarToaster committed
            let text_styles = KStyle {
                top: Units::Stretch(1.0).into(),
                bottom: Units::Stretch(1.0).into(),
                ..Default::default()
            };

            let shift = if let Some(layout) = widget_context.get_layout(entity) {
                let font_handle = match &styles.font {
                    StyleProp::Value(font) => font_mapping.get_handle(font.clone()).unwrap(),
                    _ => font_mapping.get_handle(DEFAULT_FONT.into()).unwrap(),
                };
                if let Some(font) = font_assets.get(&font_handle) {
                    let string_to_cursor = state.graphemes[0..state.cursor_position].join("");
                    let measurement = font.measure(
                        &string_to_cursor,
                        TextProperties {
                            font_size: 14.0,
                            line_height: 18.0,
                            max_size: (10000.0, 18.0),
                            alignment: kayak_font::Alignment::Start,
                            tab_size: 4,
                        },
                    );
                    if measurement.size().0 > layout.width {
                        (layout.width - measurement.size().0) - 20.0
                    } else {
                        0.0
                    }
                } else {
                    0.0
                }
            } else {
                0.0
            };

            let scroll_styles = KStyle {
                position_type: KPositionType::SelfDirected.into(),
                padding_left: StyleProp::Value(Units::Stretch(0.0)),
                padding_right: StyleProp::Value(Units::Stretch(0.0)),
                padding_bottom: StyleProp::Value(Units::Stretch(1.0)),
                padding_top: StyleProp::Value(Units::Stretch(1.0)),
                left: Units::Pixels(shift).into(),
                ..Default::default()
            };

            let parent_id = Some(entity);
            rsx! {
                <BackgroundBundle styles={background_styles}>
                    <ClipBundle styles={KStyle {
                        height: Units::Pixels(26.0).into(),
                        padding_left: StyleProp::Value(Units::Stretch(0.0)),
                        padding_right: StyleProp::Value(Units::Stretch(0.0)),
                        ..Default::default()
                    }}>
StarToaster's avatar
StarToaster committed
                        <ElementBundle styles={scroll_styles}>
                            <TextWidgetBundle
                                styles={text_styles}
StarToaster's avatar
StarToaster committed
                                text={TextProps {
                                    content: text_box.value.clone(),
                                    size: 14.0,
                                    line_height: Some(18.0),
                                    word_wrap: false,
                                    ..Default::default()
StarToaster's avatar
StarToaster committed
                                }}
                            />
                            {
                                if state.focused && state.cursor_visible {
                                    constructor! {
                                        <BackgroundBundle styles={cursor_styles} />
                                    }
StarToaster's avatar
StarToaster committed
                        </ElementBundle>
                    </ClipBundle>
                </BackgroundBundle>
StarArawn's avatar
StarArawn committed
        }
    }
StarArawn's avatar
StarArawn committed
}

/// Checks if the given character contains the "Backspace" sequence
///
/// Context: [Wikipedia](https://en.wikipedia.org/wiki/Backspace#Common_use)
fn is_backspace(c: char) -> bool {
    c == '\u{8}' || c == '\u{7f}'

fn set_graphemes(
    state: &mut TextBoxState,
    font_assets: &Res<Assets<KayakFont>>,
    font_mapping: &FontMapping,
    style_font: &StyleProp<String>,
) {
    let font_handle = match style_font {
        StyleProp::Value(font) => font_mapping.get_handle(font.clone()).unwrap(),
        _ => font_mapping.get_handle(DEFAULT_FONT.into()).unwrap(),
    };

    if let Some(font) = font_assets.get(&font_handle) {
        state.graphemes = font
StarToaster's avatar
StarToaster committed
            .get_graphemes(&state.current_value)
            .iter()
            .map(|s| s.to_string())
            .collect::<Vec<_>>();
    }
}

fn get_single_grapheme_length(
    font_assets: &Res<Assets<KayakFont>>,
    font_mapping: &FontMapping,
    style_font: &StyleProp<String>,
    text: &String,
) -> usize {
    let font_handle = match style_font {
        StyleProp::Value(font) => font_mapping.get_handle(font.clone()).unwrap(),
        _ => font_mapping.get_handle(DEFAULT_FONT.into()).unwrap(),
    };

    if let Some(font) = font_assets.get(&font_handle) {
        let graphemes = font.get_graphemes(&text);
        return graphemes[0].len();
    }

    0
}

fn set_new_cursor_position(
    state: &mut TextBoxState,
    font_assets: &Res<Assets<KayakFont>>,
    font_mapping: &FontMapping,
    style_font: &StyleProp<String>,
) {
    let font_handle = match style_font {
        StyleProp::Value(font) => font_mapping.get_handle(font.clone()).unwrap(),
        _ => font_mapping.get_handle(DEFAULT_FONT.into()).unwrap(),
    };

    if let Some(font) = font_assets.get(&font_handle) {
        let string_to_cursor = state.graphemes[0..state.cursor_position].join("");
        let measurement = font.measure(
            &string_to_cursor,
            TextProperties {
                font_size: 14.0,
                line_height: 18.0,
                max_size: (10000.0, 18.0),
                alignment: kayak_font::Alignment::Start,
                tab_size: 4,
            },
        );

        state.cursor_x = measurement.size().0;
    }
}

pub fn cursor_animation_system(
    mut state_query: ParamSet<(Query<(Entity, &TextBoxState)>, Query<&mut TextBoxState>)>,
) {
    let mut should_update = Vec::new();

    for (entity, state) in state_query.p0().iter() {
        if state.cursor_last_update.elapsed().as_secs_f32() > 0.5 && state.focused {
            should_update.push(entity);
        }
    }

    for state_entity in should_update.drain(..) {
        if let Ok(mut state) = state_query.p1().get_mut(state_entity) {
            state.cursor_last_update = Instant::now();
            state.cursor_visible = !state.cursor_visible;
        }
    }
}