pub mod clipboard;
pub mod edit;
pub mod render;
pub mod text_input_pipeline;
use bevy::app::{Plugin, PostUpdate};
use bevy::asset::AssetEvents;
use bevy::color::Color;
use bevy::color::palettes::css::SKY_BLUE;
use bevy::color::palettes::tailwind::GRAY_400;
use bevy::ecs::component::{Component, HookContext};
use bevy::ecs::entity::Entity;
use bevy::ecs::event::Event;
use bevy::ecs::observer::Observer;
use bevy::ecs::query::Changed;
use bevy::ecs::schedule::IntoScheduleConfigs;
use bevy::ecs::schedule::common_conditions::resource_changed;
use bevy::ecs::system::Query;
use bevy::ecs::world::DeferredWorld;
use bevy::input_focus::InputFocus;
use bevy::math::{Rect, Vec2};
use bevy::prelude::ReflectComponent;
use bevy::reflect::{Reflect, std_traits::ReflectDefault};
use bevy::render::{ExtractSchedule, RenderApp};
use bevy::text::TextColor;
use bevy::text::cosmic_text::{Align, Buffer, Change, Edit, Editor, Metrics, Wrap};
use bevy::text::{GlyphAtlasInfo, TextFont};
use bevy::ui::{Node, RenderUiSystem, UiSystem, extract_text_sections};
use edit::{
clear_selection_on_focus_change, mouse_wheel_scroll, on_drag_text_input,
on_move_clear_multi_click, on_multi_click_set_selection, on_text_input_pressed,
text_input_edit_system,
};
use render::{extract_text_input_nodes, extract_text_input_prompts};
use text_input_pipeline::{
TextInputPipeline, remove_dropped_font_atlas_sets_from_text_input_pipeline,
text_input_prompt_system, text_input_system,
};
pub struct TextInputPlugin;
impl Plugin for TextInputPlugin {
fn build(&self, app: &mut bevy::app::App) {
app.add_event::<TextSubmissionEvent>()
.add_event::<SubmitTextEvent>()
.init_resource::<InputFocus>()
.init_resource::<TextInputPipeline>()
.init_resource::<clipboard::Clipboard>()
.add_systems(
PostUpdate,
(
remove_dropped_font_atlas_sets_from_text_input_pipeline.before(AssetEvents),
(
mouse_wheel_scroll,
text_input_edit_system,
update_text_input_contents,
text_input_system,
text_input_prompt_system,
clear_selection_on_focus_change.run_if(resource_changed::<InputFocus>),
)
.chain()
.in_set(UiSystem::PostLayout),
),
);
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app.add_systems(
ExtractSchedule,
(extract_text_input_prompts, extract_text_input_nodes)
.chain()
.in_set(RenderUiSystem::ExtractText)
.after(extract_text_sections),
);
}
}
#[derive(Component, Debug)]
#[require(
Node,
TextInputBuffer,
TextFont,
TextInputLayoutInfo,
TextInputStyle,
TextColor
)]
#[component(
on_add = on_add_textinputnode,
on_remove = on_remove_unfocus,
)]
pub struct TextInputNode {
pub clear_on_submit: bool,
pub mode: TextInputMode,
pub max_chars: Option<usize>,
pub allow_overwrite_mode: bool,
pub is_enabled: bool,
pub focus_on_pointer_down: bool,
pub unfocus_on_submit: bool,
pub alignment: Option<Align>,
}
impl Default for TextInputNode {
fn default() -> Self {
Self {
clear_on_submit: true,
mode: TextInputMode::default(),
max_chars: None,
allow_overwrite_mode: true,
is_enabled: true,
focus_on_pointer_down: true,
unfocus_on_submit: true,
alignment: None,
}
}
}
fn on_add_textinputnode(mut world: DeferredWorld, context: HookContext) {
for mut observer in [
Observer::new(on_drag_text_input),
Observer::new(on_text_input_pressed),
Observer::new(on_multi_click_set_selection),
Observer::new(on_move_clear_multi_click),
] {
observer.watch_entity(context.entity);
world.commands().spawn(observer);
}
}
fn on_remove_unfocus(mut world: DeferredWorld, context: HookContext) {
let mut input_focus = world.resource_mut::<InputFocus>();
if input_focus.0 == Some(context.entity) {
input_focus.0 = None;
}
}
#[derive(Event)]
pub struct TextSubmissionEvent {
pub entity: Entity,
pub text: String,
}
#[derive(Event)]
pub struct SubmitTextEvent {
pub entity: Entity,
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum TextInputMode {
Text { wrap: Wrap },
Integer,
Decimal,
Hex,
TextSingleLine,
}
impl Default for TextInputMode {
fn default() -> Self {
Self::Text {
wrap: Wrap::WordOrGlyph,
}
}
}
impl TextInputMode {
pub fn wrap(&self) -> Wrap {
match self {
TextInputMode::Text { wrap } => *wrap,
_ => Wrap::None,
}
}
}
#[derive(Component, Debug)]
pub struct TextInputBuffer {
set_text: Option<String>,
pub editor: Editor<'static>,
pub(crate) selection_rects: Vec<Rect>,
pub(crate) cursor_blink_time: f32,
pub(crate) overwrite_mode: bool,
pub(crate) needs_update: bool,
pub(crate) prompt_buffer: Option<Buffer>,
pub(crate) changes: cosmic_undo_2::Commands<Change>,
}
impl TextInputBuffer {
pub fn set_text(&mut self, text: String) {
self.set_text = Some(text);
}
pub fn clear(&mut self) {
self.set_text(String::new());
}
pub fn get_text(&self) -> String {
self.editor.with_buffer(get_text)
}
}
impl Default for TextInputBuffer {
fn default() -> Self {
Self {
set_text: None,
editor: Editor::new(Buffer::new_empty(Metrics::new(20.0, 20.0))),
selection_rects: vec![],
cursor_blink_time: 0.,
overwrite_mode: false,
needs_update: true,
prompt_buffer: None,
changes: cosmic_undo_2::Commands::default(),
}
}
}
#[derive(Component, Clone, Debug, Reflect)]
#[reflect(Component, Default, Debug)]
#[require(TextInputPromptLayoutInfo)]
pub struct TextInputPrompt {
pub text: String,
pub font: Option<TextFont>,
pub color: Option<Color>,
}
impl TextInputPrompt {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
..Default::default()
}
}
}
impl Default for TextInputPrompt {
fn default() -> Self {
Self {
text: "Enter some text here".into(),
font: None,
color: Some(bevy::color::palettes::css::GRAY.into()),
}
}
}
#[derive(Component, Copy, Clone, Debug, PartialEq, Reflect)]
#[reflect(Component, Default, Debug, PartialEq)]
pub struct TextInputStyle {
pub cursor_color: Color,
pub selection_color: Color,
pub selected_text_color: Option<Color>,
pub cursor_width: f32,
pub cursor_radius: f32,
pub cursor_height: f32,
pub blink_interval: f32,
}
impl Default for TextInputStyle {
fn default() -> Self {
Self {
cursor_color: GRAY_400.into(),
selection_color: SKY_BLUE.into(),
selected_text_color: None,
cursor_width: 3.,
cursor_radius: 0.,
cursor_height: 1.,
blink_interval: 0.5,
}
}
}
fn get_text(buffer: &Buffer) -> String {
buffer
.lines
.iter()
.map(|buffer_line| buffer_line.text())
.fold(String::new(), |mut out, line| {
if !out.is_empty() {
out.push('\n');
}
out.push_str(line);
out
})
}
#[derive(Component, Clone, Default, Debug, Reflect)]
#[reflect(Component, Default, Debug)]
pub struct TextInputLayoutInfo {
pub glyphs: Vec<TextInputGlyph>,
pub size: Vec2,
}
#[derive(Component, Clone, Default, Debug, Reflect)]
#[reflect(Component, Default, Debug)]
pub struct TextInputPromptLayoutInfo {
pub glyphs: Vec<TextInputGlyph>,
pub size: Vec2,
}
#[derive(Debug, Clone, Reflect)]
pub struct TextInputGlyph {
pub position: Vec2,
pub size: Vec2,
pub atlas_info: GlyphAtlasInfo,
pub span_index: usize,
pub line_index: usize,
pub byte_index: usize,
pub byte_length: usize,
}
#[derive(Default, Debug, Component, PartialEq)]
pub struct TextInputContents {
text: String,
}
impl TextInputContents {
pub fn get(&self) -> &str {
&self.text
}
}
pub fn update_text_input_contents(
mut query: Query<(&TextInputBuffer, &mut TextInputContents), Changed<TextInputBuffer>>,
) {
for (buffer, mut contents) in query.iter_mut() {
let text = buffer.get_text();
if contents.text != text {
contents.text = text;
}
}
}