-
John Mitchell authored
- Widget's with styles must also include a ComputedStyles component and set it to equal to your styles object.
14bdc659
text_box.rs 17.71 KiB
use std::time::Instant;
use bevy::prelude::*;
use kayak_font::{KayakFont, TextProperties};
use kayak_ui_macros::{constructor, rsx};
use crate::{
context::WidgetName,
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,
widgets::{
text::{TextProps, TextWidgetBundle},
BackgroundBundle, ClipBundle,
},
Focusable, DEFAULT_FONT,
};
use super::ElementBundle;
/// Props used by the [`TextBox`] widget
#[derive(Component, PartialEq, Eq, Default, Debug, Clone)]
pub struct TextBoxProps {
/// If true, prevents the widget from being focused (and consequently edited)
pub disabled: bool,
/// The text to display when the user input is empty
pub placeholder: Option<String>,
/// 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)]
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,
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(),
current_value: String::new(),
}
}
}
pub struct TextBoxValue(pub String);
impl Widget for TextBoxProps {}
/// 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.
///
#[derive(Bundle)]
pub struct TextBoxBundle {
pub text_box: TextBoxProps,
pub styles: KStyle,
pub computed_styles: ComputedStyles,
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(),
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)>,
mut commands: Commands,
mut query: Query<(
&KStyle,
&mut ComputedStyles,
&TextBoxProps,
&mut OnEvent,
&OnChange,
)>,
mut state_query: ParamSet<(Query<&TextBoxState>, Query<&mut TextBoxState>)>,
font_assets: Res<Assets<KayakFont>>,
font_mapping: Res<FontMapping>,
) -> 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 {
current_value: text_box.value.clone(),
..TextBoxState::default()
},
);
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();
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();
set_new_cursor_position(&mut state, &font_assets, &font_mapping, &style_font);
}
}
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()
})
.into();
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(),
height: Units::Pixels(26.0).into(),
padding_left: Units::Pixels(5.0).into(),
padding_right: Units::Pixels(5.0).into(),
..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 } => {
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);
}
let cursor_pos = state.cursor_position;
if is_backspace(c) {
if !state.current_value.is_empty() {
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;
}
} else if !c.is_control() {
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
set_graphemes(&mut state, &font_assets, &font_mapping, &style_font);
set_new_cursor_position(
&mut state,
&font_assets,
&font_mapping,
&style_font,
);
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
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,
);
}
}
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(),
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()
};
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()
}}>
<ElementBundle styles={scroll_styles}>
<TextWidgetBundle
styles={text_styles}
text={TextProps {
content: text_box.value.clone(),
size: 14.0,
line_height: Some(18.0),
word_wrap: false,
..Default::default()
}}
/>
{
if state.focused && state.cursor_visible {
constructor! {
<BackgroundBundle styles={cursor_styles} />
}
}
}
</ElementBundle>
</ClipBundle>
</BackgroundBundle>
}
}
}
true
}
/// 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
.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;
}
}
}