From aa8363246894ad8606d1bf4e0142890c3ee01f67 Mon Sep 17 00:00:00 2001
From: sam edelsten <43527203+bytemunch@users.noreply.github.com>
Date: Sat, 6 Apr 2024 13:32:57 +0100
Subject: [PATCH] internal placeholder plugin (#125)

* internal placeholder plugin

* fix panic on placeholder editor input

* remove autofocus in placeholder example

* make `Placeholder.active` private

* remove placeholder on input

* fix multi-byte char in placeholder

* show placeholder on empty editor

* add guards to placeholder add fns

* fix placeholder displaying incorrectly on input

* fix flash when backspacing empty placeholder

also properly fix the first-char display error

* hacky fix for delete key breaking placeholder

* fix newline issues in placeholder

* fix clippy

* update changelog, bump version

---------

Co-authored-by: StaffEngineer <111751109+StaffEngineer@users.noreply.github.com>
Co-authored-by: StaffEngineer <velo.app1@gmail.com>
---
 CHANGELOG.md                   |   4 +
 Cargo.lock                     |   2 +-
 Cargo.toml                     |  11 +-
 examples/placeholder.rs        |  80 +++++++++++++
 src/buffer.rs                  |   2 -
 src/input.rs                   |   3 -
 src/lib.rs                     |  21 +++-
 src/plugins/mod.rs             |   2 +
 src/plugins/placeholder/mod.rs | 199 +++++++++++++++++++++++++++++++++
 9 files changed, 314 insertions(+), 10 deletions(-)
 create mode 100644 examples/placeholder.rs
 create mode 100644 src/plugins/mod.rs
 create mode 100644 src/plugins/placeholder/mod.rs

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 608db70..f0f93e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Changelog 
 
+## Version 0.18.0 (2024)
+
+- Add text placeholder plugin
+
 ## Version 0.17.0 (2024)
 
 - Update to cosmic-text 0.11.2
diff --git a/Cargo.lock b/Cargo.lock
index 7622718..bcd40f2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -406,7 +406,7 @@ dependencies = [
 
 [[package]]
 name = "bevy_cosmic_edit"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "arboard",
  "bevy",
diff --git a/Cargo.toml b/Cargo.toml
index c47d624..7f44d8b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,7 +1,7 @@
 workspace = { members = ["util"] }
 [package]
 name = "bevy_cosmic_edit"
-version = "0.17.0"
+version = "0.18.0"
 edition = "2021"
 license = "MIT OR Apache-2.0"
 description = "Bevy cosmic-text multiline text input"
@@ -13,6 +13,7 @@ exclude = ["assets/*"]
 
 [features]
 multicam = []
+placeholder = []
 
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
@@ -43,8 +44,12 @@ arboard = "3.2.0"
 js-sys = "0.3.67"
 wasm-bindgen = "0.2.92"
 wasm-bindgen-futures = "0.4.42"
-web-sys = { version = "0.3.67", features = ["Clipboard", "Navigator", "Window"] }
+web-sys = { version = "0.3.67", features = [
+  "Clipboard",
+  "Navigator",
+  "Window",
+] }
 
 [dev-dependencies]
 insta = "1.29.0"
-util = {path="./util/"}
+util = { path = "./util/" }
diff --git a/examples/placeholder.rs b/examples/placeholder.rs
new file mode 100644
index 0000000..bc081ea
--- /dev/null
+++ b/examples/placeholder.rs
@@ -0,0 +1,80 @@
+use bevy::prelude::*;
+use bevy_cosmic_edit::{placeholder::Placeholder, *};
+use util::{
+    bevy_color_to_cosmic, change_active_editor_ui, deselect_editor_on_esc, print_editor_text,
+};
+
+fn setup(mut commands: Commands, mut font_system: ResMut<CosmicFontSystem>) {
+    let camera_bundle = Camera2dBundle {
+        camera: Camera {
+            clear_color: ClearColorConfig::Custom(Color::PINK),
+            ..default()
+        },
+        ..default()
+    };
+    commands.spawn(camera_bundle);
+
+    let mut attrs = Attrs::new();
+    attrs = attrs.family(Family::Name("Victor Mono"));
+    attrs = attrs.color(CosmicColor::rgb(0x94, 0x00, 0xD3));
+
+    let cosmic_edit =
+        commands
+            .spawn((
+                CosmicEditBundle {
+                    buffer: CosmicBuffer::new(&mut font_system, Metrics::new(20., 20.))
+                        .with_rich_text(&mut font_system, vec![("", attrs)], attrs),
+                    text_position: CosmicTextPosition::Center,
+                    ..default()
+                },
+                Placeholder::new(
+                    "Placeholder",
+                    attrs.color(bevy_color_to_cosmic(Color::GRAY)),
+                ),
+            ))
+            .id();
+
+    commands
+        .spawn(
+            // Use buttonbundle for layout
+            // Includes Interaction and UiImage which are used by the plugin.
+            ButtonBundle {
+                style: Style {
+                    width: Val::Percent(100.),
+                    height: Val::Percent(100.),
+                    ..default()
+                },
+                ..default()
+            },
+        )
+        // point editor at this entity.
+        // Plugin looks for UiImage and sets it's
+        // texture to the editor's rendered image
+        .insert(CosmicSource(cosmic_edit));
+}
+
+fn main() {
+    let font_bytes: &[u8] = include_bytes!("../assets/fonts/VictorMono-Regular.ttf");
+    let font_config = CosmicFontConfig {
+        fonts_dir_path: None,
+        font_bytes: Some(vec![font_bytes]),
+        load_system_fonts: true,
+    };
+
+    App::new()
+        .add_plugins(DefaultPlugins)
+        .add_plugins(CosmicEditPlugin {
+            font_config,
+            ..default()
+        })
+        .add_systems(Startup, setup)
+        .add_systems(
+            Update,
+            (
+                print_editor_text,
+                change_active_editor_ui,
+                deselect_editor_on_esc,
+            ),
+        )
+        .run();
+}
diff --git a/src/buffer.rs b/src/buffer.rs
index 8f58564..1d32295 100644
--- a/src/buffer.rs
+++ b/src/buffer.rs
@@ -15,8 +15,6 @@ impl<'s, 'r> CosmicBuffer {
         Self(Buffer::new(font_system, metrics))
     }
 
-    // TODO: Set redraw when setting text
-
     // Das a lotta boilerplate just to hide the shaping argument
     pub fn with_text(
         mut self,
diff --git a/src/input.rs b/src/input.rs
index 5052197..087cef0 100644
--- a/src/input.rs
+++ b/src/input.rs
@@ -310,7 +310,6 @@ pub(crate) fn input_kb(
         }
         if should_jump && keys.just_pressed(KeyCode::Home) {
             editor.action(&mut font_system.0, Action::Motion(Motion::BufferStart));
-            editor.set_redraw(true);
             if !shift {
                 editor.set_selection(Selection::None);
             }
@@ -318,7 +317,6 @@ pub(crate) fn input_kb(
         }
         if should_jump && keys.just_pressed(KeyCode::End) {
             editor.action(&mut font_system.0, Action::Motion(Motion::BufferEnd));
-            editor.set_redraw(true);
             if !shift {
                 editor.set_selection(Selection::None);
             }
@@ -534,7 +532,6 @@ pub(crate) fn input_kb(
                     for c in b {
                         let c: char = (*c).into();
                         editor.action(&mut font_system.0, Action::Insert(c));
-                        editor.set_redraw(true);
                     }
                 }
             }
diff --git a/src/lib.rs b/src/lib.rs
index 0cc9cf3..be98184 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -7,9 +7,13 @@ mod input;
 mod layout;
 mod render;
 
+mod plugins;
+pub use plugins::*;
+
 use std::{path::PathBuf, time::Duration};
 
 use bevy::{prelude::*, transform::TransformSystem};
+
 use buffer::{
     add_font_system, set_editor_redraw, set_initial_scale, set_redraw, swap_target_handle,
 };
@@ -192,6 +196,9 @@ impl Default for CosmicFontConfig {
     }
 }
 
+#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
+pub struct KbInput;
+
 /// Plugin struct that adds systems and initializes resources related to cosmic edit functionality.
 #[derive(Default)]
 pub struct CosmicEditPlugin {
@@ -224,7 +231,10 @@ impl Plugin for CosmicEditPlugin {
                 .chain(),
         )
         .add_systems(PreUpdate, (input_mouse,).chain())
-        .add_systems(Update, (input_kb, reshape, blink_cursor).chain())
+        .add_systems(
+            Update,
+            (input_kb, reshape, blink_cursor).chain().in_set(KbInput),
+        )
         .add_systems(
             PostUpdate,
             (
@@ -264,9 +274,18 @@ impl Plugin for CosmicEditPlugin {
             app.insert_resource(WasmPasteAsyncChannel { tx, rx })
                 .add_systems(Update, poll_wasm_paste);
         }
+
+        add_feature_plugins(app);
     }
 }
 
+fn add_feature_plugins(app: &mut App) -> &mut App {
+    #[cfg(feature = "placeholder")]
+    app.add_plugins(plugins::placeholder::PlaceholderPlugin);
+
+    app
+}
+
 fn create_cosmic_font_system(cosmic_font_config: CosmicFontConfig) -> FontSystem {
     let locale = sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"));
     let mut db = cosmic_text::fontdb::Database::new();
diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs
new file mode 100644
index 0000000..2c07c75
--- /dev/null
+++ b/src/plugins/mod.rs
@@ -0,0 +1,2 @@
+#[cfg(feature = "placeholder")]
+pub mod placeholder;
diff --git a/src/plugins/placeholder/mod.rs b/src/plugins/placeholder/mod.rs
new file mode 100644
index 0000000..65d7b70
--- /dev/null
+++ b/src/plugins/placeholder/mod.rs
@@ -0,0 +1,199 @@
+use bevy::prelude::*;
+use cosmic_text::{Attrs, Edit};
+
+use crate::{
+    CosmicBuffer, CosmicEditor, CosmicFontSystem, CosmicTextChanged, DefaultAttrs, KbInput,
+};
+
+#[derive(Component)]
+pub struct Placeholder {
+    pub text: &'static str,
+    pub attrs: Attrs<'static>,
+    active: bool,
+}
+
+impl Placeholder {
+    pub fn new(text: impl Into<&'static str>, attrs: Attrs<'static>) -> Self {
+        Self {
+            active: false,
+            text: text.into(),
+            attrs,
+        }
+    }
+}
+
+pub struct PlaceholderPlugin;
+
+impl Plugin for PlaceholderPlugin {
+    fn build(&self, app: &mut App) {
+        app.add_systems(
+            Update,
+            (
+                add_placeholder_to_buffer,
+                add_placeholder_to_editor,
+                move_cursor_to_start_of_placeholder,
+                remove_placeholder_on_input,
+            )
+                .chain()
+                .after(KbInput),
+        );
+    }
+}
+
+fn add_placeholder_to_buffer(
+    mut q: Query<(&mut CosmicBuffer, &mut Placeholder)>,
+    mut font_system: ResMut<CosmicFontSystem>,
+) {
+    for (mut buffer, mut placeholder) in q.iter_mut() {
+        if placeholder.active {
+            return;
+        }
+
+        if buffer.get_text().is_empty() {
+            buffer.set_text(&mut font_system, placeholder.text, placeholder.attrs);
+            placeholder.active = true;
+        }
+    }
+}
+
+fn add_placeholder_to_editor(
+    mut q: Query<(&mut CosmicEditor, &mut Placeholder)>,
+    mut font_system: ResMut<CosmicFontSystem>,
+) {
+    for (mut editor, mut placeholder) in q.iter_mut() {
+        if placeholder.active {
+            // PERF: Removing this guard fixes single char placeholder deletion
+            // BUT makes the check and buffer update run every frame
+            // return;
+        }
+
+        editor.with_buffer_mut(|buffer| {
+            if buffer.lines.len() > 1 {
+                return;
+            }
+
+            if buffer.lines[0].clone().into_text().is_empty() {
+                buffer.set_text(
+                    &mut font_system,
+                    placeholder.text,
+                    placeholder.attrs,
+                    cosmic_text::Shaping::Advanced,
+                );
+                placeholder.active = true;
+                buffer.set_redraw(true);
+            }
+        })
+    }
+}
+
+fn move_cursor_to_start_of_placeholder(mut q: Query<(&mut CosmicEditor, &mut Placeholder)>) {
+    for (mut editor, placeholder) in q.iter_mut() {
+        if placeholder.active {
+            editor.set_cursor(cosmic_text::Cursor::new(0, 0));
+        }
+    }
+}
+
+fn remove_placeholder_on_input(
+    mut q: Query<(&mut CosmicEditor, &mut Placeholder, &DefaultAttrs)>,
+    evr: EventReader<CosmicTextChanged>,
+    mut font_system: ResMut<CosmicFontSystem>,
+) {
+    for (mut editor, mut placeholder, attrs) in q.iter_mut() {
+        if !placeholder.active {
+            return;
+        }
+        if evr.is_empty() {
+            return;
+        }
+
+        let mut lines = 0;
+
+        let last_line = editor.with_buffer_mut(|b| {
+            lines = b.lines.len();
+
+            if lines > 1 {
+                let mut full_text: String = b
+                    .lines
+                    .iter()
+                    .map(|l| {
+                        let mut s = l.clone().into_text().replace(placeholder.text, "");
+                        // Extra newline on enter to prevent reading as an empty buffer
+                        s.push('\n');
+                        s
+                    })
+                    .collect();
+
+                if lines > 2 {
+                    // for pasted text, remove trailing newline
+                    full_text = full_text
+                        .strip_suffix('\n')
+                        .expect("oop something broke in multiline placeholder removal")
+                        .to_string();
+                }
+
+                b.set_text(
+                    &mut font_system,
+                    full_text.as_str(),
+                    attrs.0.as_attrs(),
+                    cosmic_text::Shaping::Advanced,
+                );
+
+                let last_line = full_text.lines().last();
+
+                return last_line.map(|line| line.to_string());
+            }
+
+            let single_line = b.lines[0].clone().into_text().replace(placeholder.text, "");
+
+            if single_line.is_empty() {
+                return None;
+            }
+
+            {
+                // begin hacky fix for delete key in empty placeholder widget
+
+                let p = placeholder
+                    .text
+                    .chars()
+                    .next()
+                    .expect("Couldn't get first char of placeholder.");
+
+                let laceholder = placeholder
+                    .text
+                    .strip_prefix(p)
+                    .expect("Couldn't remove first char of placeholder.");
+
+                if single_line.as_str() == laceholder {
+                    b.set_text(
+                        &mut font_system,
+                        placeholder.text,
+                        placeholder.attrs,
+                        cosmic_text::Shaping::Advanced,
+                    );
+                    return None;
+                }
+            } // end hacky fix
+
+            b.set_text(
+                &mut font_system,
+                single_line.as_str(),
+                attrs.0.as_attrs(),
+                cosmic_text::Shaping::Advanced,
+            );
+
+            Some(single_line)
+        });
+
+        let Some(last_line) = last_line else {
+            return;
+        };
+
+        editor.set_cursor(cosmic_text::Cursor::new(
+            (lines - 1).max(0),
+            last_line.bytes().len(),
+        ));
+
+        placeholder.active = false;
+    }
+}
-- 
GitLab