use crate::{
scroll::TextViewport,
text_edit::{poll_and_apply_paste, reveal_cursor, TextEdit},
FontCx, FontHinting, LayoutCx, LineHeight, TextBrush, TextColor, TextFont, TextLayout,
};
use alloc::sync::Arc;
use bevy_clipboard::ClipboardRead;
use bevy_derive::{Deref, DerefMut};
use bevy_ecs::prelude::*;
use bevy_math::Vec2;
use core::time::Duration;
use parley::{FontContext, LayoutContext, PlainEditor, SplitString};
#[derive(Component, Clone)]
#[require(
TextLayout,
TextFont,
TextColor,
LineHeight,
FontHinting,
EditableTextGeneration,
TextReadWriteMode
)]
pub struct EditableText {
pub editor: PlainEditor<TextBrush>,
pub viewport: TextViewport,
pub pending_edits: Vec<TextEdit>,
pub pending_paste: Option<ClipboardRead>,
pub cursor_margin: Vec2,
pub cursor_width: f32,
pub cursor_blink_period: Duration,
pub max_characters: Option<usize>,
pub visible_lines: Option<f32>,
pub visible_width: Option<f32>,
pub allow_newlines: bool,
}
impl Default for EditableText {
fn default() -> Self {
Self {
editor: PlainEditor::new(100.),
viewport: TextViewport::default(),
cursor_margin: Vec2::splat(0.2),
pending_edits: Vec::new(),
pending_paste: None,
cursor_width: 0.2,
cursor_blink_period: Duration::from_secs(1),
max_characters: None,
visible_lines: Some(1.),
visible_width: None,
allow_newlines: false,
}
}
}
impl EditableText {
pub fn new(initial_text: impl AsRef<str>) -> Self {
let mut editable_text = Self::default();
editable_text.editor.set_text(initial_text.as_ref());
editable_text.queue_edit(TextEdit::TextEnd(false));
editable_text
}
pub fn editor(&self) -> &PlainEditor<TextBrush> {
&self.editor
}
pub fn editor_mut(&mut self) -> &mut PlainEditor<TextBrush> {
&mut self.editor
}
pub fn value(&self) -> SplitString<'_> {
self.editor.text()
}
pub fn queue_edit(&mut self, edit: TextEdit) {
self.pending_edits.push(edit);
}
pub fn apply_pending_edits(
&mut self,
font_context: &mut FontContext,
layout_context: &mut LayoutContext<TextBrush>,
clipboard: &mut bevy_clipboard::Clipboard,
char_filter: impl Fn(char) -> bool,
) {
let Self {
editor,
pending_edits,
pending_paste,
max_characters,
viewport,
cursor_margin,
..
} = self;
let mut driver = editor.driver(font_context, layout_context);
if let Some(mut read) = pending_paste.take() {
let generation = driver.editor.generation();
if !poll_and_apply_paste(&mut read, &mut driver, *max_characters, &char_filter) {
*pending_paste = Some(read);
return;
}
if generation != driver.editor.generation() {
reveal_cursor(&mut driver, viewport, *cursor_margin);
}
}
let mut edits = core::mem::take(pending_edits).into_iter();
while let Some(edit) = edits.next() {
match edit {
TextEdit::Paste => {
let generation = driver.editor.generation();
let mut read = clipboard.fetch_text();
if !poll_and_apply_paste(&mut read, &mut driver, *max_characters, &char_filter)
{
*pending_paste = Some(read);
pending_edits.extend(edits);
return;
}
if generation != driver.editor.generation() {
reveal_cursor(&mut driver, viewport, *cursor_margin);
}
}
other => other.apply(
&mut driver,
viewport,
*cursor_margin,
clipboard,
*max_characters,
&char_filter,
),
}
}
}
pub fn clear(&mut self) {
self.editor.set_text("");
self.pending_edits.clear();
self.pending_paste = None;
self.queue_edit(TextEdit::TextStart(false));
}
pub fn is_composing(&self) -> bool {
self.editor.is_composing()
}
}
#[derive(Component, PartialEq, Eq, Default, Clone, Copy, Deref, DerefMut)]
pub struct EditableTextGeneration(parley::Generation);
#[derive(Component, Clone, Default)]
pub struct EditableTextFilter(Option<Arc<dyn Fn(char) -> bool + Send + Sync + 'static>>);
#[derive(Component, Clone, Copy, Default, PartialEq, Debug)]
pub enum TextReadWriteMode {
#[default]
Editable,
ReadOnly,
Static,
}
impl EditableTextFilter {
pub fn new(filter: impl Fn(char) -> bool + Send + Sync + 'static) -> Self {
Self(Some(Arc::new(filter)))
}
}
pub fn apply_text_edits(
mut query: Query<(
Entity,
&mut EditableText,
Option<&EditableTextFilter>,
&EditableTextGeneration,
)>,
mut font_context: ResMut<FontCx>,
mut layout_context: ResMut<LayoutCx>,
mut clipboard: ResMut<bevy_clipboard::Clipboard>,
mut commands: Commands,
) {
for (entity, mut editable_text, filter, generation) in query.iter_mut() {
if !editable_text.pending_edits.is_empty() || editable_text.pending_paste.is_some() {
editable_text.apply_pending_edits(
&mut font_context,
&mut layout_context.0,
&mut clipboard,
match filter {
Some(EditableTextFilter(Some(filter))) => filter.as_ref(),
_ => &|_| true,
},
);
}
if **generation != editable_text.editor.generation() {
commands.trigger(TextEditChange { entity });
}
}
}
#[derive(EntityEvent)]
pub struct TextEditChange {
entity: Entity,
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::borrow::Cow;
use parley::FontFamilyName;
fn edit_after_clear(setup: TextEdit, edit: TextEdit) -> String {
let mut font_context = FontContext::new();
let font = crate::Font::from_bytes(include_bytes!("FiraMono-subset.ttf").to_vec());
font_context.collection.register_fonts(font.data, None);
let mut layout_context = LayoutContext::new();
let mut clipboard = bevy_clipboard::Clipboard::default();
let mut text = EditableText::new("hello world");
text.editor
.edit_styles()
.insert(FontFamilyName::Named(Cow::Borrowed("Fira Mono")).into());
text.queue_edit(setup);
text.apply_pending_edits(
&mut font_context,
&mut layout_context,
&mut clipboard,
|_| true,
);
text.clear();
text.queue_edit(edit);
text.apply_pending_edits(
&mut font_context,
&mut layout_context,
&mut clipboard,
|_| true,
);
text.value().to_string()
}
#[test]
fn insert_after_clear_uses_a_fresh_cursor() {
let value = edit_after_clear(TextEdit::TextEnd(false), TextEdit::Insert("x".into()));
assert_eq!(value, "x");
}
#[test]
fn delete_after_clear_ignores_the_old_selection() {
let value = edit_after_clear(TextEdit::SelectAll, TextEdit::Backspace);
assert_eq!(value, "");
}
}