diff --git a/kayak_font/Cargo.toml b/kayak_font/Cargo.toml index 305c83cd2cdf1328bf60af9168b28cb874cf50f2..03ee9c3e382bae688eb059301d93fd155d79bbb1 100644 --- a/kayak_font/Cargo.toml +++ b/kayak_font/Cargo.toml @@ -10,7 +10,7 @@ bevy_renderer = ["bevy"] [dependencies] anyhow = { version = "1.0" } nanoserde = "0.1.30" -unicode-segmentation = "1.9" +unicode-segmentation = "1.10.0" # Provides UAX #14 line break segmentation xi-unicode = "0.3" diff --git a/kayak_font/src/font.rs b/kayak_font/src/font.rs index 43be3b888bcf831abd5646b83f468dca03cfe83f..b1143fc66fe921344789fdbdd02bb7f5a256ebcf 100644 --- a/kayak_font/src/font.rs +++ b/kayak_font/src/font.rs @@ -92,6 +92,11 @@ impl KayakFont { width } + /// Splits up the provided &str into grapheme clusters. + pub fn get_graphemes<'a>(&'a self, content: &'a str) -> Vec<&'a str> { + UnicodeSegmentation::graphemes(content, true).collect::<Vec<_>>() + } + /// Measures the given text content and calculates an appropriate layout /// given a set of properties. /// diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index a11e2cb6997d9d3555b66fa58f599ef61b93fe8d..2ab6866681c67f1dc026ec82f3323b9f1a6251a2 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -86,7 +86,8 @@ pub struct KayakWidgets; impl Plugin for KayakWidgets { fn build(&self, app: &mut bevy::prelude::App) { - app.add_startup_system_to_stage(StartupStage::PostStartup, add_widget_systems); + app.add_startup_system_to_stage(StartupStage::PostStartup, add_widget_systems) + .add_system(text_box::cursor_animation_system); } } diff --git a/src/widgets/text_box.rs b/src/widgets/text_box.rs index 5c92aa98dbd07d6234af538bb4fd09f1e749ca44..8bf53c00891b88dd5b77c2fae9ec3a0719086b3f 100644 --- a/src/widgets/text_box.rs +++ b/src/widgets/text_box.rs @@ -1,5 +1,8 @@ -use bevy::prelude::{Bundle, Color, Commands, Component, Entity, In, Query}; -use kayak_ui_macros::rsx; +use std::time::Instant; + +use bevy::prelude::*; +use kayak_font::{KayakFont, TextProperties}; +use kayak_ui_macros::{constructor, rsx}; use crate::{ context::WidgetName, @@ -8,6 +11,7 @@ use crate::{ on_event::OnEvent, on_layout::OnLayout, prelude::{KChildren, KayakWidgetContext, OnChange}, + render::font::FontMapping, styles::{Edge, KStyle, RenderCommand, StyleProp, Units}, widget::Widget, widget_state::WidgetState, @@ -15,7 +19,7 @@ use crate::{ text::{TextProps, TextWidgetBundle}, BackgroundBundle, ClipBundle, }, - Focusable, + Focusable, DEFAULT_FONT, }; /// Props used by the [`TextBox`] widget @@ -32,9 +36,27 @@ pub struct TextBoxProps { pub value: String, } -#[derive(Component, Default, Clone, PartialEq)] +#[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, +} + +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(), + } + } } pub struct TextBoxValue(pub String); @@ -80,7 +102,9 @@ pub fn text_box_render( let state_entity = widget_context.use_state::<TextBoxState>( &mut commands, entity, - TextBoxState::default(), + TextBoxState { + ..TextBoxState::default() + }, ); if let Ok(state) = state_query.get(state_entity) { @@ -119,6 +143,8 @@ pub fn text_box_render( let current_value = text_box.value.clone(); let cloned_on_change = on_change.clone(); + let style_font = styles.font.clone(); + *on_event = OnEvent::new( move |In((event_dispatcher_context, _, mut event, _entity)): In<( EventDispatcherContext, @@ -126,8 +152,38 @@ pub fn text_box_render( 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 } => { let mut current_value = current_value.clone(); let cloned_on_change = cloned_on_change.clone(); @@ -140,17 +196,61 @@ pub fn text_box_render( } if is_backspace(c) { if !current_value.is_empty() { - current_value.truncate(current_value.len() - 1); + if let Ok(mut state) = state_query.get_mut(state_entity) { + // TODO: This doesn't respect graphemes! + current_value.remove(state.cursor_position - 1); + state.cursor_position -= 1; + } } } else if !c.is_control() { - current_value.push(c); + if let Ok(mut state) = state_query.get_mut(state_entity) { + // TODO: This doesn't respect graphemes! + current_value.insert(state.cursor_position, c); + state.cursor_position += 1; + } + } + + if let Ok(mut state) = state_query.get_mut(state_entity) { + // Update graphemes + set_graphemes( + &mut state, + &font_assets, + &font_mapping, + &style_font, + ¤t_value, + ); + + set_new_cursor_position( + &mut state, + &font_assets, + &font_mapping, + &style_font, + ); } + cloned_on_change.set_value(current_value); 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, + ¤t_value, + ); + + state.cursor_position = state.graphemes.len(); + + set_new_cursor_position( + &mut state, + &font_assets, + &font_mapping, + &style_font, + ); } } EventType::Blur => { @@ -164,6 +264,16 @@ pub fn text_box_render( }, ); + let cursor_styles = KStyle { + background_color: Color::rgba(0.933, 0.745, 0.745, 1.0).into(), + position_type: crate::styles::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 parent_id = Some(entity); rsx! { <BackgroundBundle styles={background_styles}> @@ -188,6 +298,13 @@ pub fn text_box_render( ..Default::default() }} /> + { + if state.focused && state.cursor_visible { + constructor! { + <BackgroundBundle styles={cursor_styles} /> + } + } + } </ClipBundle> </BackgroundBundle> } @@ -203,3 +320,71 @@ pub fn text_box_render( 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>, + current_value: &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(¤t_value) + .iter() + .map(|s| s.to_string()) + .collect::<Vec<_>>(); + } +} + +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; + } + } +}