diff --git a/examples/login.rs b/examples/login.rs new file mode 100644 index 0000000000000000000000000000000000000000..8670a50c70fde696b80a8aac8a45791b0f89fcda --- /dev/null +++ b/examples/login.rs @@ -0,0 +1,245 @@ +use bevy::{ + prelude::*, + window::{PrimaryWindow, WindowResolution}, +}; +use bevy_cosmic_edit::*; + +#[derive(Component)] +struct SubmitButton; + +#[derive(Component)] +struct UsernameTag; + +#[derive(Component)] +struct PasswordTag; + +#[derive(Component)] +struct DisplayTag; + +fn setup(mut commands: Commands, window: Query<&Window, With<PrimaryWindow>>) { + let window = window.single(); + + commands.spawn(Camera2dBundle::default()); + + commands + .spawn(NodeBundle { + style: Style { + flex_direction: FlexDirection::Column, + align_items: AlignItems::Center, + padding: UiRect::all(Val::Px(15.0)), + width: Val::Px(330.0), + + ..default() + }, + ..default() + }) + .with_children(|root| { + root.spawn(CosmicEditBundle { + max_lines: CosmicMaxLines(1), + metrics: CosmicMetrics { + scale_factor: window.scale_factor() as f32, + ..default() + }, + ..default() + }) + .insert(ButtonBundle { + style: Style { + // Size and position of text box + width: Val::Px(300.), + height: Val::Px(50.), + margin: UiRect::all(Val::Px(15.0)), + ..default() + }, + background_color: BackgroundColor(Color::WHITE), + ..default() + }) + .insert(CosmicEditPlaceholderBundle { + text_setter: PlaceholderText(CosmicText::OneStyle("Username".into())), + attrs: PlaceholderAttrs(AttrsOwned::new( + Attrs::new().color(bevy_color_to_cosmic(Color::rgb_u8(128, 128, 128))), + )), + }) + .insert(UsernameTag); + + root.spawn(CosmicEditBundle { + max_lines: CosmicMaxLines(1), + metrics: CosmicMetrics { + scale_factor: window.scale_factor() as f32, + ..default() + }, + ..default() + }) + .insert(ButtonBundle { + style: Style { + // Size and position of text box + width: Val::Px(300.), + height: Val::Px(50.), + margin: UiRect::all(Val::Px(15.0)), + ..default() + }, + background_color: BackgroundColor(Color::WHITE), + ..default() + }) + .insert(CosmicEditPlaceholderBundle { + text_setter: PlaceholderText(CosmicText::OneStyle("Password".into())), + attrs: PlaceholderAttrs(AttrsOwned::new( + Attrs::new().color(bevy_color_to_cosmic(Color::rgb_u8(128, 128, 128))), + )), + }) + .insert(PasswordTag) + .insert(PasswordInput::default()); + + root.spawn(CosmicEditBundle { + max_lines: CosmicMaxLines(1), + metrics: CosmicMetrics { + font_size: 25.0, + line_height: 25.0, + scale_factor: window.scale_factor() as f32, + ..default() + }, + attrs: CosmicAttrs(AttrsOwned::new( + Attrs::new().color(bevy_color_to_cosmic(Color::WHITE)), + )), + text_setter: CosmicText::OneStyle("Submit".into()), + fill_color: FillColor(Color::GREEN), + ..default() + }) + .insert(ButtonBundle { + style: Style { + // Size and position of text box + width: Val::Px(150.), + height: Val::Px(50.), + margin: UiRect::all(Val::Px(15.0)), + border: UiRect::all(Val::Px(3.0)), + ..default() + }, + background_color: BackgroundColor(Color::WHITE), + border_color: Color::DARK_GREEN.into(), + + ..default() + }) + .insert(SubmitButton) + .insert(ReadOnly); + + root.spawn(CosmicEditBundle { + metrics: CosmicMetrics { + scale_factor: window.scale_factor() as f32, + ..default() + }, + ..default() + }) + .insert(ButtonBundle { + style: Style { + // Size and position of text box + width: Val::Px(300.), + height: Val::Px(100.), + margin: UiRect::all(Val::Px(15.0)), + ..default() + }, + background_color: BackgroundColor(Color::WHITE), + ..default() + }) + .insert(CosmicEditPlaceholderBundle { + text_setter: PlaceholderText(CosmicText::OneStyle("Output".into())), + attrs: PlaceholderAttrs(AttrsOwned::new( + Attrs::new().color(bevy_color_to_cosmic(Color::rgb_u8(128, 128, 128))), + )), + }) + .insert((ReadOnly, DisplayTag)); + }); +} + +fn bevy_color_to_cosmic(color: bevy::prelude::Color) -> CosmicColor { + cosmic_text::Color::rgba( + (color.r() * 255.) as u8, + (color.g() * 255.) as u8, + (color.b() * 255.) as u8, + (color.a() * 255.) as u8, + ) +} + +fn change_active_editor_ui( + mut commands: Commands, + mut interaction_query: Query< + (&Interaction, Entity), + ( + Changed<Interaction>, + (With<CosmicEditor>, Without<ReadOnly>), + ), + >, +) { + for (interaction, entity) in interaction_query.iter_mut() { + if let Interaction::Pressed = interaction { + commands.insert_resource(Focus(Some(entity))); + } + } +} + +fn print_changed_input(mut evr_type: EventReader<CosmicTextChanged>) { + for ev in evr_type.iter() { + println!("Changed: {}", ev.0 .1); + } +} + +fn submit_button( + button_q: Query<&Interaction, With<SubmitButton>>, + username_q: Query< + &CosmicEditor, + (With<UsernameTag>, Without<PasswordTag>, Without<DisplayTag>), + >, + password_q: Query< + &CosmicEditor, + (With<PasswordTag>, Without<UsernameTag>, Without<DisplayTag>), + >, + mut display_q: Query< + (&mut CosmicEditor, &CosmicAttrs), + (With<DisplayTag>, Without<UsernameTag>, Without<PasswordTag>), + >, + mut font_system: ResMut<CosmicFontSystem>, + mut window: Query<&mut Window, With<PrimaryWindow>>, +) { + for interaction in button_q.iter() { + match interaction { + Interaction::None => {} + Interaction::Pressed => { + let u = username_q.single(); + let p = password_q.single(); + let (mut d, attrs) = display_q.single_mut(); + + let text = format!( + "Submitted!\nUsername: {}\nPassword: {}\n", + u.get_text(), + p.get_text() + ); + + d.set_text( + CosmicText::OneStyle(text), + attrs.0.clone(), + &mut font_system.0, + ); + } + Interaction::Hovered => { + window.single_mut().cursor.icon = CursorIcon::Hand; + } + } + } +} + +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + resolution: WindowResolution::new(330., 480.), + ..default() + }), + ..default() + })) + .add_plugins(CosmicEditPlugin { + change_cursor: CursorConfig::Default, + ..default() + }) + .add_systems(Startup, setup) + .add_systems(Update, change_active_editor_ui) + .add_systems(Update, (print_changed_input, submit_button)) + .run(); +} diff --git a/src/font/FiraMono-Regular-subset.ttf b/src/font/FiraMono-Regular-subset.ttf index bca868cdd34be48bbd1e23cbe0e91a2ef6c04f0e..c0fee60b0317902b0378bb21537dadf053fcef24 100644 Binary files a/src/font/FiraMono-Regular-subset.ttf and b/src/font/FiraMono-Regular-subset.ttf differ diff --git a/src/input.rs b/src/input.rs index 3e1b84ebf95d310d32fa158b3401d89edb4c6a8d..3ef16bab07b7ba42ca44e038644033725edead36 100644 --- a/src/input.rs +++ b/src/input.rs @@ -22,8 +22,8 @@ use wasm_bindgen_futures::JsFuture; use crate::{ get_node_cursor_pos, get_timestamp, get_x_offset_center, get_y_offset_center, save_edit_history, CosmicAttrs, CosmicEditHistory, CosmicEditor, CosmicFontSystem, - CosmicMaxChars, CosmicMaxLines, CosmicTextChanged, CosmicTextPosition, Focus, ReadOnly, - XOffset, + CosmicMaxChars, CosmicMaxLines, CosmicTextChanged, CosmicTextPosition, Focus, PasswordInput, + ReadOnly, XOffset, }; #[derive(Resource)] @@ -227,6 +227,7 @@ pub(crate) fn input_kb( &CosmicMaxChars, Entity, Option<&ReadOnly>, + Option<&PasswordInput>, )>, mut evw_changed: EventWriter<CosmicTextChanged>, mut font_system: ResMut<CosmicFontSystem>, @@ -234,8 +235,16 @@ pub(crate) fn input_kb( mut edits_duration: Local<Option<Duration>>, _channel: Option<Res<WasmPasteAsyncChannel>>, ) { - for (mut editor, mut edit_history, attrs, max_lines, max_chars, entity, readonly_opt) in - &mut cosmic_edit_query.iter_mut() + for ( + mut editor, + mut edit_history, + attrs, + max_lines, + max_chars, + entity, + readonly_opt, + password_opt, + ) in &mut cosmic_edit_query.iter_mut() { if active_editor.0 != Some(entity) { continue; @@ -402,12 +411,18 @@ pub(crate) fn input_kb( { if let Ok(mut clipboard) = arboard::Clipboard::new() { if command && keys.just_pressed(KeyCode::C) { + if password_opt.is_some() { + return; + } if let Some(text) = editor.0.copy_selection() { clipboard.set_text(text).unwrap(); return; } } if command && keys.just_pressed(KeyCode::X) && !readonly { + if password_opt.is_some() { + return; + } if let Some(text) = editor.0.copy_selection() { clipboard.set_text(text).unwrap(); editor.0.delete_selection(); @@ -425,6 +440,10 @@ pub(crate) fn input_kb( editor.0.action(&mut font_system.0, Action::Insert(c)); } } else { + if password_opt.is_some() && c.len_utf8() > 1 { + println!("Cannot input multi-byte char '{}' to password field! See https://github.com/StaffEngineer/bevy_cosmic_edit/pull/99#issuecomment-1782607486",c); + continue; + } editor.0.action(&mut font_system.0, Action::Insert(c)); } } @@ -438,6 +457,9 @@ pub(crate) fn input_kb( #[cfg(target_arch = "wasm32")] { if command && keys.just_pressed(KeyCode::C) { + if password_opt.is_some() { + return; + } if let Some(text) = editor.0.copy_selection() { write_clipboard_wasm(text.as_str()); return; @@ -445,6 +467,9 @@ pub(crate) fn input_kb( } if command && keys.just_pressed(KeyCode::X) && !readonly { + if password_opt.is_some() { + return; + } if let Some(text) = editor.0.copy_selection() { write_clipboard_wasm(text.as_str()); editor.0.delete_selection(); @@ -496,6 +521,10 @@ pub(crate) fn input_kb( } editor.0.action(&mut font_system.0, Action::Backspace); } else if !command && (max_chars.0 == 0 || editor.get_text().len() < max_chars.0) { + if password_opt.is_some() && char_ev.char.len_utf8() > 1 { + println!("Cannot input multi-byte char '{}' to password field! See https://github.com/StaffEngineer/bevy_cosmic_edit/pull/99#issuecomment-1782607486",char_ev.char); + continue; + } editor .0 .action(&mut font_system.0, Action::Insert(char_ev.char)); @@ -673,6 +702,7 @@ pub fn poll_wasm_paste( &CosmicMaxChars, &CosmicMaxChars, &mut CosmicEditHistory, + Option<&PasswordInput>, ), Without<ReadOnly>, >, @@ -683,7 +713,7 @@ pub fn poll_wasm_paste( match inlet { Ok(inlet) => { let entity = inlet.entity; - if let Ok((mut editor, attrs, max_chars, max_lines, mut edit_history)) = + if let Ok((mut editor, attrs, max_chars, max_lines, mut edit_history, password_opt)) = editor_q.get_mut(entity) { let text = inlet.text; @@ -695,6 +725,11 @@ pub fn poll_wasm_paste( editor.0.action(&mut font_system.0, Action::Insert(c)); } } else { + if password_opt.is_some() && c.len_utf8() > 1 { + // TODO: console.log here instead + println!("Cannot input multi-byte char '{}' to password field! See https://github.com/StaffEngineer/bevy_cosmic_edit/pull/99#issuecomment-1782607486",c); + continue; + } editor.0.action(&mut font_system.0, Action::Insert(c)); } } diff --git a/src/lib.rs b/src/lib.rs index a3febca20c58b8fb0065d97936e47fab7ba099a0..60888a0afbf52fd86611667e3b9dd3234edd2737 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,7 +21,8 @@ use input::{input_kb, input_mouse, undo_redo, ClickTimer}; use input::{poll_wasm_paste, WasmPaste, WasmPasteAsyncChannel}; use render::{ blink_cursor, cosmic_edit_redraw_buffer, freeze_cursor_blink, hide_inactive_or_readonly_cursor, - on_scale_factor_change, set_initial_scale, CursorBlinkTimer, CursorVisibility, SwashCacheState, + hide_password_text, on_scale_factor_change, restore_password_text, set_initial_scale, + CursorBlinkTimer, CursorVisibility, PasswordValues, SwashCacheState, }; #[cfg(feature = "multicam")] @@ -105,7 +106,7 @@ struct XOffset(Option<(f32, f32)>); pub struct CosmicEditor(pub Editor); impl CosmicEditor { - fn set_text( + pub fn set_text( &mut self, text: CosmicText, attrs: AttrsOwned, @@ -204,6 +205,15 @@ pub struct PlaceholderText(pub CosmicText); #[derive(Component)] pub struct PlaceholderAttrs(pub AttrsOwned); +#[derive(Component)] +pub struct PasswordInput(pub char); + +impl Default for PasswordInput { + fn default() -> Self { + PasswordInput("•".chars().next().unwrap()) + } +} + impl Default for PlaceholderAttrs { fn default() -> Self { Self(AttrsOwned::new(Attrs::new())) @@ -287,6 +297,19 @@ impl Plugin for CosmicEditPlugin { fn build(&self, app: &mut App) { let font_system = create_cosmic_font_system(self.font_config.clone()); + let update_texts = (update_buffer_text, update_placeholder_text); + let main_unordered = ( + init_history, + input_kb, + undo_redo, + blink_cursor, + freeze_cursor_blink, + hide_inactive_or_readonly_cursor, + clear_inactive_selection, + render::update_handle_ui, + render::update_handle_sprite, + ); + app.add_systems( First, ( @@ -301,27 +324,30 @@ impl Plugin for CosmicEditPlugin { render::cosmic_sprite_to_canvas, ), ) - .add_systems(PreUpdate, (update_buffer_text, update_placeholder_text)) .add_systems( - Update, + PreUpdate, ( - init_history, - input_kb, + update_texts, + main_unordered, + hide_password_text, input_mouse, - undo_redo, - blink_cursor, - freeze_cursor_blink, - hide_inactive_or_readonly_cursor, - clear_inactive_selection, - render::update_handle_ui, - render::update_handle_sprite, - ), + restore_password_text, + ) + .chain(), ) .add_systems( PostUpdate, - (cosmic_edit_redraw_buffer).after(TransformSystem::TransformPropagate), + ( + hide_password_text, + cosmic_edit_redraw_buffer + .after(TransformSystem::TransformPropagate) + .after(hide_password_text) + .before(restore_password_text), + restore_password_text, + ), ) .init_resource::<Focus>() + .init_resource::<PasswordValues>() .insert_resource(CursorBlinkTimer(Timer::from_seconds( 0.53, TimerMode::Repeating, diff --git a/src/render.rs b/src/render.rs index e76e6d87bee7971a0fa8e3780f1d9d59a7743cda..dae6ee53066279c8cb4c747f8d725f8a5250c023 100644 --- a/src/render.rs +++ b/src/render.rs @@ -4,6 +4,7 @@ use bevy::{ asset::HandleId, prelude::*, render::render_resource::Extent3d, + utils::HashMap, window::{PrimaryWindow, WindowScaleFactorChanged}, }; use cosmic_text::{Affinity, Edit, Metrics, SwashCache}; @@ -11,8 +12,9 @@ use image::{imageops::FilterType, GenericImageView}; use crate::{ get_text_size, get_x_offset_center, get_y_offset_center, CosmicAttrs, CosmicBackground, - CosmicCanvas, CosmicEditor, CosmicFontSystem, CosmicMetrics, CosmicMode, CosmicTextPosition, - FillColor, Focus, Placeholder, ReadOnly, XOffset, DEFAULT_SCALE_PLACEHOLDER, + CosmicCanvas, CosmicEditor, CosmicFontSystem, CosmicMetrics, CosmicMode, CosmicText, + CosmicTextPosition, FillColor, Focus, PasswordInput, Placeholder, ReadOnly, XOffset, + DEFAULT_SCALE_PLACEHOLDER, }; #[derive(Resource)] @@ -26,6 +28,9 @@ pub(crate) struct CursorBlinkTimer(pub Timer); #[derive(Resource)] pub(crate) struct CursorVisibility(pub bool); +#[derive(Resource, Default)] +pub(crate) struct PasswordValues(pub HashMap<Entity, (String, usize)>); + pub(crate) fn cosmic_edit_redraw_buffer( windows: Query<&Window, With<PrimaryWindow>>, mut images: ResMut<Assets<Image>>, @@ -50,7 +55,7 @@ pub(crate) fn cosmic_edit_redraw_buffer( let scale = primary_window.scale_factor() as f32; for ( - mut editor, + mut cosmic_editor, attrs, background_image, fill_color, @@ -64,16 +69,18 @@ pub(crate) fn cosmic_edit_redraw_buffer( mut placeholder_opt, ) in &mut cosmic_edit_query.iter_mut() { - if !editor.0.buffer().redraw() { + if !cosmic_editor.0.buffer().redraw() { continue; } + let current_text = cosmic_editor.get_text(); + // Check for placeholder, replace editor if found and buffer is empty - let editor = if editor.get_text().is_empty() && placeholder_opt.is_some() { + let editor = if current_text.is_empty() && placeholder_opt.is_some() { let placeholder = &mut placeholder_opt.as_mut().unwrap().0 .0; placeholder.buffer_mut().set_redraw(true); - editor.0.buffer_mut().set_redraw(true); + cosmic_editor.0.buffer_mut().set_redraw(true); let mut cursor = placeholder.cursor(); cursor.index = 0; @@ -81,7 +88,7 @@ pub(crate) fn cosmic_edit_redraw_buffer( *x_offset = XOffset(None); placeholder } else { - &mut editor.0 + &mut cosmic_editor.0 }; editor.shape_as_needed(&mut font_system.0); @@ -409,7 +416,7 @@ pub(crate) fn hide_inactive_or_readonly_cursor( let editor = &mut editor.0; let mut cursor = editor.0.cursor(); if cursor.color == Some(cosmic_text::Color::rgba(0, 0, 0, 0)) { - return; + continue; } cursor.color = Some(cosmic_text::Color::rgba(0, 0, 0, 0)); editor.0.set_cursor(cursor); @@ -421,7 +428,7 @@ pub(crate) fn hide_inactive_or_readonly_cursor( if active_editor.is_none() || e != active_editor.0.unwrap() { let mut cursor = editor.0.cursor(); if cursor.color == Some(cosmic_text::Color::rgba(0, 0, 0, 0)) { - return; + continue; } cursor.color = Some(cosmic_text::Color::rgba(0, 0, 0, 0)); editor.0.set_cursor(cursor); @@ -494,3 +501,94 @@ pub(crate) fn update_handle_sprite( *handle = canvas.0.clone_weak(); } } + +pub(crate) fn hide_password_text( + mut editor_q: Query<(Entity, &mut CosmicEditor, &CosmicAttrs, &PasswordInput)>, + mut font_system: ResMut<CosmicFontSystem>, + mut password_input_states: ResMut<PasswordValues>, + active_editor: Res<Focus>, +) { + for (entity, mut cosmic_editor, attrs, password) in editor_q.iter_mut() { + let text = cosmic_editor.get_text(); + let select_opt = cosmic_editor.0.select_opt(); + let mut cursor = cosmic_editor.0.cursor(); + + if !text.is_empty() { + cosmic_editor.set_text( + CosmicText::OneStyle(format!("{}", password.0).repeat(text.chars().count())), + attrs.0.clone(), + &mut font_system.0, + ); + + // multiply cursor idx and select_opt end point by password char length + // the actual char length cos 'â—' is 3x as long as 'a' + // This operation will need to be undone when resetting. + // + // Currently breaks entering multi-byte chars + + let char_len = password.0.len_utf8(); + + let select_opt = match select_opt { + Some(mut select) => { + select.index *= char_len; + Some(select) + } + None => None, + }; + + cursor.index *= char_len; + + cosmic_editor.0.set_select_opt(select_opt); + + // Fixes stuck cursor on password inputs + if let Some(active) = active_editor.0 { + if entity != active { + cursor.color = Some(cosmic_text::Color::rgba(0, 0, 0, 0)); + } + } + + cosmic_editor.0.set_cursor(cursor); + } + + let glyph_idx = match cosmic_editor.0.buffer().lines[0].layout_opt() { + Some(_) => cosmic_editor.0.buffer().layout_cursor(&cursor).glyph, + None => 0, + }; + + password_input_states.0.insert(entity, (text, glyph_idx)); + } +} + +pub(crate) fn restore_password_text( + mut editor_q: Query<(Entity, &mut CosmicEditor, &CosmicAttrs, &PasswordInput)>, + mut font_system: ResMut<CosmicFontSystem>, + password_input_states: Res<PasswordValues>, +) { + for (entity, mut cosmic_editor, attrs, password) in editor_q.iter_mut() { + if let Some((text, _glyph_idx)) = password_input_states.0.get(&entity) { + if !text.is_empty() { + let char_len = password.0.len_utf8(); + + let mut cursor = cosmic_editor.0.cursor(); + let select_opt = match cosmic_editor.0.select_opt() { + Some(mut select) => { + select.index /= char_len; + Some(select) + } + None => None, + }; + + cursor.index /= char_len; + + cosmic_editor.set_text( + crate::CosmicText::OneStyle(text.clone()), + attrs.0.clone(), + &mut font_system.0, + ); + + cosmic_editor.0.set_select_opt(select_opt); + cosmic_editor.0.set_cursor(cursor); + } + } + } +}