From e935320718dffb27c0ecaf63718e0f8f1696d853 Mon Sep 17 00:00:00 2001 From: Caleb Yates <calebyates42@gmail.com> Date: Fri, 13 Dec 2024 00:46:13 +1000 Subject: [PATCH] bevy::picking integration and refactoring (#167) * wip: seperating cursor event handling preparing to directly use bevy::picking instead * mark: removed all padding and offset from render code * bug: not rendering until focussed? * feat: text now follows horizontal alignment * add: example basic_sprite_editor * feat: now centers vertically as well * todo: get proper clicking input to work Translating from widget to buffer coord space is annoying ngl * feat: top_padding works correctly with click input * refactor: moved output under render_implemetations module * bug: for some reason, renders the same image to all editors? * fix: editors render properly now * mark: shift key supported in sprites * feat: all click funcionatlity for sprites work! * feat: primary input uses bevy::picking * refactor: moved `RelativeCoord` into render_implementations/coords.rs * refactor: moved `CosmicWidgetSize` into render_implementations/widget_size.rs * refactor: Seperating input.rs into submodules * wip: refactoring input into drag * todo: finish hover impl * todo: implement proper hovering * feat: cursor hovering is well behaved! * mark: doesn't deselect on dragend * add: input/cursor_visbility.rs and various module refactors * feat: focus_on_click observer * mark: doctests all pass * refactor: scan.rs in render_implementations and removed editor examples * fmt * fix: compiles on wasm now * fix: ui clicking works now * todo: use new EditorBuffer API * todo: finish refactor into editor_buffer.rs module * todo: refactor all code to use EditorBuffer * feat: all compiles! * todo: fix bug where cursor doesn't show on empty editors * fix: cursor blinks in empty wdigets now * add: veritcal scrolling only kicks in with canvas larger than render target * fix: infinite line works * chore: added CosmicWrap::InfiniteLine where removed before because of pancis * chore: minor privacy restrictions * fix: no warnings in examples --- .gitignore | 3 +- Cargo.lock | 37 ++ Cargo.toml | 8 +- examples/basic_sprite.rs | 44 +- examples/basic_ui.rs | 10 +- examples/bevy_editor_pls.rs | 73 --- examples/every_option.rs | 90 +-- examples/font_per_widget.rs | 63 +- examples/image_background.rs | 36 +- examples/multiple_sprites.rs | 44 +- examples/password.rs | 38 +- examples/placeholder.rs | 47 +- examples/readonly.rs | 34 +- examples/scroll.rs | 69 ++ examples/sprite_and_ui_clickable.rs | 78 +-- src/cosmic_edit.rs | 163 +++-- src/cursor.rs | 107 --- src/debug.rs | 45 ++ src/double_click.rs | 91 +++ src/editor_buffer.rs | 172 +++++ src/{ => editor_buffer}/buffer.rs | 272 ++++---- src/editor_buffer/editor.rs | 62 ++ src/events.rs | 20 - src/focus.rs | 61 +- src/input.rs | 753 +++------------------- src/input/click.rs | 144 +++++ src/input/clipboard.rs | 182 ++++++ src/input/cursor_icon.rs | 96 +++ src/input/cursor_visibility.rs | 48 ++ src/input/drag.rs | 148 +++++ src/input/hover.rs | 106 +++ src/input/keyboard.rs | 293 +++++++++ src/input/scroll.rs | 35 + src/lib.rs | 92 +-- src/password.rs | 121 ++-- src/placeholder.rs | 7 +- src/primary.rs | 70 +- src/render.rs | 233 +++++-- src/render_implementations.rs | 97 +++ src/render_implementations/coords.rs | 94 +++ src/render_implementations/output.rs | 46 ++ src/render_implementations/scan.rs | 43 ++ src/render_implementations/widget_size.rs | 71 ++ src/render_targets.rs | 242 ------- src/utils.rs | 60 +- src/widget.rs | 215 ------ 46 files changed, 2826 insertions(+), 2037 deletions(-) delete mode 100644 examples/bevy_editor_pls.rs create mode 100644 examples/scroll.rs delete mode 100644 src/cursor.rs create mode 100644 src/debug.rs create mode 100644 src/double_click.rs create mode 100644 src/editor_buffer.rs rename src/{ => editor_buffer}/buffer.rs (52%) create mode 100644 src/editor_buffer/editor.rs delete mode 100644 src/events.rs create mode 100644 src/input/click.rs create mode 100644 src/input/clipboard.rs create mode 100644 src/input/cursor_icon.rs create mode 100644 src/input/cursor_visibility.rs create mode 100644 src/input/drag.rs create mode 100644 src/input/hover.rs create mode 100644 src/input/keyboard.rs create mode 100644 src/input/scroll.rs create mode 100644 src/render_implementations.rs create mode 100644 src/render_implementations/coords.rs create mode 100644 src/render_implementations/output.rs create mode 100644 src/render_implementations/scan.rs create mode 100644 src/render_implementations/widget_size.rs delete mode 100644 src/render_targets.rs delete mode 100644 src/widget.rs diff --git a/.gitignore b/.gitignore index 1a20a17..e58467c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ velo.json ichart.json data -out \ No newline at end of file +out +env.nu diff --git a/Cargo.lock b/Cargo.lock index 134884d..e5277dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -505,6 +505,9 @@ dependencies = [ "image", "insta", "js-sys", + "num", + "num-derive", + "num-traits", "sys-locale", "unicode-segmentation", "wasm-bindgen", @@ -2673,6 +2676,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2683,6 +2700,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -2703,6 +2729,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" diff --git a/Cargo.toml b/Cargo.toml index 0ee3d7d..f9f9d34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,9 +11,8 @@ keywords = ["bevy"] exclude = ["assets/*"] [features] -## Enable to avoid panicing when multiple cameras are used in the same world. -## Requires you to add `CosmicPrimaryCamera` marker component to the primary camera -multicam = [] +## For internal use only +internal-debugging = ["bevy/track_change_detection"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -38,6 +37,9 @@ crossbeam-channel = "0.5.8" image = "0.25.1" sys-locale = "0.3.0" document-features = "0.2.8" +num = "0.4.3" +num-derive = "0.4.2" +num-traits = "0.2.19" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] arboard = "3.2.0" diff --git a/examples/basic_sprite.rs b/examples/basic_sprite.rs index 99594ba..30e8eb3 100644 --- a/examples/basic_sprite.rs +++ b/examples/basic_sprite.rs @@ -12,7 +12,6 @@ fn setup( let primary_window = windows.single(); let camera_bundle = ( Camera2d, - CosmicPrimaryCamera, Camera { clear_color: ClearColorConfig::Custom(Color::WHITE), ..default() @@ -24,22 +23,23 @@ fn setup( attrs = attrs.family(Family::Name("Victor Mono")); attrs = attrs.color(CosmicColor::rgb(0x94, 0x00, 0xD3)); - let cosmic_edit = ( - CosmicEditBuffer::new(&mut font_system, Metrics::new(14., 18.)).with_text( - &mut font_system, - "πππ x => y", - attrs, - ), - TextEdit2d, - Sprite { - // You must specify custom size - // so the editor knows what size images to render to the sprite - custom_size: Some(Vec2::new(primary_window.width(), primary_window.height())), - ..default() - }, - ); - - let cosmic_edit = commands.spawn(cosmic_edit).id(); + let cosmic_edit = commands + .spawn(( + CosmicEditBuffer::new(&mut font_system, Metrics::new(14., 18.)).with_text( + &mut font_system, + "πππ x => y", + attrs, + ), + TextEdit2d, + Sprite { + // You must specify custom size + // so the editor knows what size images to render to the sprite + custom_size: Some(Vec2::new(primary_window.width(), primary_window.height())), + ..default() + }, + )) + .observe(focus_on_click) + .id(); commands.insert_resource(FocusedWidget(Some(cosmic_edit))); } @@ -54,16 +54,8 @@ fn main() { App::new() .add_plugins(DefaultPlugins) - .add_plugins(bevy_editor_pls::EditorPlugin::default()) .add_plugins(CosmicEditPlugin { font_config }) .add_systems(Startup, setup) - .add_systems( - Update, - ( - print_editor_text, - change_active_editor_sprite, - deselect_editor_on_esc, - ), - ) + .add_systems(Update, (print_editor_text, deselect_editor_on_esc)) .run(); } diff --git a/examples/basic_ui.rs b/examples/basic_ui.rs index ccb4625..43324ab 100644 --- a/examples/basic_ui.rs +++ b/examples/basic_ui.rs @@ -32,6 +32,7 @@ fn setup(mut commands: Commands, mut font_system: ResMut<CosmicFontSystem>) { ..default() }, )) + .observe(focus_on_click) .id(); commands.insert_resource(FocusedWidget(Some(cosmic_edit))); @@ -49,13 +50,6 @@ fn main() { .add_plugins(DefaultPlugins) .add_plugins(CosmicEditPlugin { font_config }) .add_systems(Startup, setup) - .add_systems( - Update, - ( - print_editor_text, - change_active_editor_ui, - deselect_editor_on_esc, - ), - ) + .add_systems(Update, (print_editor_text, deselect_editor_on_esc)) .run(); } diff --git a/examples/bevy_editor_pls.rs b/examples/bevy_editor_pls.rs deleted file mode 100644 index 5f8f573..0000000 --- a/examples/bevy_editor_pls.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! With [bevy_editor_pls] integration -//! Requires `multicam` features enabled - -use bevy::prelude::*; -use bevy_cosmic_edit::{ - cosmic_text::{Attrs, Family, Metrics}, - prelude::*, -}; - -fn setup(mut commands: Commands, mut font_system: ResMut<CosmicFontSystem>) { - let camera_bundle = ( - Camera2d, - // marker from bevy_cosmic_edit - bevy_cosmic_edit::CosmicPrimaryCamera, - // marker from bevy - // required else for some reason no UI renders to the screen - bevy::prelude::IsDefaultUiCamera, - Camera { - clear_color: ClearColorConfig::Custom(bevy::color::palettes::css::PINK.into()), - ..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(( - TextEdit, - CosmicEditBuffer::new(&mut font_system, Metrics::new(40., 40.)).with_rich_text( - &mut font_system, - vec![("Banana", attrs)], - attrs, - ), - Node { - width: Val::Percent(60.), - height: Val::Percent(60.), - top: Val::Percent(10.), - left: Val::Percent(30.), - ..default() - }, - )) - .id(); - - commands.insert_resource(FocusedWidget(Some(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 }) - // add editor plugin - .add_plugins(bevy_editor_pls::EditorPlugin::default()) - .add_systems(Startup, setup) - .add_systems( - Update, - ( - print_editor_text, - change_active_editor_ui, - deselect_editor_on_esc, - ), - ) - .run(); -} diff --git a/examples/every_option.rs b/examples/every_option.rs index 8f88213..1e57390 100644 --- a/examples/every_option.rs +++ b/examples/every_option.rs @@ -3,7 +3,8 @@ use bevy_cosmic_edit::{ cosmic_text::{Attrs, AttrsOwned, Metrics}, prelude::*, CosmicBackgroundColor, CosmicBackgroundImage, CosmicTextAlign, CosmicWrap, CursorColor, - DefaultAttrs, HoverCursor, MaxChars, MaxLines, SelectedTextColor, SelectionColor, + DefaultAttrs, HorizontalAlign, HoverCursor, MaxChars, MaxLines, SelectedTextColor, + SelectionColor, VerticalAlign, }; #[derive(Resource)] @@ -14,47 +15,52 @@ fn setup(mut commands: Commands, mut font_system: ResMut<CosmicFontSystem>) { let attrs = Attrs::new().color(Color::srgb(0.27, 0.27, 0.27).to_cosmic()); - commands.spawn(( - ( - // cosmic edit components - CosmicEditBuffer::new(&mut font_system, Metrics::new(16., 16.)).with_text( - &mut font_system, - "Begin counting.", - attrs, + commands + .spawn(( + ( + // cosmic edit components + CosmicEditBuffer::new(&mut font_system, Metrics::new(16., 16.)).with_text( + &mut font_system, + "Begin counting.", + attrs, + ), + CursorColor(bevy::color::palettes::css::LIME.into()), + SelectionColor(bevy::color::palettes::css::DEEP_PINK.into()), + CosmicBackgroundColor(bevy::color::palettes::css::YELLOW_GREEN.into()), + CosmicTextAlign { + horizontal: Some(HorizontalAlign::Center), + vertical: VerticalAlign::Center, + }, + CosmicBackgroundImage(None), + DefaultAttrs(AttrsOwned::new(attrs)), + MaxChars(15), + MaxLines(1), + CosmicWrap::Wrap, + HoverCursor(CursorIcon::System(SystemCursorIcon::Pointer)), + SelectedTextColor(Color::WHITE), ), - CursorColor(bevy::color::palettes::css::LIME.into()), - SelectionColor(bevy::color::palettes::css::DEEP_PINK.into()), - CosmicBackgroundColor(bevy::color::palettes::css::YELLOW_GREEN.into()), - CosmicTextAlign::Center { padding: 0 }, - CosmicBackgroundImage(None), - DefaultAttrs(AttrsOwned::new(attrs)), - MaxChars(15), - MaxLines(1), - CosmicWrap::Wrap, - HoverCursor(CursorIcon::System(SystemCursorIcon::Pointer)), - SelectedTextColor(Color::WHITE), - ), - ( - TextEdit, - // the image mode is optional, but due to bevy 0.15 mechanics is required to - // render the border within the `ImageNode` - // See bevy issue https://github.com/bevyengine/bevy/issues/16643#issuecomment-2518163688 - ImageNode::default().with_mode(bevy::ui::widget::NodeImageMode::Stretch), - Node { - // Size and position of text box - border: UiRect::all(Val::Px(4.)), - width: Val::Percent(20.), - height: Val::Px(50.), - left: Val::Percent(40.), - top: Val::Px(100.), - ..default() - }, - BorderColor(bevy::color::palettes::css::LIMEGREEN.into()), - BorderRadius::all(Val::Px(10.)), - // This is overriden by setting `CosmicBackgroundColor` so you don't see any white - BackgroundColor(Color::WHITE), - ), - )); + ( + TextEdit, + // the image mode is optional, but due to bevy 0.15 mechanics is required to + // render the border within the `ImageNode` + // See bevy issue https://github.com/bevyengine/bevy/issues/16643#issuecomment-2518163688 + ImageNode::default().with_mode(bevy::ui::widget::NodeImageMode::Stretch), + Node { + // Size and position of text box + border: UiRect::all(Val::Px(4.)), + width: Val::Percent(20.), + height: Val::Px(50.), + left: Val::Percent(40.), + top: Val::Px(100.), + ..default() + }, + BorderColor(bevy::color::palettes::css::LIMEGREEN.into()), + BorderRadius::all(Val::Px(10.)), + // This is overriden by setting `CosmicBackgroundColor` so you don't see any white + BackgroundColor(Color::WHITE), + ), + )) + .observe(focus_on_click); commands.insert_resource(TextChangeTimer(Timer::from_seconds( 1., @@ -91,6 +97,6 @@ fn main() { .add_plugins(CosmicEditPlugin::default()) .add_systems(Startup, setup) .add_systems(Update, text_swapper) - .add_systems(Update, (change_active_editor_ui, deselect_editor_on_esc)) + .add_systems(Update, deselect_editor_on_esc) .run(); } diff --git a/examples/font_per_widget.rs b/examples/font_per_widget.rs index caf65fd..8b08a47 100644 --- a/examples/font_per_widget.rs +++ b/examples/font_per_widget.rs @@ -157,41 +157,44 @@ fn setup(mut commands: Commands, mut font_system: ResMut<CosmicFontSystem>) { ]; commands.entity(root).with_children(|parent| { - parent.spawn(( - CosmicEditBuffer::new(&mut font_system, Metrics::new(18., 22.)).with_rich_text( - &mut font_system, - lines, - attrs, - ), - TextEdit, - Node { - width: Val::Percent(50.), - height: Val::Percent(100.), - ..default() - }, - BackgroundColor(Color::WHITE), - )); + parent + .spawn(( + TextEdit, + CosmicEditBuffer::new(&mut font_system, Metrics::new(18., 22.)).with_rich_text( + &mut font_system, + lines, + attrs, + ), + Node { + width: Val::Percent(50.), + height: Val::Percent(100.), + ..default() + }, + BackgroundColor(Color::WHITE), + )) + .observe(focus_on_click); }); let mut attrs_2 = Attrs::new(); attrs_2 = attrs_2.family(Family::Name("Times New Roman")); attrs_2.color_opt = Some(bevy::color::palettes::css::PURPLE.to_cosmic()); commands.entity(root).with_children(|parent| { - parent.spawn(( - CosmicEditBuffer::new(&mut font_system, Metrics::new(28., 36.)).with_text( - &mut font_system, - "Widget 2.\nClick on me =>", - attrs_2, - ), - ImageNode::default(), - Button, - Node { - width: Val::Percent(50.), - height: Val::Percent(100.), - ..default() - }, - BackgroundColor(Color::WHITE.with_alpha(0.8)), - )); + parent + .spawn(( + TextEdit, + CosmicEditBuffer::new(&mut font_system, Metrics::new(28., 36.)).with_text( + &mut font_system, + "Widget 2.\nClick on me =>", + attrs_2, + ), + Node { + width: Val::Percent(50.), + height: Val::Percent(100.), + ..default() + }, + BackgroundColor(Color::WHITE.with_alpha(0.8)), + )) + .observe(focus_on_click); }); } @@ -200,6 +203,6 @@ fn main() { .add_plugins(DefaultPlugins) .add_plugins(CosmicEditPlugin { ..default() }) .add_systems(Startup, setup) - .add_systems(Update, (change_active_editor_ui, deselect_editor_on_esc)) + .add_systems(Update, deselect_editor_on_esc) .run(); } diff --git a/examples/image_background.rs b/examples/image_background.rs index 19ab10b..ef5fa86 100644 --- a/examples/image_background.rs +++ b/examples/image_background.rs @@ -10,22 +10,24 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) { let bg_image_handle = asset_server.load("img/bevy_logo_light.png"); - commands.spawn(( - TextEdit, - CosmicEditBuffer::default(), - DefaultAttrs(AttrsOwned::new( - Attrs::new().color(bevy::color::palettes::basic::LIME.to_cosmic()), - )), - CosmicBackgroundImage(Some(bg_image_handle)), - Node { - // Size and position of text box - width: Val::Px(300.), - height: Val::Px(50.), - left: Val::Px(100.), - top: Val::Px(100.), - ..default() - }, - )); + commands + .spawn(( + TextEdit, + CosmicEditBuffer::default(), + DefaultAttrs(AttrsOwned::new( + Attrs::new().color(bevy::color::palettes::basic::LIME.to_cosmic()), + )), + CosmicBackgroundImage(Some(bg_image_handle)), + Node { + // Size and position of text box + width: Val::Px(300.), + height: Val::Px(50.), + left: Val::Px(100.), + top: Val::Px(100.), + ..default() + }, + )) + .observe(focus_on_click); } fn main() { @@ -33,6 +35,6 @@ fn main() { .add_plugins(DefaultPlugins) .add_plugins(CosmicEditPlugin::default()) .add_systems(Startup, setup) - .add_systems(Update, (change_active_editor_ui, deselect_editor_on_esc)) + .add_systems(Update, deselect_editor_on_esc) .run(); } diff --git a/examples/multiple_sprites.rs b/examples/multiple_sprites.rs index e787e3c..b5174a6 100644 --- a/examples/multiple_sprites.rs +++ b/examples/multiple_sprites.rs @@ -42,26 +42,29 @@ fn setup( Transform::from_translation(Vec3::new(-primary_window.width() / 4., 0., 1.)), )); - commands.spawn(( - CosmicEditBuffer::new(&mut font_system, Metrics::new(14., 18.)).with_text( - &mut font_system, - "Widget_2. Click on me", - attrs, - ), - CosmicBackgroundColor(bevy::color::palettes::basic::GRAY.with_alpha(0.5).into()), - Sprite { - custom_size: Some(Vec2 { - x: primary_window.width() / 2., - y: primary_window.height() / 2., - }), - ..default() - }, - Transform::from_translation(Vec3::new( - primary_window.width() / 4., - -primary_window.height() / 4., - 1., - )), - )); + commands + .spawn(( + TextEdit2d, + CosmicEditBuffer::new(&mut font_system, Metrics::new(14., 18.)).with_text( + &mut font_system, + "Widget_2. Click on me", + attrs, + ), + CosmicBackgroundColor(bevy::color::palettes::basic::GRAY.with_alpha(0.5).into()), + Sprite { + custom_size: Some(Vec2 { + x: primary_window.width() / 2., + y: primary_window.height() / 2., + }), + ..default() + }, + Transform::from_translation(Vec3::new( + primary_window.width() / 4., + -primary_window.height() / 4., + 1., + )), + )) + .observe(focus_on_click); } fn main() { @@ -76,6 +79,5 @@ fn main() { .add_plugins(DefaultPlugins) .add_plugins(CosmicEditPlugin { font_config }) .add_systems(Startup, setup) - .add_systems(Update, change_active_editor_sprite) .run(); } diff --git a/examples/password.rs b/examples/password.rs index e13af7e..90c2b62 100644 --- a/examples/password.rs +++ b/examples/password.rs @@ -1,27 +1,30 @@ use bevy::prelude::*; use bevy_cosmic_edit::{ - cosmic_text::Attrs, prelude::*, CosmicWrap, InputSet, MaxLines, Password, Placeholder, + cosmic_text::Attrs, password::Password, placeholder::Placeholder, prelude::*, CosmicWrap, + MaxLines, }; fn setup(mut commands: Commands) { commands.spawn(Camera2d); // Sprite editor - commands.spawn(( - TextEdit2d, - CosmicEditBuffer::default(), - MaxLines(1), - CosmicWrap::InfiniteLine, - // Sets size of text box - Sprite { - custom_size: Some(Vec2::new(300., 100.)), - ..default() - }, - // Position of text box - Transform::from_xyz(0., 100., 0.), - Password::default(), - Placeholder::new("Password", Attrs::new()), - )); + commands + .spawn(( + TextEdit2d, + CosmicEditBuffer::default(), + MaxLines(1), + CosmicWrap::InfiniteLine, + // Sets size of text box + Sprite { + custom_size: Some(Vec2::new(300., 100.)), + ..default() + }, + // Position of text box + Transform::from_xyz(0., 100., 0.), + Password::default(), + Placeholder::new("Password", Attrs::new()), + )) + .observe(focus_on_click); } fn main() { @@ -32,10 +35,9 @@ fn main() { .add_systems( Update, ( - change_active_editor_sprite, deselect_editor_on_esc, // If you don't .after(InputSet) you'll just see the hashed-out safe text - print_editor_text.after(InputSet), + print_editor_text.after(bevy_cosmic_edit::input::InputSet), ), ) .run(); diff --git a/examples/placeholder.rs b/examples/placeholder.rs index 1a09fbf..4509566 100644 --- a/examples/placeholder.rs +++ b/examples/placeholder.rs @@ -1,8 +1,8 @@ use bevy::prelude::*; use bevy_cosmic_edit::{ cosmic_text::{Attrs, Family, Metrics}, + placeholder::Placeholder, prelude::*, - Placeholder, }; fn setup(mut commands: Commands, mut font_system: ResMut<CosmicFontSystem>) { @@ -19,23 +19,25 @@ fn setup(mut commands: Commands, mut font_system: ResMut<CosmicFontSystem>) { attrs = attrs.family(Family::Name("Victor Mono")); attrs = attrs.color(CosmicColor::rgb(0x94, 0x00, 0xD3)); - commands.spawn(( - TextEdit, - CosmicEditBuffer::new(&mut font_system, Metrics::new(20., 20.)).with_rich_text( - &mut font_system, - vec![("", attrs)], - attrs, - ), - Placeholder::new( - "Placeholder", - attrs.color(bevy::color::palettes::basic::GRAY.to_cosmic()), - ), - Node { - width: Val::Percent(100.), - height: Val::Percent(100.), - ..default() - }, - )); + commands + .spawn(( + TextEdit, + CosmicEditBuffer::new(&mut font_system, Metrics::new(20., 20.)).with_rich_text( + &mut font_system, + vec![("", attrs)], + attrs, + ), + Placeholder::new( + "Placeholder", + attrs.color(bevy::color::palettes::basic::GRAY.to_cosmic()), + ), + Node { + width: Val::Percent(100.), + height: Val::Percent(100.), + ..default() + }, + )) + .observe(focus_on_click); } fn main() { @@ -50,13 +52,6 @@ fn main() { .add_plugins(DefaultPlugins) .add_plugins(CosmicEditPlugin { font_config }) .add_systems(Startup, setup) - .add_systems( - Update, - ( - print_editor_text, - change_active_editor_ui, - deselect_editor_on_esc, - ), - ) + .add_systems(Update, (print_editor_text, deselect_editor_on_esc)) .run(); } diff --git a/examples/readonly.rs b/examples/readonly.rs index dd04535..1c9a5c2 100644 --- a/examples/readonly.rs +++ b/examples/readonly.rs @@ -12,21 +12,23 @@ fn setup(mut commands: Commands, mut font_system: ResMut<CosmicFontSystem>) { attrs = attrs.color(bevy::color::palettes::basic::PURPLE.to_cosmic()); // spawn editor - commands.spawn(( - TextEdit, - ReadOnly, - CosmicEditBuffer::new(&mut font_system, Metrics::new(14., 18.)).with_text( - &mut font_system, - "πππ x => y\nRead only widget", - attrs, - ), - Node { - width: Val::Percent(100.), - height: Val::Percent(100.), - ..default() - }, - BackgroundColor(Color::WHITE), - )); + commands + .spawn(( + TextEdit, + ReadOnly, + CosmicEditBuffer::new(&mut font_system, Metrics::new(14., 18.)).with_text( + &mut font_system, + "πππ x => y\nRead only widget", + attrs, + ), + Node { + width: Val::Percent(100.), + height: Val::Percent(100.), + ..default() + }, + BackgroundColor(Color::WHITE), + )) + .observe(focus_on_click); } fn main() { @@ -41,6 +43,6 @@ fn main() { .add_plugins(DefaultPlugins) .add_plugins(CosmicEditPlugin { font_config }) .add_systems(Startup, setup) - .add_systems(Update, (change_active_editor_ui, deselect_editor_on_esc)) + .add_systems(Update, deselect_editor_on_esc) .run(); } diff --git a/examples/scroll.rs b/examples/scroll.rs new file mode 100644 index 0000000..47d8e00 --- /dev/null +++ b/examples/scroll.rs @@ -0,0 +1,69 @@ +//! Currently scrolling is bugged + +use bevy::prelude::*; +use bevy_cosmic_edit::{ + cosmic_text::{Attrs, AttrsOwned, Metrics}, + prelude::*, + CosmicTextAlign, ScrollEnabled, +}; + +fn setup(mut commands: Commands, mut font_system: ResMut<CosmicFontSystem>) { + commands.spawn(Camera2d); + + let attrs = Attrs::new().color(CosmicColor::rgb(0, 50, 200)); + + // Ui editor + commands + .spawn(( + TextEdit, + CosmicEditBuffer::new(&mut font_system, Metrics::new(20., 18.)).with_text( + &mut font_system, + format!( + "Top line\n{}BottomLine", + "UI editor that is long vertical\n".repeat(7) + ) + .as_str(), + attrs, + ), + ScrollEnabled::Enabled, + CosmicTextAlign::top_left(), + DefaultAttrs(AttrsOwned::new( + Attrs::new().color(bevy::color::palettes::css::LIMEGREEN.to_cosmic()), + )), + // CosmicWrap::InfiniteLine, + Node { + // Size and position of text box + width: Val::Px(300.), + height: Val::Px(150.), + left: Val::Px(100.), + top: Val::Px(100.), + ..default() + }, + )) + .observe(focus_on_click); + + // Sprite editor + commands + .spawn(( + TextEdit2d, + // MaxLines(1), + CosmicWrap::InfiniteLine, + // Sets size of text box + Sprite { + custom_size: Some(Vec2::new(300., 100.)), + ..default() + }, + // Position of text box + Transform::from_xyz(0., 100., 0.), + )) + .observe(focus_on_click); +} + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_plugins(CosmicEditPlugin::default()) + .add_systems(Startup, setup) + .add_systems(Update, deselect_editor_on_esc) + .run(); +} diff --git a/examples/sprite_and_ui_clickable.rs b/examples/sprite_and_ui_clickable.rs index 4a01b17..79a5755 100644 --- a/examples/sprite_and_ui_clickable.rs +++ b/examples/sprite_and_ui_clickable.rs @@ -1,46 +1,53 @@ use bevy::prelude::*; use bevy_cosmic_edit::{ cosmic_text::{Attrs, AttrsOwned}, + input::hover::{TextHoverIn, TextHoverOut}, + input::CosmicTextChanged, prelude::*, - CosmicTextAlign, CosmicTextChanged, CosmicWrap, MaxLines, TextHoverIn, TextHoverOut, + CosmicTextAlign, MaxLines, }; fn setup(mut commands: Commands) { commands.spawn(Camera2d); // Ui editor - commands.spawn(( - TextEdit, - CosmicEditBuffer::default(), - DefaultAttrs(AttrsOwned::new( - Attrs::new().color(bevy::color::palettes::css::LIMEGREEN.to_cosmic()), - )), - MaxLines(1), - CosmicWrap::InfiniteLine, - CosmicTextAlign::Left { padding: 5 }, - Node { - // Size and position of text box - width: Val::Px(300.), - height: Val::Px(50.), - left: Val::Px(100.), - top: Val::Px(100.), - ..default() - }, - )); + commands + .spawn(( + TextEdit, + CosmicEditBuffer::default(), + DefaultAttrs(AttrsOwned::new( + Attrs::new().color(bevy::color::palettes::css::LIMEGREEN.to_cosmic()), + )), + MaxLines(1), + CosmicWrap::InfiniteLine, + CosmicTextAlign::left_center(), + Node { + // Size and position of text box + width: Val::Px(300.), + height: Val::Px(50.), + left: Val::Px(100.), + top: Val::Px(100.), + ..default() + }, + )) + .observe(focus_on_click); // Sprite editor - commands.spawn(( - CosmicEditBuffer::default(), - MaxLines(1), - CosmicWrap::InfiniteLine, - // Sets size of text box - Sprite { - custom_size: Some(Vec2::new(300., 100.)), - ..default() - }, - // Position of text box - Transform::from_xyz(0., 100., 0.), - )); + commands + .spawn(( + TextEdit2d, + MaxLines(1), + CosmicWrap::InfiniteLine, + CosmicTextAlign::left_center(), + // Sets size of text box + Sprite { + custom_size: Some(Vec2::new(300., 100.)), + ..default() + }, + // Position of text box + Transform::from_xyz(0., 100., 0.), + )) + .observe(focus_on_click); } fn ev_test( @@ -64,14 +71,7 @@ fn main() { .add_plugins(DefaultPlugins) .add_plugins(CosmicEditPlugin { ..default() }) .add_systems(Startup, setup) - .add_systems( - Update, - ( - change_active_editor_ui, - change_active_editor_sprite, - deselect_editor_on_esc, - ), - ) + .add_systems(Update, deselect_editor_on_esc) .add_systems(Update, ev_test) .run(); } diff --git a/src/cosmic_edit.rs b/src/cosmic_edit.rs index 83aa1c0..0de3284 100644 --- a/src/cosmic_edit.rs +++ b/src/cosmic_edit.rs @@ -1,10 +1,9 @@ use crate::prelude::*; -use cosmic_text::{Attrs, AttrsOwned, Editor, FontSystem}; +use cosmic_text::{Align, Attrs, AttrsOwned, FontSystem}; pub(crate) fn plugin(app: &mut App) { app.register_type::<CosmicWrap>() .register_type::<CosmicTextAlign>() - .register_type::<XOffset>() .register_type::<CosmicBackgroundImage>() .register_type::<CosmicBackgroundColor>() .register_type::<CursorColor>() @@ -16,24 +15,130 @@ pub(crate) fn plugin(app: &mut App) { /// Enum representing text wrapping in a cosmic [`Buffer`] #[derive(Component, Reflect, Clone, PartialEq, Default)] +#[component(on_add = check_align_sanity)] pub enum CosmicWrap { InfiniteLine, #[default] Wrap, } -/// Enum representing the text alignment in a cosmic [`Buffer`]. -/// Defaults to [`CosmicTextAlign::Center`] -#[derive(Component, Reflect, Clone)] -pub enum CosmicTextAlign { - Center { padding: i32 }, - TopLeft { padding: i32 }, - Left { padding: i32 }, +fn check_align_sanity( + world: bevy::ecs::world::DeferredWorld, + target: Entity, + _: bevy::ecs::component::ComponentId, +) { + if let Some(CosmicWrap::InfiniteLine) = world.get(target) { + let Some(align) = world.get::<CosmicTextAlign>(target) else { + return; + }; + if matches!( + align.horizontal, + Some(HorizontalAlign::End | HorizontalAlign::Right | HorizontalAlign::Center) + ) { + warn!(message = "Having a widget with `CosmicWrap::InfiniteLine` while using a horizontal alignment like `HorizontalAlign::Center` will likely obscure the text"); + } + } +} + +/// Where to render the [`CosmicEditBuffer`] within the given size. +/// +/// [`cosmic_text`] can [`Align`](cosmic_text::Align) items per line already, +/// e.g. [`Align::Center`], but this only works horizontally. +/// To place the text in the direct center vertically, [bevy_cosmic_edit] +/// manually calculates the vertical offset as configured by +/// [`CosmicTextAlign.vertical`] +#[derive(Component, Reflect)] +pub struct CosmicTextAlign { + /// Managed by [bevy_cosmic_edit]. + /// Will place the text in the direct center vertically. + pub vertical: VerticalAlign, + + /// Defaults to `Some(HorizontalAlign::Center)`. + /// + /// If this `.is_some()`, every frame each line will have this alignment + /// set for it. Set this to `None` to apply your own manual + /// [cosmic_text::Align]ments. + pub horizontal: Option<HorizontalAlign>, } impl Default for CosmicTextAlign { fn default() -> Self { - CosmicTextAlign::Center { padding: 5 } + Self::center() + } +} + +impl CosmicTextAlign { + pub fn new(horizontal: HorizontalAlign, vertical: VerticalAlign) -> Self { + CosmicTextAlign { + vertical, + horizontal: Some(horizontal), + } + } + + pub fn center() -> Self { + CosmicTextAlign { + vertical: VerticalAlign::Center, + horizontal: Some(HorizontalAlign::Center), + } + } + + pub fn top_left() -> Self { + CosmicTextAlign { + vertical: VerticalAlign::Top, + horizontal: Some(HorizontalAlign::Left), + } + } + + /// Horizontally left, vertically center + pub fn left_center() -> Self { + CosmicTextAlign { + vertical: VerticalAlign::Center, + horizontal: Some(HorizontalAlign::Left), + } + } + + pub fn bottom_center() -> Self { + CosmicTextAlign { + vertical: VerticalAlign::Bottom, + horizontal: Some(HorizontalAlign::Center), + } + } +} + +/// Enum representing the text alignment in a cosmic [`Buffer`]. +/// Defaults to [`CosmicTextAlign::Center`] +#[derive(Reflect, Default, Clone, Copy, PartialEq, Eq)] +pub enum VerticalAlign { + /// If [bevy_cosmic_edit] made no manual calcualtions, this would + /// effecively be the default + Top, + + /// Default + #[default] + Center, + + Bottom, +} + +/// Mirrors [`cosmic_text::Align`] +#[derive(Reflect, Debug, Clone, Copy, PartialEq, Eq)] +pub enum HorizontalAlign { + Left, + Center, + Right, + End, + Justified, +} + +impl From<HorizontalAlign> for Align { + fn from(h: HorizontalAlign) -> Self { + match h { + HorizontalAlign::Left => Align::Left, + HorizontalAlign::Center => Align::Center, + HorizontalAlign::Right => Align::Right, + HorizontalAlign::End => Align::End, + HorizontalAlign::Justified => Align::Justified, + } } } @@ -42,20 +147,6 @@ impl Default for CosmicTextAlign { #[derive(Component, Default)] pub struct ReadOnly; // tag component -/// Internal value used to decide what section of a [`Buffer`] to render -#[derive(Component, Reflect, Debug, Default)] -pub(crate) struct XOffset { - /// How much space in logical units from the left of the [`Buffer`] - /// to start rendering text. - pub left: f32, - - /// Width of buffer that includes text that should be rendered, - /// in logical units. - /// - /// Should only be [None] if in default state - pub width: Option<f32>, -} - /// Default text attributes to be used on a [`CosmicEditBuffer`] #[derive(Component, Deref, DerefMut)] pub struct DefaultAttrs(pub AttrsOwned); @@ -130,27 +221,3 @@ impl ScrollEnabled { /// this should be merged with its builtin resource #[derive(Resource, Deref, DerefMut)] pub struct CosmicFontSystem(pub FontSystem); - -/// Wrapper component for an [`Editor`] with a few helpful values for cursor blinking -/// -/// [`cosmic_text::Editor`] is basically a mutable version of [`cosmic_text::Buffer`]. -/// -/// This component should be on a focussed [`CosmicEditBuffer`] -// Managed by crate::focus::add_editor_to_focussed and similar systems -#[derive(Component, Deref, DerefMut)] -pub struct CosmicEditor { - #[deref] - pub editor: Editor<'static>, - pub cursor_visible: bool, - pub cursor_timer: Timer, -} - -impl CosmicEditor { - pub fn new(editor: Editor<'static>) -> Self { - Self { - editor, - cursor_visible: true, - cursor_timer: Timer::new(std::time::Duration::from_millis(530), TimerMode::Repeating), - } - } -} diff --git a/src/cursor.rs b/src/cursor.rs deleted file mode 100644 index ee3504e..0000000 --- a/src/cursor.rs +++ /dev/null @@ -1,107 +0,0 @@ -// This will all be rewritten soon, looking toward per-widget cursor control -// Rewrite should address issue #93 too - -use crate::prelude::*; -use bevy::{ - input::mouse::MouseMotion, - window::{PrimaryWindow, SystemCursorIcon}, - winit::cursor::CursorIcon, -}; - -pub(crate) struct CursorPlugin; - -/// Unit resource whose existence in the world disables the cursor plugin systems. -#[derive(Resource)] -pub struct CursorPluginDisabled; - -impl Plugin for CursorPlugin { - fn build(&self, app: &mut App) { - app.add_systems( - Update, - ( - (crate::render_targets::hover_sprites, hover_ui), - change_cursor, - ) - .chain() - .run_if(not(resource_exists::<CursorPluginDisabled>)), - ) - .add_event::<TextHoverIn>() - .register_type::<TextHoverIn>() - .add_event::<TextHoverOut>() - .register_type::<TextHoverOut>() - .register_type::<HoverCursor>(); - } -} - -/// What cursor icon to show when hovering over a widget -/// -/// By default is [`CursorIcon::System(SystemCursorIcon::Text)`] -#[derive(Component, Reflect, Deref)] -pub struct HoverCursor(pub CursorIcon); - -impl Default for HoverCursor { - fn default() -> Self { - Self(CursorIcon::System(SystemCursorIcon::Text)) - } -} - -/// For use with custom cursor control -/// -/// Event is emitted when cursor enters a text widget. -/// Event contains the cursor from the buffer's [`HoverCursor`] -#[derive(Event, Reflect, Deref, Debug)] -pub struct TextHoverIn(pub CursorIcon); - -/// For use with custom cursor control -/// Event is emitted when cursor leaves a text widget -#[derive(Event, Reflect, Debug)] -pub struct TextHoverOut; - -pub(crate) fn change_cursor( - mut evr_hover_in: EventReader<TextHoverIn>, - evr_hover_out: EventReader<TextHoverOut>, - evr_text_changed: EventReader<crate::events::CosmicTextChanged>, - evr_mouse_motion: EventReader<MouseMotion>, - mouse_buttons: Res<ButtonInput<MouseButton>>, - mut windows: Query<(&mut Window, &mut CursorIcon), With<PrimaryWindow>>, -) { - if windows.iter().len() == 0 { - return; - } - let (mut window, mut window_cursor_icon) = windows.single_mut(); - - if let Some(ev) = evr_hover_in.read().last() { - *window_cursor_icon = ev.0.clone(); - } else if !evr_hover_out.is_empty() { - *window_cursor_icon = CursorIcon::System(SystemCursorIcon::Default); - } - - if !evr_text_changed.is_empty() { - window.cursor_options.visible = false; - } - - if mouse_buttons.get_just_pressed().len() != 0 || !evr_mouse_motion.is_empty() { - window.cursor_options.visible = true; - } -} - -pub(crate) fn hover_ui( - interaction_query: Query< - (&Interaction, &HoverCursor), - (With<CosmicEditBuffer>, Changed<Interaction>), - >, - mut evw_hover_in: EventWriter<TextHoverIn>, - mut evw_hover_out: EventWriter<TextHoverOut>, -) { - for (interaction, hover) in interaction_query.iter() { - match interaction { - Interaction::None => { - evw_hover_out.send(TextHoverOut); - } - Interaction::Hovered => { - evw_hover_in.send(TextHoverIn(hover.0.clone())); - } - _ => {} - } - } -} diff --git a/src/debug.rs b/src/debug.rs new file mode 100644 index 0000000..b5478c2 --- /dev/null +++ b/src/debug.rs @@ -0,0 +1,45 @@ +//! Internal debugging only + +use crate::prelude::*; + +pub(crate) fn plugin(app: &mut App) { + app.add_systems(Last, change_detection) + .add_systems(PreUpdate, change_detection); +} + +/// Query filters like [`Changed<T>`] and [`Added<T>`] ensure only entities matching these filters +/// will be returned by the query. +/// +/// Using the [`Ref<T>`] system param allows you to access change detection information, but does +/// not filter the query. +fn change_detection( + changed_components: Query<Ref<CosmicRenderOutput>, Changed<CosmicRenderOutput>>, + // my_resource: Res<MyResource>, +) { + for component in &changed_components { + // By default, you can only tell that a component was changed. + // + // This is useful, but what if you have multiple systems modifying the same component, how + // will you know which system is causing the component to change? + warn!( + "Change detected!\n\t-> value: {:?}\n\t-> added: {}\n\t-> changed: {}\n\t-> changed by: {}", + component, + component.is_added(), + component.is_changed(), + // If you enable the `track_change_detection` feature, you can unlock the `changed_by()` + // method. It returns the file and line number that the component or resource was + // changed in. It's not recommended for released games, but great for debugging! + component.changed_by() + ); + } + + // if my_resource.is_changed() { + // warn!( + // "Change detected!\n\t-> value: {:?}\n\t-> added: {}\n\t-> changed: {}\n\t-> changed by: {}", + // my_resource, + // my_resource.is_added(), + // my_resource.is_changed(), + // my_resource.changed_by() // Like components, requires `track_change_detection` feature. + // ); + // } +} diff --git a/src/double_click.rs b/src/double_click.rs new file mode 100644 index 0000000..f2e25c1 --- /dev/null +++ b/src/double_click.rs @@ -0,0 +1,91 @@ +use bevy::ecs::system::SystemParam; + +use crate::prelude::*; +use std::{fmt::Debug, time::Duration}; + +pub(crate) fn plugin(app: &mut App) { + app.init_resource::<ClickStateRes>() + .register_type::<ClickStateRes>() + .add_systems(Update, tick); +} + +#[derive(SystemParam)] +pub struct ClickState<'w> { + res: ResMut<'w, ClickStateRes>, +} + +/// Actual state struct +#[derive(Resource, Reflect)] +#[reflect(Resource)] +struct ClickStateRes { + timer_since_last_click: Timer, + click_state: Option<ClickCount>, +} + +impl Default for ClickStateRes { + fn default() -> Self { + Self { + timer_since_last_click: Timer::from_seconds(0.5, TimerMode::Once), + click_state: None, + } + } +} + +#[derive(Reflect, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ClickCount { + Single, + Double, + Triple, + MoreThanTriple, +} + +impl ClickCount { + fn advance(self) -> Self { + match self { + Self::Single => Self::Double, + Self::Double => Self::Triple, + Self::Triple => Self::MoreThanTriple, + Self::MoreThanTriple => Self::MoreThanTriple, + } + } +} + +impl ClickState<'_> { + /// Makes [ClickState] aware of a click event. + pub fn feed_click(&mut self) -> ClickCount { + self.res.timer_since_last_click.reset(); + match self.res.click_state { + None => { + self.res.click_state = Some(ClickCount::Single); + ClickCount::Single + } + Some(click_count) => { + let new_click_count = click_count.advance(); + self.res.click_state = Some(new_click_count); + new_click_count + } + } + } + + /// Get the current click state. + /// + /// `None` means no clicks have been registered recently + #[allow(dead_code)] + pub fn get(&self) -> Option<ClickCount> { + self.res.click_state + } + + /// You must call this every frame + pub(crate) fn tick(&mut self, delta: Duration) { + self.res.timer_since_last_click.tick(delta); + + if self.res.timer_since_last_click.just_finished() { + self.res.click_state = None; + // debug!("Resetting click timer"); + } + } +} + +fn tick(mut state: ClickState, time: Res<Time>) { + state.tick(time.delta()); +} diff --git a/src/editor_buffer.rs b/src/editor_buffer.rs new file mode 100644 index 0000000..abedefc --- /dev/null +++ b/src/editor_buffer.rs @@ -0,0 +1,172 @@ +//! Provides an API for mutating [`cosmic_text::Editor`] and [`cosmic_text::Buffer`] +//! +//! This module is the privacy boundary for the abitrary construction +//! of [`CosmicEditor`], which is the primary interface for mutating [`Buffer`]. + +use bevy::ecs::query::QueryData; +use cosmic_text::{Attrs, BufferRef, FontSystem, Shaping}; + +use crate::prelude::*; + +pub(crate) struct EditorBufferPlugin; + +impl Plugin for EditorBufferPlugin { + fn build(&self, app: &mut App) { + app.add_systems(First, (buffer::set_initial_scale, buffer::add_font_system)) + .add_systems(Update, editor::blink_cursor); + } +} + +pub mod buffer; +pub mod editor; + +/// Primary interface for accessing the [`cosmic_text::Buffer`] of a widget. +/// +/// This will check for the (optional) presence of a [`CosmicEditor`] component +/// and mutate its [`Buffer`] instead of [`CosmicEditBuffer`] by default, +/// which is always what you want. +/// +/// This is the required alternative to manually querying [`&mut CosmicEditBuffer`] +/// to uphold the invariant that [`CosmicEditBuffer`] is basically immutable +/// and the source of truth without a [`CosmicEditor`], **but [`CosmicEditor`] is +/// the source of truth when present** (to allow mutation). +#[derive(QueryData)] +#[query_data(mutable)] +pub struct EditorBuffer { + editor: Option<&'static mut CosmicEditor>, + buffer: &'static mut CosmicEditBuffer, +} + +impl std::ops::Deref for EditorBufferItem<'_> { + type Target = Buffer; + + fn deref(&self) -> &Self::Target { + self.get_raw_buffer() + } +} + +impl std::ops::DerefMut for EditorBufferItem<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.get_raw_buffer_mut() + } +} + +impl<'r, 's> EditorBufferItem<'_> { + pub fn editor(&mut self) -> Option<&mut CosmicEditor> { + self.editor.as_deref_mut() + } + + /// Replace buffer text + pub fn set_text( + &mut self, + font_system: &mut FontSystem, + text: &'s str, + attrs: Attrs<'r>, + ) -> &mut Self { + self.get_raw_buffer_mut() + .set_text(font_system, text, attrs, Shaping::Advanced); + self.set_redraw(true); + self + } + + /// Replace buffer text with rich text + /// + /// Rich text is an iterable of `(&'s str, Attrs<'r>)` + pub fn set_rich_text<I>( + &mut self, + font_system: &mut FontSystem, + spans: I, + attrs: Attrs<'r>, + ) -> &mut Self + where + I: IntoIterator<Item = (&'s str, Attrs<'r>)>, + { + self.get_raw_buffer_mut() + .set_rich_text(font_system, spans, attrs, Shaping::Advanced); + self.set_redraw(true); + self + } + + pub fn with_buffer_mut<F: FnOnce(&mut Buffer) -> T, T>(&mut self, f: F) -> T { + match self.editor.as_mut() { + Some(editor) => editor.with_buffer_mut(f), + None => f(&mut self.buffer.0), + } + } + + pub fn with_buffer<F: FnOnce(&Buffer) -> T, T>(&self, f: F) -> T { + match self.editor.as_ref() { + Some(editor) => editor.with_buffer(f), + None => f(&self.buffer.0), + } + } + + pub fn get_raw_buffer(&self) -> &Buffer { + match self.editor.as_ref() { + Some(editor) => match editor.editor.buffer_ref() { + BufferRef::Owned(buffer) => buffer, + BufferRef::Borrowed(buffer) => buffer, + BufferRef::Arc(arc) => arc, + }, + None => &self.buffer.0, + } + } + + pub fn get_raw_buffer_mut(&mut self) -> &mut Buffer { + match self.editor.as_mut() { + Some(editor) => match editor.editor.buffer_ref_mut() { + BufferRef::Owned(buffer) => buffer, + BufferRef::Borrowed(buffer) => buffer, + BufferRef::Arc(arc) => std::sync::Arc::make_mut(arc), + }, + None => &mut self.buffer.0, + } + } + + pub fn borrow_with<'a>( + &'a mut self, + font_system: &'a mut cosmic_text::FontSystem, + ) -> ManuallyBorrowedWithFontSystem<'a, Self> { + ManuallyBorrowedWithFontSystem { + font_system, + inner: self, + } + } +} + +impl buffer::BufferRefExtras for EditorBufferItem<'_> { + fn get_text(&self) -> String { + self.with_buffer(|b| b.get_text()) + } +} + +pub struct ManuallyBorrowedWithFontSystem<'a, T> { + font_system: &'a mut cosmic_text::FontSystem, + inner: &'a mut T, +} + +impl ManuallyBorrowedWithFontSystem<'_, EditorBufferItem<'_>> { + pub fn with_buffer_mut<F: FnOnce(&mut cosmic_text::BorrowedWithFontSystem<Buffer>) -> T, T>( + &mut self, + f: F, + ) -> T { + match self.inner.editor.as_mut() { + Some(editor) => editor.borrow_with(self.font_system).with_buffer_mut(f), + None => f(&mut self.inner.buffer.0.borrow_with(self.font_system)), + } + } +} + +impl buffer::BufferMutExtras for ManuallyBorrowedWithFontSystem<'_, EditorBufferItem<'_>> { + fn width(&mut self) -> f32 { + self.with_buffer_mut(|b| b.width()) + } + + fn height(&mut self) -> f32 { + self.with_buffer_mut(|b| b.height()) + } + + fn compute_everything(&mut self) { + self.with_buffer_mut(|b| b.compute_everything()) + } +} diff --git a/src/buffer.rs b/src/editor_buffer/buffer.rs similarity index 52% rename from src/buffer.rs rename to src/editor_buffer/buffer.rs index 5205038..1062754 100644 --- a/src/buffer.rs +++ b/src/editor_buffer/buffer.rs @@ -1,48 +1,38 @@ -use crate::{ - cosmic_edit::{ScrollEnabled, XOffset}, - prelude::*, - widget::CosmicPadding, - CosmicBackgroundColor, CosmicBackgroundImage, CosmicTextAlign, CosmicWrap, CursorColor, - HoverCursor, MaxChars, MaxLines, SelectionColor, -}; -use bevy::{ - ecs::{component::ComponentId, query::QueryData, world::DeferredWorld}, - window::PrimaryWindow, -}; -use cosmic_text::{Attrs, AttrsOwned, Buffer, Edit, FontSystem, Metrics, Shaping}; - -pub(crate) struct BufferPlugin; - -impl Plugin for BufferPlugin { - fn build(&self, app: &mut App) { - app.add_systems( - First, - ( - add_font_system, - set_initial_scale, - set_redraw, - set_editor_redraw, - update_internal_target_handles, - ) - .chain(), - ); - } -} +//! API for [`cosmic_text::Buffer`]. +//! +//! Primarily stored in [`CosmicEditBuffer`] + +use cosmic_text::Attrs; +use cosmic_text::AttrsOwned; +use cosmic_text::BorrowedWithFontSystem; +use cosmic_text::FontSystem; +use cosmic_text::Metrics; +use cosmic_text::Shaping; -pub trait BufferExtras { +use crate::cosmic_edit::*; +use crate::prelude::*; + +pub trait BufferRefExtras { fn get_text(&self) -> String; } -impl BufferExtras for Buffer { - /// Retrieves the text content from a buffer. - /// - /// # Arguments - /// - /// * none, takes the rust magic ref to self +pub trait BufferMutExtras { + fn compute_everything(&mut self); + + /// Height that buffer text would take up if rendered /// - /// # Returns - ///input - /// A [`String`] containing the cosmic text content. + /// Used for [`VerticalAlign`](crate::VerticalAlign) + fn height(&mut self) -> f32; + + fn width(&mut self) -> f32; + + fn expected_size(&mut self) -> Vec2 { + Vec2::new(self.width(), self.height()) + } +} + +impl BufferRefExtras for Buffer { + /// Retrieves the text content from a buffer. fn get_text(&self) -> String { let mut text = String::new(); let line_count = self.lines.len(); @@ -59,9 +49,52 @@ impl BufferExtras for Buffer { } } -/// Component wrapper for [`Buffer`] -#[derive(Component, Deref, DerefMut)] -#[component(on_remove = remove_focus_from_entity)] +impl BufferMutExtras for BorrowedWithFontSystem<'_, Buffer> { + fn height(&mut self) -> f32 { + self.compute_everything(); + // TODO: which implementation is correct? + // self.metrics().line_height * self.layout_runs().count() as f32 + self.layout_runs().map(|line| line.line_height).sum() + } + + fn width(&mut self) -> f32 { + self.compute_everything(); + // get max line width + self.layout_runs() + .map(|line| line.line_w) + .reduce(f32::max) + .unwrap_or(0.0) + } + + fn compute_everything(&mut self) { + let last_line_num = self.lines.len() - 1; + let last_line_width = self.lines[last_line_num].text().len(); + let end_cursor = cosmic_text::Cursor::new(last_line_num, last_line_width); + self.shape_until_cursor(end_cursor, false); + } +} + +impl BufferMutExtras for BorrowedWithFontSystem<'_, cosmic_text::Editor<'_>> { + fn height(&mut self) -> f32 { + self.with_buffer_mut(|b| b.height()) + } + + fn width(&mut self) -> f32 { + self.with_buffer_mut(|b| b.width()) + } + + fn compute_everything(&mut self) { + self.with_buffer_mut(|b| b.compute_everything()); + // self.shape_as_needed(false) + } +} + +/// Component wrapper for [`cosmic_text::Buffer`] +/// +/// To access the underlying [`Buffer`], use [`EditorBuffer`](crate::editor_buffer:EditorBuffer). +/// +#[derive(Component, Debug)] +#[component(on_add = on_buffer_add, on_remove = crate::focus::remove_focus_from_entity)] #[require( CosmicBackgroundColor, CursorColor, @@ -71,24 +104,12 @@ impl BufferExtras for Buffer { CosmicRenderOutput, MaxLines, MaxChars, - XOffset, CosmicWrap, CosmicTextAlign, - CosmicPadding, - HoverCursor, - ScrollEnabled + crate::input::hover::HoverCursor, + crate::input::InputState )] -pub struct CosmicEditBuffer(pub Buffer); - -fn remove_focus_from_entity(mut world: DeferredWorld, entity: Entity, _: ComponentId) { - if let Some(mut focused_widget) = world.get_resource_mut::<FocusedWidget>() { - if let Some(focused) = focused_widget.0 { - if focused == entity { - focused_widget.0 = None; - } - } - } -} +pub struct CosmicEditBuffer(pub(super) Buffer); impl Default for CosmicEditBuffer { fn default() -> Self { @@ -96,10 +117,31 @@ impl Default for CosmicEditBuffer { } } +fn on_buffer_add( + mut world: bevy::ecs::world::DeferredWorld, + target: Entity, + _: bevy::ecs::component::ComponentId, +) { + // set redraw + world + .get_mut::<CosmicEditBuffer>(target) + .unwrap() + .0 + .set_redraw(true); +} + +/// Should be partly mirrored on [`EditorBuffer`] impl<'s, 'r> CosmicEditBuffer { /// Create a new buffer with a font system pub fn new(font_system: &mut FontSystem, metrics: Metrics) -> Self { - Self(Buffer::new(font_system, metrics)) + let mut buffer = Buffer::new(font_system, metrics); + buffer.set_redraw(true); + Self(buffer) + } + + #[cfg(test)] + pub(crate) fn inner(&self) -> &Buffer { + &self.0 } // Das a lotta boilerplate just to hide the shaping argument @@ -111,6 +153,7 @@ impl<'s, 'r> CosmicEditBuffer { attrs: Attrs<'r>, ) -> Self { self.0.set_text(font_system, text, attrs, Shaping::Advanced); + self.0.set_redraw(true); self } @@ -139,7 +182,7 @@ impl<'s, 'r> CosmicEditBuffer { attrs: Attrs<'r>, ) -> &mut Self { self.0.set_text(font_system, text, attrs, Shaping::Advanced); - self.set_redraw(true); + self.0.set_redraw(true); self } @@ -157,10 +200,15 @@ impl<'s, 'r> CosmicEditBuffer { { self.0 .set_rich_text(font_system, spans, attrs, Shaping::Advanced); - self.set_redraw(true); + self.0.set_redraw(true); self } + pub fn from_raw_buffer(mut buffer: Buffer) -> CosmicEditBuffer { + buffer.set_redraw(true); + Self(buffer) + } + /// Returns texts from a MultiStyle buffer pub fn get_text_spans(&self, default_attrs: AttrsOwned) -> Vec<Vec<(String, AttrsOwned)>> { // TODO: untested! @@ -168,7 +216,7 @@ impl<'s, 'r> CosmicEditBuffer { let buffer = self; let mut spans = Vec::new(); - for line in buffer.lines.iter() { + for line in buffer.0.lines.iter() { let mut line_spans = Vec::new(); let line_text = line.text(); let line_attrs = line.attrs_list(); @@ -200,25 +248,33 @@ impl<'s, 'r> CosmicEditBuffer { } spans } + + pub(crate) fn from_downgrading_editor(removed_editor: &CosmicEditor) -> CosmicEditBuffer { + // maybe clone only lines? + let buffer = removed_editor.with_buffer(|buf| buf.clone()); + CosmicEditBuffer::from_raw_buffer(buffer) + } } +/// Sets a default text value of "". /// Adds a [`FontSystem`] to a newly created [`CosmicEditBuffer`] if one was not provided -pub(crate) fn add_font_system( +/// +/// This fixes the bug where an empty buffer won't show a blinking cursor when focused +pub(in crate::editor_buffer) fn add_font_system( mut font_system: ResMut<CosmicFontSystem>, mut q: Query<&mut CosmicEditBuffer, Added<CosmicEditBuffer>>, ) { for mut b in q.iter_mut() { - if !b.lines.is_empty() { - continue; + if b.0.lines.is_empty() { + b.0.set_text(&mut font_system, "", Attrs::new(), Shaping::Advanced); + b.0.set_redraw(true); } - b.0.set_text(&mut font_system, "", Attrs::new(), Shaping::Advanced); - b.set_redraw(true); } } /// Initialises [`CosmicEditBuffer`] scale factor -pub(crate) fn set_initial_scale( - window_q: Query<&Window, With<PrimaryWindow>>, +pub(in crate::editor_buffer) fn set_initial_scale( + window_q: Query<&Window, With<bevy::window::PrimaryWindow>>, mut cosmic_query: Query<&mut CosmicEditBuffer, Added<CosmicEditBuffer>>, mut font_system: ResMut<CosmicFontSystem>, ) { @@ -226,82 +282,8 @@ pub(crate) fn set_initial_scale( let w_scale = window.scale_factor(); for mut b in &mut cosmic_query.iter_mut() { - let m = b.metrics().scale(w_scale); - b.set_metrics(&mut font_system, m); - } - } -} - -/// Initialises new [`CosmicEditBuffer`] redraw flag to true -pub(crate) fn set_redraw(mut q: Query<&mut CosmicEditBuffer, Added<CosmicEditBuffer>>) { - for mut b in q.iter_mut() { - b.set_redraw(true); - } -} - -/// Initialises new [`CosmicEditor`] redraw flag to true -pub(crate) fn set_editor_redraw(mut q: Query<&mut CosmicEditor, Added<CosmicEditor>>) { - for mut ed in q.iter_mut() { - ed.set_redraw(true); - } -} - -/// Will attempt to find a place on the receiving entity to place -/// a [`Handle<Image>`] -#[derive(QueryData)] -#[query_data(mutable)] -pub(crate) struct OutputToEntity { - sprite_target: Option<&'static mut Sprite>, - image_node_target: Option<&'static mut ImageNode>, -} - -impl OutputToEntityItem<'_> { - pub fn write_image_data(&mut self, image: &Handle<Image>) { - if let Some(sprite) = self.sprite_target.as_mut() { - sprite.image = image.clone_weak(); - } - if let Some(image_node) = self.image_node_target.as_mut() { - image_node.image = image.clone_weak(); + let m = b.0.metrics().scale(w_scale); + b.0.set_metrics(&mut font_system, m); } } } - -/// Every frame updates the output (in [`CosmicRenderOutput`]) to its receiver -/// on the same entity, e.g. [`Sprite`] -pub(crate) fn update_internal_target_handles( - mut buffers_q: Query<(&CosmicRenderOutput, OutputToEntity), With<CosmicEditBuffer>>, -) { - for (output_data, mut output_components) in buffers_q.iter_mut() { - output_components.write_image_data(&output_data.0); - } -} - -// TODO put this on impl CosmicBuffer - -/// Returns in physical pixels -pub(crate) fn get_text_size(buffer: &Buffer) -> Vec2 { - if buffer.layout_runs().count() == 0 { - return Vec2::new(0., buffer.metrics().line_height); - } - // get max width - let width = buffer - .layout_runs() - .map(|run| run.line_w) - .reduce(f32::max) - .unwrap(); - // get total height - let height = buffer.layout_runs().count() as f32 * buffer.metrics().line_height; - Vec2::new(width, height) -} - -/// Returns in physical pixels -pub(crate) fn get_y_offset_center(widget_height: f32, buffer: &Buffer) -> i32 { - let text_height = get_text_size(buffer).y; - ((widget_height - text_height) / 2.0) as i32 -} - -/// Returns in physical pixels -pub(crate) fn get_x_offset_center(widget_width: f32, buffer: &Buffer) -> i32 { - let text_width = get_text_size(buffer).x; - ((widget_width - text_width) / 2.0) as i32 -} diff --git a/src/editor_buffer/editor.rs b/src/editor_buffer/editor.rs new file mode 100644 index 0000000..0a34214 --- /dev/null +++ b/src/editor_buffer/editor.rs @@ -0,0 +1,62 @@ +use std::time::Duration; + +use cosmic_text::Editor; + +use crate::prelude::*; + +/// Wrapper component for an [`Editor`] with a few helpful values for cursor blinking. +/// [`cosmic_text::Editor`] is basically a mutable version of [`cosmic_text::Buffer`]. +/// +/// This component shouldn't be manually added or constructed, and is automatically +/// managed by the [`crate::focus`] +#[derive(Component, Deref, DerefMut)] +#[non_exhaustive] +pub struct CosmicEditor { + #[deref] + pub editor: Editor<'static>, + pub cursor_visible: bool, + pub cursor_timer: Timer, +} + +pub(super) fn blink_cursor(mut q: Query<&mut CosmicEditor, Without<ReadOnly>>, time: Res<Time>) { + for mut e in q.iter_mut() { + e.cursor_timer.tick(time.delta()); + if e.cursor_timer.just_finished() { + e.cursor_visible = !e.cursor_visible; + // trace!("Toggling cursor"); + e.set_redraw(true); + } + } +} + +impl CosmicEditor { + /// The only way to create a new [`CosmicEditor`] outside of `crate::editor_buffer::editor` + pub(crate) fn clone_from_buffer(old_buffer: &CosmicEditBuffer) -> Self { + let buffer = old_buffer.0.clone(); + let editor = Editor::new(buffer); + Self::new(editor) + } + + fn new(mut editor: Editor<'static>) -> Self { + // this makes sure when switching between editors, + // the cursor doesn't immediately blink at the start + // before its position has been updated + let duration = Duration::from_millis(530); + let mut cursor_timer = Timer::new(Duration::from_millis(530), TimerMode::Repeating); + cursor_timer.tick(duration - Duration::from_millis(80)); + + editor.set_redraw(true); + + Self { + editor, + cursor_visible: false, + cursor_timer, + } + } +} + +impl super::buffer::BufferRefExtras for CosmicEditor { + fn get_text(&self) -> String { + self.with_buffer(|b| b.get_text()) + } +} diff --git a/src/events.rs b/src/events.rs deleted file mode 100644 index ecbb82d..0000000 --- a/src/events.rs +++ /dev/null @@ -1,20 +0,0 @@ -// File for all events, meant for easy documentation - -use bevy::prelude::*; - -/// Registers internal events -pub(crate) struct EventsPlugin; - -impl Plugin for EventsPlugin { - fn build(&self, app: &mut App) { - app.add_event::<CosmicTextChanged>() - .register_type::<CosmicTextChanged>(); - } -} - -/// Text change events -/// -/// Sent when text is changed in a cosmic buffer -/// Contains the entity on which the text was changed, and the new text as a [`String`] -#[derive(Event, Reflect, Debug)] -pub struct CosmicTextChanged(pub (Entity, String)); diff --git a/src/focus.rs b/src/focus.rs index 04df1ab..e61fbd0 100644 --- a/src/focus.rs +++ b/src/focus.rs @@ -1,5 +1,9 @@ -use crate::{prelude::*, widget::WidgetSet}; -use cosmic_text::{Edit, Editor}; +//! Manages the [`FocusedWidget`] resource +//! +//! Makes sure that the focused widget has a [`CosmicEditor`] component +//! if its focused + +use crate::prelude::*; /// System set for focus systems. Runs in `PostUpdate` #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] @@ -13,8 +17,7 @@ impl Plugin for FocusPlugin { PostUpdate, (drop_editor_unfocused, add_editor_to_focused) .chain() - .in_set(FocusSet) - .after(WidgetSet), + .in_set(FocusSet), ) .init_resource::<FocusedWidget>() .register_type::<FocusedWidget>(); @@ -36,12 +39,12 @@ pub(crate) fn add_editor_to_focused( q: Query<&CosmicEditBuffer, Without<CosmicEditor>>, ) { if let Some(e) = active_editor.0 { - let Ok(b) = q.get(e) else { + let Ok(buffer) = q.get(e) else { return; }; - let mut editor = Editor::new(b.0.clone()); - editor.set_redraw(true); - commands.entity(e).insert(CosmicEditor::new(editor)); + let editor = CosmicEditor::clone_from_buffer(buffer); + trace!("Adding editor to focused widget"); + commands.entity(e).insert(editor); } } @@ -51,19 +54,39 @@ pub(crate) fn drop_editor_unfocused( active_editor: Res<FocusedWidget>, mut q: Query<(Entity, &mut CosmicEditBuffer, &CosmicEditor)>, ) { - if active_editor.0.is_none() { - for (e, mut b, ed) in q.iter_mut() { - b.lines = ed.with_buffer(|buf| buf.lines.clone()); - b.set_redraw(true); - commands.entity(e).remove::<CosmicEditor>(); - } - } else if let Some(focused) = active_editor.0 { - for (e, mut b, ed) in q.iter_mut() { - if e != focused { - b.lines = ed.with_buffer(|buf| buf.lines.clone()); - b.set_redraw(true); + match active_editor.0 { + None => { + for (e, mut buffer, editor) in q.iter_mut() { + // buffer.lines = editor.with_buffer(|buf| buf.lines.clone()); + // buffer.set_redraw(true); + *buffer = CosmicEditBuffer::from_downgrading_editor(editor); + trace!("Removing editor from all entities as there is no focussed widget",); commands.entity(e).remove::<CosmicEditor>(); } } + Some(focused) => { + for (e, mut b, editor) in q.iter_mut() { + if e != focused { + *b = CosmicEditBuffer::from_downgrading_editor(editor); + trace!("Removing editor from entity as its not focussed anymore",); + commands.entity(e).remove::<CosmicEditor>(); + } + } + } + } +} + +/// Placed as on_remove hook for [`CosmicEditBuffer`] and [`CosmicEditor`] +pub(crate) fn remove_focus_from_entity( + mut world: bevy::ecs::world::DeferredWorld, + entity: Entity, + _: bevy::ecs::component::ComponentId, +) { + if let Some(mut focused_widget) = world.get_resource_mut::<FocusedWidget>() { + if let Some(focused) = focused_widget.0 { + if focused == entity { + focused_widget.0 = None; + } + } } } diff --git a/src/input.rs b/src/input.rs index 124abfe..0d97a14 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,30 +1,16 @@ #![allow(clippy::too_many_arguments, clippy::type_complexity)] -use crate::{ - buffer::{get_x_offset_center, get_y_offset_center}, - cosmic_edit::{CosmicTextAlign, MaxChars, MaxLines, ReadOnly, ScrollEnabled, XOffset}, - events::CosmicTextChanged, - prelude::*, - CosmicWidgetSize, -}; -use bevy::{ - input::{ - keyboard::{Key, KeyboardInput}, - mouse::{MouseMotion, MouseScrollUnit, MouseWheel}, - }, - window::PrimaryWindow, -}; -use cosmic_text::{Action, Cursor, Edit, Motion, Selection}; - -#[cfg(target_arch = "wasm32")] -use bevy::tasks::AsyncComputeTaskPool; -#[cfg(target_arch = "wasm32")] -#[allow(unused_imports)] -use js_sys::Promise; -#[cfg(target_arch = "wasm32")] -use wasm_bindgen::prelude::*; -#[cfg(target_arch = "wasm32")] -use wasm_bindgen_futures::JsFuture; +use crate::{cosmic_edit::ScrollEnabled, prelude::*}; +use bevy::ecs::{component::ComponentId, world::DeferredWorld}; + +pub mod click; +pub mod clipboard; +pub mod cursor_icon; +pub mod cursor_visibility; +pub mod drag; +pub mod hover; +pub mod keyboard; +pub mod scroll; /// System set for mouse and keyboard input events. Runs in [`PreUpdate`] and [`Update`] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] @@ -34,671 +20,110 @@ pub(crate) struct InputPlugin; impl Plugin for InputPlugin { fn build(&self, app: &mut App) { - app.add_systems(PreUpdate, input_mouse.in_set(InputSet)) + app.add_systems(PreUpdate, scroll::scroll.in_set(InputSet)) .add_systems( Update, - (kb_move_cursor, kb_input_text, kb_clipboard) + ( + keyboard::kb_move_cursor, + keyboard::kb_input_text, + clipboard::kb_clipboard, + ( + cursor_icon::update_cursor_icon, + cursor_visibility::update_cursor_visibility, + ), + ) .chain() .in_set(InputSet), ) - .insert_resource(ClickTimer(Timer::from_seconds(0.5, TimerMode::Once))); + .add_event::<hover::TextHoverIn>() + .add_event::<hover::TextHoverOut>() + .add_event::<CosmicTextChanged>() + .register_type::<hover::TextHoverIn>() + .register_type::<hover::TextHoverOut>() + .register_type::<CosmicTextChanged>(); #[cfg(target_arch = "wasm32")] { - let (tx, rx) = crossbeam_channel::bounded::<WasmPaste>(1); - app.insert_resource(WasmPasteAsyncChannel { tx, rx }) - .add_systems(Update, poll_wasm_paste); + let (tx, rx) = crossbeam_channel::bounded::<clipboard::WasmPaste>(1); + app.insert_resource(clipboard::WasmPasteAsyncChannel { tx, rx }) + .add_systems(Update, clipboard::poll_wasm_paste); } } } -/// Timer for double / triple clicks -#[derive(Resource)] -pub(crate) struct ClickTimer(pub(crate) Timer); - -// TODO: hide this behind #cfg wasm, depends on wasm having own copy/paste fn -/// Crossbeam channel struct for Wasm clipboard data -#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] -pub(crate) struct WasmPaste { - text: String, - entity: Entity, -} - -/// Async channel for receiving from the clipboard in Wasm -#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] -#[derive(Resource)] -pub(crate) struct WasmPasteAsyncChannel { - pub tx: crossbeam_channel::Sender<WasmPaste>, - pub rx: crossbeam_channel::Receiver<WasmPaste>, +/// Text change events +/// +/// Sent when text is changed in a cosmic buffer +/// Contains the entity on which the text was changed, and the new text as a [`String`] +#[derive(Event, Reflect, Debug)] +pub struct CosmicTextChanged(pub (Entity, String)); + +/// First variant is least important, last is most important +#[derive(Component, Default, Debug)] +#[require(ScrollEnabled)] +#[component(on_add = add_event_handlers)] +pub(crate) enum InputState { + #[default] + Idle, + Hovering, + Dragging { + initial_buffer_coord: Vec2, + }, } -pub(crate) fn input_mouse( - windows: Query<&Window, With<PrimaryWindow>>, - active_editor: Res<FocusedWidget>, - keys: Res<ButtonInput<KeyCode>>, - buttons: Res<ButtonInput<MouseButton>>, - mut editor_q: Query<( - &mut CosmicEditor, - &GlobalTransform, - &CosmicTextAlign, - &XOffset, - &ScrollEnabled, - CosmicWidgetSize, - )>, - mut font_system: ResMut<CosmicFontSystem>, - mut scroll_evr: EventReader<MouseWheel>, - camera_q: Query<(&Camera, &GlobalTransform)>, - mut click_timer: ResMut<ClickTimer>, - mut click_count: Local<usize>, - time: Res<Time>, - evr_mouse_motion: EventReader<MouseMotion>, +fn add_event_handlers( + mut world: DeferredWorld, + targeted_entity: Entity, + _component_id: ComponentId, ) { - // handle click timer and click_count - click_timer.0.tick(time.delta()); - - if click_timer.0.finished() || !evr_mouse_motion.is_empty() { - *click_count = 0; - } - - if buttons.just_pressed(MouseButton::Left) { - click_timer.0.reset(); - *click_count += 1; - } - - if *click_count > 3 { - *click_count = 0; - } - - // unwrap resources - let Some(active_editor_entity) = active_editor.0 else { - return; - }; - - let Ok(primary_window) = windows.get_single() else { - return; - }; - - let scale_factor = primary_window.scale_factor(); - let Some((camera, camera_transform)) = camera_q.iter().find(|(c, _)| c.is_active) else { - return; - }; - - // TODO: generalize this over UI and sprite - if let Ok((mut editor, transform, text_position, x_offset, scroll_disabled, target_size)) = - editor_q.get_mut(active_editor_entity) - { - let buffer = editor.with_buffer(|b| b.clone()); - - // get size of render target - let Ok(source_type) = target_size.scan() else { - return; - }; - let Ok(size) = target_size.logical_size() else { - return; - }; - let (width, height) = (size.x, size.y); - - let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]); - - // if shift key is pressed - let already_has_selection = editor.selection() != Selection::None; - if shift && !already_has_selection { - let cursor = editor.cursor(); - editor.set_selection(Selection::Normal(cursor)); - } - - let (padding_x, padding_y) = match text_position { - CosmicTextAlign::Center { padding: _ } => ( - get_x_offset_center(width * scale_factor, &buffer), - get_y_offset_center(height * scale_factor, &buffer), - ), - CosmicTextAlign::TopLeft { padding } => (*padding, *padding), - CosmicTextAlign::Left { padding } => ( - *padding, - get_y_offset_center(height * scale_factor, &buffer), - ), - }; - // Converts a node-relative space coordinate to a screen space physical coord - let screen_physical = |node_cursor_pos: Vec2| { - ( - (node_cursor_pos.x * scale_factor) as i32 - padding_x, - (node_cursor_pos.y * scale_factor) as i32 - padding_y, - ) - }; - - if buttons.just_pressed(MouseButton::Left) { - editor.cursor_visible = true; - editor.cursor_timer.reset(); - - if let Some(node_cursor_pos) = crate::render_targets::get_node_cursor_pos( - primary_window, - transform, - Vec2::new(width, height), - source_type, - camera, - camera_transform, - ) { - let (mut x, y) = screen_physical(node_cursor_pos); - x += x_offset.left as i32; - if shift { - editor.action(&mut font_system.0, Action::Drag { x, y }); - } else { - match *click_count { - 1 => { - editor.action(&mut font_system.0, Action::Click { x, y }); - } - 2 => { - // select word - editor.action(&mut font_system.0, Action::Motion(Motion::LeftWord)); - let cursor = editor.cursor(); - editor.set_selection(Selection::Normal(cursor)); - editor.action(&mut font_system.0, Action::Motion(Motion::RightWord)); - } - 3 => { - // select paragraph - editor - .action(&mut font_system.0, Action::Motion(Motion::ParagraphStart)); - let cursor = editor.cursor(); - editor.set_selection(Selection::Normal(cursor)); - editor.action(&mut font_system.0, Action::Motion(Motion::ParagraphEnd)); - } - _ => {} - } - } - } - return; - } - - if buttons.pressed(MouseButton::Left) && *click_count == 0 { - if let Some(node_cursor_pos) = crate::render_targets::get_node_cursor_pos( - primary_window, - transform, - Vec2::new(width, height), - source_type, - camera, - camera_transform, - ) { - let (mut x, y) = screen_physical(node_cursor_pos); - x += x_offset.left as i32; - if active_editor.is_changed() && !shift { - editor.action(&mut font_system.0, Action::Click { x, y }); - } else { - editor.action(&mut font_system.0, Action::Drag { x, y }); - } - } - return; - } - - if scroll_disabled.should_scroll() { - for ev in scroll_evr.read() { - match ev.unit { - MouseScrollUnit::Line => { - editor.action( - &mut font_system.0, - Action::Scroll { - lines: -ev.y as i32, - }, - ); - } - MouseScrollUnit::Pixel => { - let line_height = buffer.metrics().line_height; - editor.action( - &mut font_system.0, - Action::Scroll { - lines: -(ev.y / line_height) as i32, - }, - ); - } - } - } - } + let mut observers = [ + Observer::new(click::handle_focussed_click.pipe(render_implementations::debug_error)), + Observer::new(drag::handle_dragstart.pipe(render_implementations::debug_error)), + Observer::new(drag::handle_drag_continue), + Observer::new(drag::handle_dragend), + Observer::new(hover::handle_hover_start), + Observer::new(hover::handle_hover_continue), + Observer::new(hover::handle_hover_end), + Observer::new(cancel::handle_cancel), + ]; + for observer in &mut observers { + observer.watch_entity(targeted_entity); } + world.commands().spawn_batch(observers); } -pub(crate) fn kb_move_cursor( - active_editor: Res<FocusedWidget>, - keys: Res<ButtonInput<KeyCode>>, - mut cosmic_edit_query: Query<(&mut CosmicEditor,)>, - mut font_system: ResMut<CosmicFontSystem>, -) { - let Some(active_editor_entity) = active_editor.0 else { - return; - }; - if let Ok((mut editor,)) = cosmic_edit_query.get_mut(active_editor_entity) { - if keys.get_just_pressed().len() != 0 { - editor.cursor_visible = true; - editor.cursor_timer.reset(); - } - - let command = keypress_command(&keys); - - #[cfg(target_arch = "wasm32")] - let command = if web_sys::window() - .unwrap() - .navigator() - .user_agent() - .unwrap_or("NoUA".into()) - .contains("Macintosh") - { - keys.any_pressed([KeyCode::SuperLeft, KeyCode::SuperRight]) - } else { - command - }; - - #[cfg(target_os = "macos")] - let option = keys.any_pressed([KeyCode::AltLeft, KeyCode::AltRight]); - - let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]); - - // if shift key is pressed - let already_has_selection = editor.selection() != Selection::None; - if shift && !already_has_selection { - let cursor = editor.cursor(); - editor.set_selection(Selection::Normal(cursor)); - } - - #[cfg(target_os = "macos")] - let should_jump = command && option; - #[cfg(not(target_os = "macos"))] - let should_jump = command; - - if should_jump && keys.just_pressed(KeyCode::ArrowLeft) { - editor.action(&mut font_system.0, Action::Motion(Motion::PreviousWord)); - if !shift { - editor.set_selection(Selection::None); - } - return; - } - if should_jump && keys.just_pressed(KeyCode::ArrowRight) { - editor.action(&mut font_system.0, Action::Motion(Motion::NextWord)); - if !shift { - editor.set_selection(Selection::None); - } - return; - } - if should_jump && keys.just_pressed(KeyCode::Home) { - editor.action(&mut font_system.0, Action::Motion(Motion::BufferStart)); - if !shift { - editor.set_selection(Selection::None); - } - return; - } - if should_jump && keys.just_pressed(KeyCode::End) { - editor.action(&mut font_system.0, Action::Motion(Motion::BufferEnd)); - if !shift { - editor.set_selection(Selection::None); - } - return; - } - - if keys.just_pressed(KeyCode::ArrowLeft) { - editor.action(&mut font_system.0, Action::Motion(Motion::Left)); - if !shift { - editor.set_selection(Selection::None); - } - return; - } - if keys.just_pressed(KeyCode::ArrowRight) { - editor.action(&mut font_system.0, Action::Motion(Motion::Right)); - if !shift { - editor.set_selection(Selection::None); - } - return; - } - if keys.just_pressed(KeyCode::ArrowUp) { - editor.action(&mut font_system.0, Action::Motion(Motion::Up)); - if !shift { - editor.set_selection(Selection::None); - } - return; - } - if keys.just_pressed(KeyCode::ArrowDown) { - editor.action(&mut font_system.0, Action::Motion(Motion::Down)); - if !shift { - editor.set_selection(Selection::None); - } - return; - } - if keys.just_pressed(KeyCode::Escape) { - editor.action(&mut font_system.0, Action::Escape); - } - if command && keys.just_pressed(KeyCode::KeyA) { - editor.action(&mut font_system.0, Action::Motion(Motion::BufferEnd)); - let current_cursor = editor.cursor(); - editor.set_selection(Selection::Normal(Cursor { - line: 0, - index: 0, - affinity: current_cursor.affinity, - })); - return; - } - if keys.just_pressed(KeyCode::Home) { - editor.action(&mut font_system.0, Action::Motion(Motion::Home)); - if !shift { - editor.set_selection(Selection::None); - } - return; - } - if keys.just_pressed(KeyCode::End) { - editor.action(&mut font_system.0, Action::Motion(Motion::End)); - if !shift { - editor.set_selection(Selection::None); - } - return; - } - if keys.just_pressed(KeyCode::PageUp) { - editor.action(&mut font_system.0, Action::Motion(Motion::PageUp)); - if !shift { - editor.set_selection(Selection::None); - } - return; - } - if keys.just_pressed(KeyCode::PageDown) { - editor.action(&mut font_system.0, Action::Motion(Motion::PageDown)); - if !shift { - editor.set_selection(Selection::None); - } - } - } +// todo: avoid these warnings on ReadOnly +fn warn_no_editor_on_picking_event(job: &'static str) { + debug!( + note = "This is a false alarm for ReadOnly buffers", + note = "Please only use the `InputState` component on entities with a `CosmicEditor` component", + note = "`CosmicEditor` components should be automatically added to focussed `CosmicEditBuffer` entities", + "Failed to get editor from picking event while {job}" + ); } -pub(crate) fn kb_input_text( - active_editor: Res<FocusedWidget>, - keys: Res<ButtonInput<KeyCode>>, - mut char_evr: EventReader<KeyboardInput>, - mut cosmic_edit_query: Query<( - &mut CosmicEditor, - &mut CosmicEditBuffer, - &MaxLines, - &MaxChars, - Entity, - Option<&ReadOnly>, - )>, - mut evw_changed: EventWriter<CosmicTextChanged>, - mut font_system: ResMut<CosmicFontSystem>, - mut is_deleting: Local<bool>, -) { - let Some(active_editor_entity) = active_editor.0 else { - return; - }; - - if let Ok((mut editor, buffer, max_lines, max_chars, entity, readonly_opt)) = - cosmic_edit_query.get_mut(active_editor_entity) - { - let command = keypress_command(&keys); - if keys.get_just_pressed().len() != 0 { - editor.cursor_visible = true; - editor.cursor_timer.reset(); - } - let readonly = readonly_opt.is_some(); - - if keys.just_pressed(KeyCode::Backspace) & !readonly { - // fix for issue #8 - let select = editor.selection(); - match select { - Selection::Line(cursor) => { - if editor.cursor().line == cursor.line && editor.cursor().index == cursor.index - { - editor.set_selection(Selection::None); - } - } - Selection::Normal(cursor) => { - if editor.cursor().line == cursor.line && editor.cursor().index == cursor.index - { - editor.set_selection(Selection::None); - } - } - Selection::Word(cursor) => { - if editor.cursor().line == cursor.line && editor.cursor().index == cursor.index - { - editor.set_selection(Selection::None); - } - } - Selection::None => {} - } - - *is_deleting = true; - } - - if keys.just_released(KeyCode::Backspace) { - *is_deleting = false; - } - if keys.just_pressed(KeyCode::Delete) && !readonly { - editor.action(&mut font_system.0, Action::Delete); - editor.with_buffer_mut(|b| b.set_redraw(true)); - } - - if readonly { - return; - } - - let mut is_edit = false; - let mut is_return = false; - if keys.just_pressed(KeyCode::Enter) { - is_return = true; - if (max_lines.0 == 0 || buffer.lines.len() < max_lines.0) - && (max_chars.0 == 0 || buffer.get_text().len() < max_chars.0) - { - // to have new line on wasm rather than E - is_edit = true; - editor.action(&mut font_system.0, Action::Insert('\n')); - } - } +pub mod cancel { + use crate::prelude::*; - if !is_return { - for char_ev in char_evr.read() { - is_edit = true; - if *is_deleting { - editor.action(&mut font_system.0, Action::Backspace); - } else if !command - && (max_chars.0 == 0 || buffer.get_text().len() < max_chars.0) - && matches!(char_ev.state, bevy::input::ButtonState::Pressed) - { - match &char_ev.logical_key { - Key::Character(char) => { - let b = char.as_bytes(); - for c in b { - let c: char = (*c).into(); - editor.action(&mut font_system.0, Action::Insert(c)); - } - } - Key::Space => { - editor.action(&mut font_system.0, Action::Insert(' ')); - } - _ => (), - } - } - } - } + use super::{warn_no_editor_on_picking_event, InputState}; - if !is_edit { - return; + impl InputState { + /// `Cancel` event handler + pub fn cancel(&mut self) { + trace!("Cancelling a pointer"); + *self = InputState::Idle; } - - evw_changed.send(CosmicTextChanged(( - entity, - editor.with_buffer_mut(|b| b.get_text()), - ))); } -} -pub(crate) fn kb_clipboard( - active_editor: Res<FocusedWidget>, - keys: Res<ButtonInput<KeyCode>>, - mut evw_changed: EventWriter<CosmicTextChanged>, - mut font_system: ResMut<CosmicFontSystem>, - mut cosmic_edit_query: Query<( - &mut CosmicEditor, - &mut CosmicEditBuffer, - &MaxLines, - &MaxChars, - Entity, - Option<&ReadOnly>, - )>, - _channel: Option<Res<WasmPasteAsyncChannel>>, -) { - let Some(active_editor_entity) = active_editor.0 else { - return; - }; - - if let Ok((mut editor, buffer, max_lines, max_chars, entity, readonly_opt)) = - cosmic_edit_query.get_mut(active_editor_entity) - { - let command = keypress_command(&keys); - - let readonly = readonly_opt.is_some(); - - let mut is_clipboard = false; - #[cfg(not(target_arch = "wasm32"))] - { - if let Ok(mut clipboard) = arboard::Clipboard::new() { - if command && keys.just_pressed(KeyCode::KeyC) { - if let Some(text) = editor.copy_selection() { - clipboard.set_text(text).unwrap(); - return; - } - } - if command && keys.just_pressed(KeyCode::KeyX) && !readonly { - if let Some(text) = editor.copy_selection() { - clipboard.set_text(text).unwrap(); - editor.delete_selection(); - } - is_clipboard = true; - } - if command && keys.just_pressed(KeyCode::KeyV) && !readonly { - if let Ok(text) = clipboard.get_text() { - for c in text.chars() { - if max_chars.0 == 0 || buffer.get_text().len() < max_chars.0 { - if c == 0xA as char { - if max_lines.0 == 0 || buffer.lines.len() < max_lines.0 { - editor.action(&mut font_system.0, Action::Insert(c)); - } - } else { - editor.action(&mut font_system.0, Action::Insert(c)); - } - } - } - } - is_clipboard = true; - } - } - } - - #[cfg(target_arch = "wasm32")] - { - if command && keys.just_pressed(KeyCode::KeyC) { - if let Some(text) = editor.copy_selection() { - write_clipboard_wasm(text.as_str()); - return; - } - } - - if command && keys.just_pressed(KeyCode::KeyX) && !readonly { - if let Some(text) = editor.copy_selection() { - write_clipboard_wasm(text.as_str()); - editor.delete_selection(); - } - is_clipboard = true; - } - if command && keys.just_pressed(KeyCode::KeyV) && !readonly { - let tx = _channel.unwrap().tx.clone(); - let _task = AsyncComputeTaskPool::get().spawn(async move { - let promise = read_clipboard_wasm(); - - let result = JsFuture::from(promise).await; - - if let Ok(js_text) = result { - if let Some(text) = js_text.as_string() { - let _ = tx.try_send(WasmPaste { text, entity }); - } - } - }); - - return; - } - } - - if !is_clipboard { + pub(super) fn handle_cancel( + trigger: Trigger<Pointer<Cancel>>, + mut editor: Query<&mut InputState, With<CosmicEditBuffer>>, + ) { + let Ok(mut input_state) = editor.get_mut(trigger.target) else { + warn_no_editor_on_picking_event("handling cursor `Cancel` event"); return; - } - - evw_changed.send(CosmicTextChanged((entity, buffer.get_text()))); - } -} - -fn keypress_command(keys: &ButtonInput<KeyCode>) -> bool { - #[cfg(target_os = "macos")] - let command = keys.any_pressed([KeyCode::SuperLeft, KeyCode::SuperRight]); - - #[cfg(not(target_os = "macos"))] - let command = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]); - - #[cfg(target_arch = "wasm32")] - let command = if web_sys::window() - .unwrap() - .navigator() - .user_agent() - .unwrap_or("NoUA".into()) - .contains("Macintosh") - { - keys.any_pressed([KeyCode::SuperLeft, KeyCode::SuperRight]) - } else { - command - }; - - command -} - -#[cfg(target_arch = "wasm32")] -#[wasm_bindgen] -pub fn write_clipboard_wasm(text: &str) { - let clipboard = web_sys::window().unwrap().navigator().clipboard(); - let _result = clipboard.write_text(text); -} - -#[cfg(target_arch = "wasm32")] -#[wasm_bindgen] -pub fn read_clipboard_wasm() -> Promise { - let clipboard = web_sys::window().unwrap().navigator().clipboard(); - clipboard.read_text() -} - -#[cfg(target_arch = "wasm32")] -pub(crate) fn poll_wasm_paste( - channel: Res<WasmPasteAsyncChannel>, - mut editor_q: Query< - ( - &mut CosmicEditor, - &mut CosmicEditBuffer, - &MaxChars, - &MaxChars, - ), - Without<ReadOnly>, - >, - mut evw_changed: EventWriter<CosmicTextChanged>, - mut font_system: ResMut<CosmicFontSystem>, -) { - let inlet = channel.rx.try_recv(); - match inlet { - Ok(inlet) => { - let entity = inlet.entity; - if let Ok((mut editor, buffer, max_chars, max_lines)) = editor_q.get_mut(entity) { - let text = inlet.text; - for c in text.chars() { - if max_chars.0 == 0 || buffer.get_text().len() < max_chars.0 { - if c == 0xA as char { - if max_lines.0 == 0 || buffer.lines.len() < max_lines.0 { - editor.action(&mut font_system.0, Action::Insert(c)); - } - } else { - editor.action(&mut font_system.0, Action::Insert(c)); - } - } - } + }; - evw_changed.send(CosmicTextChanged((entity, buffer.get_text()))); - } - } - Err(_) => {} + input_state.cancel(); } } diff --git a/src/input/click.rs b/src/input/click.rs new file mode 100644 index 0000000..c056212 --- /dev/null +++ b/src/input/click.rs @@ -0,0 +1,144 @@ +use crate::{ + double_click::{ClickCount, ClickState}, + prelude::*, +}; + +use super::InputState; +use cosmic_text::{Action, Motion, Selection}; +use render_implementations::{RelativeQuery, RenderTargetError, RenderTypeScan}; + +impl InputState { + /// Handler for [`Click`] event + pub fn handle_click(&self) { + trace!("Clicked"); + match self { + InputState::Idle | InputState::Hovering => {} + InputState::Dragging { .. } => { + // warn!( + // message = "Click event received while dragging", + // state = ?self, + // ) + } + } + } + + /// Should only [`Action::Click`] when not already dragging + pub fn should_click(&self) -> bool { + !matches!(self, InputState::Dragging { .. }) + } +} + +/// An [`Observer`] that focuses on the desired editor when clicked +pub fn focus_on_click( + trigger: Trigger<Pointer<Click>>, + mut focused: ResMut<FocusedWidget>, + editor_confirmation: Query<RenderTypeScan, With<CosmicEditBuffer>>, +) { + let Ok(scan) = editor_confirmation.get(trigger.target) else { + warn!( + "An entity with the `focus_on_click` observer added was clicked, but didn't have a `CosmicEditBuffer` component", + ); + return; + }; + + match scan.confirm_conformance() { + Ok(_) => { + focused.0 = Some(trigger.target); + } + Err(RenderTargetError::NoTargetsAvailable) => { + warn!("Please use a high-level driver component from `bevy_cosmic_edit::render_implementations` to add the `CosmicEditBuffer` component, e.g. `TextEdit` or `TextEdit2d`"); + } + Err(err) => { + warn!(message = "For some reason, the entity that `focus_on_click` was triggered for isn't a valid `CosmicEditor`", ?err); + // render_implementations::debug_error::<()>(In(Err(err))); + } + } +} + +/// Handles [`CosmicEditor`] widgets that are already focussed +pub(super) fn handle_focussed_click( + trigger: Trigger<Pointer<Click>>, + focused: Res<FocusedWidget>, + mut editor: Query<(&mut InputState, &mut CosmicEditor, RelativeQuery)>, + mut font_system: ResMut<CosmicFontSystem>, + buttons: Res<ButtonInput<KeyCode>>, + mut click_state: ClickState, +) -> render_implementations::Result<()> { + let font_system = &mut font_system.0; + let target = trigger.target; + let click = trigger.event(); + + // must be focused + if focused.0 != Some(target) { + return Ok(()); + } + + if click.button != PointerButton::Primary { + return Ok(()); + } + + let Ok((input_state, mut editor, buffer_relative)) = editor.get_mut(target) else { + // this is expected on first click, idk order of observers + // warn_no_editor_on_picking_event("handling focussed cursor `Click` event"); + return Ok(()); + }; + let mut editor = editor.borrow_with(font_system); + input_state.handle_click(); + + let buffer_coord = buffer_relative.compute_buffer_coord(&click.hit, editor.expected_size())?; + + if !input_state.should_click() { + return Ok(()); + } + + match click_state.feed_click() { + ClickCount::Single => { + let shift_pressed = buttons.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]); + + if shift_pressed { + editor.action(Action::Drag { + x: buffer_coord.x as i32, + y: buffer_coord.y as i32, + }); + } else { + editor.action(Action::Click { + x: buffer_coord.x as i32, + y: buffer_coord.y as i32, + }); + } + } + ClickCount::Double => { + // selects word + editor.action(Action::DoubleClick { + x: buffer_coord.x as i32, + y: buffer_coord.y as i32, + }); + // // select word + // editor.action(Action::Motion(Motion::LeftWord)); + // let cursor = editor.cursor(); + // editor.set_selection(Selection::Normal(cursor)); + // editor.action(Action::Motion(Motion::RightWord)); + } + ClickCount::Triple => { + // selects line + editor.action(Action::TripleClick { + x: buffer_coord.x as i32, + y: buffer_coord.y as i32, + }); + // // select paragraph + // editor.action(Action::Motion(Motion::ParagraphStart)); + // let cursor = editor.cursor(); + // editor.set_selection(Selection::Normal(cursor)); + // editor.action(Action::Motion(Motion::ParagraphEnd)); + } + ClickCount::MoreThanTriple => { + // select all + editor.action(Action::Motion(Motion::BufferStart)); + let cursor = editor.cursor(); + editor.set_selection(Selection::Normal(cursor)); + editor.action(Action::Motion(Motion::BufferEnd)); + } + } + + Ok(()) +} diff --git a/src/input/clipboard.rs b/src/input/clipboard.rs new file mode 100644 index 0000000..fbfe6a8 --- /dev/null +++ b/src/input/clipboard.rs @@ -0,0 +1,182 @@ +use crate::{input::CosmicTextChanged, prelude::*, MaxChars, MaxLines}; + +#[cfg(target_arch = "wasm32")] +use bevy::tasks::AsyncComputeTaskPool; +use cosmic_text::{Action, Edit}; +#[cfg(target_arch = "wasm32")] +#[allow(unused_imports)] +use js_sys::Promise; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::prelude::*; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_futures::JsFuture; + +// TODO: hide this behind #cfg wasm, depends on wasm having own copy/paste fn +/// Crossbeam channel struct for Wasm clipboard data +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +pub(crate) struct WasmPaste { + text: String, + entity: Entity, +} + +/// Async channel for receiving from the clipboard in Wasm +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +#[derive(Resource)] +pub(crate) struct WasmPasteAsyncChannel { + pub tx: crossbeam_channel::Sender<WasmPaste>, + pub rx: crossbeam_channel::Receiver<WasmPaste>, +} + +pub(crate) fn kb_clipboard( + active_editor: Res<FocusedWidget>, + keys: Res<ButtonInput<KeyCode>>, + mut evw_changed: EventWriter<CosmicTextChanged>, + #[allow(unused_variables, unused_mut)] mut font_system: ResMut<CosmicFontSystem>, + mut cosmic_edit_query: Query<( + &mut CosmicEditor, + &MaxLines, + &MaxChars, + Entity, + Option<&ReadOnly>, + )>, + _channel: Option<Res<WasmPasteAsyncChannel>>, +) { + let Some(active_editor_entity) = active_editor.0 else { + return; + }; + + #[allow(unused_variables)] + if let Ok((mut editor, max_lines, max_chars, entity, readonly_opt)) = + cosmic_edit_query.get_mut(active_editor_entity) + { + let command = crate::input::keyboard::keypress_command(&keys); + + let readonly = readonly_opt.is_some(); + + let mut is_clipboard = false; + #[cfg(not(target_arch = "wasm32"))] + { + if let Ok(mut clipboard) = arboard::Clipboard::new() { + if command && keys.just_pressed(KeyCode::KeyC) { + if let Some(text) = editor.copy_selection() { + clipboard.set_text(text).unwrap(); + return; + } + } + if command && keys.just_pressed(KeyCode::KeyX) && !readonly { + if let Some(text) = editor.copy_selection() { + clipboard.set_text(text).unwrap(); + editor.delete_selection(); + } + is_clipboard = true; + } + if command && keys.just_pressed(KeyCode::KeyV) && !readonly { + if let Ok(text) = clipboard.get_text() { + for c in text.chars() { + if max_chars.0 == 0 || editor.get_text().len() < max_chars.0 { + if c == 0xA as char { + if max_lines.0 == 0 + || editor.with_buffer(|b| b.lines.len()) < max_lines.0 + { + editor.action(&mut font_system.0, Action::Insert(c)); + } + } else { + editor.action(&mut font_system.0, Action::Insert(c)); + } + } + } + } + is_clipboard = true; + } + } + } + + #[cfg(target_arch = "wasm32")] + { + if command && keys.just_pressed(KeyCode::KeyC) { + if let Some(text) = editor.copy_selection() { + write_clipboard_wasm(text.as_str()); + return; + } + } + + if command && keys.just_pressed(KeyCode::KeyX) && !readonly { + if let Some(text) = editor.copy_selection() { + write_clipboard_wasm(text.as_str()); + editor.delete_selection(); + } + is_clipboard = true; + } + if command && keys.just_pressed(KeyCode::KeyV) && !readonly { + let tx = _channel.unwrap().tx.clone(); + let _task = AsyncComputeTaskPool::get().spawn(async move { + let promise = read_clipboard_wasm(); + + let result = JsFuture::from(promise).await; + + if let Ok(js_text) = result { + if let Some(text) = js_text.as_string() { + let _ = tx.try_send(WasmPaste { text, entity }); + } + } + }); + + return; + } + } + + if !is_clipboard { + return; + } + + evw_changed.send(CosmicTextChanged((entity, editor.get_text()))); + } +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn write_clipboard_wasm(text: &str) { + let clipboard = web_sys::window().unwrap().navigator().clipboard(); + let _result = clipboard.write_text(text); +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn read_clipboard_wasm() -> Promise { + let clipboard = web_sys::window().unwrap().navigator().clipboard(); + clipboard.read_text() +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn poll_wasm_paste( + channel: Res<WasmPasteAsyncChannel>, + mut editor_q: Query<(&mut CosmicEditor, &MaxChars, &MaxChars), Without<ReadOnly>>, + mut evw_changed: EventWriter<CosmicTextChanged>, + mut font_system: ResMut<CosmicFontSystem>, +) { + let inlet = channel.rx.try_recv(); + match inlet { + Ok(inlet) => { + let entity = inlet.entity; + if let Ok((mut editor, max_chars, max_lines)) = editor_q.get_mut(entity) { + let text = inlet.text; + for c in text.chars() { + if max_chars.0 == 0 || editor.get_text().len() < max_chars.0 { + if c == 0xA as char { + if max_lines.0 == 0 + || editor.with_buffer(|b| b.lines.len()) < max_lines.0 + { + editor.action(&mut font_system.0, Action::Insert(c)); + } + } else { + editor.action(&mut font_system.0, Action::Insert(c)); + } + } + } + + evw_changed.send(CosmicTextChanged((entity, editor.get_text()))); + } + } + Err(_) => {} + } +} diff --git a/src/input/cursor_icon.rs b/src/input/cursor_icon.rs new file mode 100644 index 0000000..fa05f3b --- /dev/null +++ b/src/input/cursor_icon.rs @@ -0,0 +1,96 @@ +use bevy::{ + ecs::system::SystemParam, + window::{PrimaryWindow, SystemCursorIcon}, + winit::cursor::CursorIcon, +}; + +use crate::prelude::*; + +use super::{hover::HoverCursor, InputState}; + +#[derive(SystemParam)] +pub(crate) struct CursorIconUpdate<'w, 's> { + window: Single<'w, Entity, With<PrimaryWindow>>, + commands: Commands<'w, 's>, +} + +impl CursorIconUpdate<'_, '_> { + pub fn set_cursor(&mut self, icon: CursorIcon) { + // trace!(message = "Setting window icon", ?icon); + self.commands.entity(*self.window).insert(icon); + } + + /// Resets to default + pub fn reset_cursor(&mut self) { + // could also set to default value + // trace!("Resetting window icon"); + self.commands.entity(*self.window).remove::<CursorIcon>(); + } +} + +enum GlobalCursorState { + Nothing, + BufferHovered(CursorIcon), + FocussedEditorDragging, +} + +impl GlobalCursorState { + pub fn account_for_hovered_buffer(&mut self, hover_cursor: CursorIcon) { + match self { + GlobalCursorState::Nothing => *self = GlobalCursorState::BufferHovered(hover_cursor), + GlobalCursorState::BufferHovered(_) => { + warn_once!( + message = "Multiple buffers hovered at the same time", + note = "What to do in this case is not yet implemented" + ); + } + GlobalCursorState::FocussedEditorDragging => {} + } + } + + pub fn account_for_dragging_focussed_editor(&mut self) { + *self = GlobalCursorState::FocussedEditorDragging; + } + + /// `None` indicates use the default icon + pub fn decide_on_icon(self) -> Option<CursorIcon> { + match self { + GlobalCursorState::Nothing => None, + GlobalCursorState::BufferHovered(icon) => Some(icon), + GlobalCursorState::FocussedEditorDragging => { + Some(CursorIcon::System(SystemCursorIcon::Text)) + } + } + } +} + +/// Doesn't take into account [`crate::UserSelectNone`] or [`crate::ReadOnly`] +pub(super) fn update_cursor_icon( + editors: Query<(&InputState, &HoverCursor, Entity, Has<CosmicEditor>), With<CosmicEditBuffer>>, + focused_widget: Res<FocusedWidget>, + mut cursor_icon: CursorIconUpdate, +) { + // if an editor is being hovered, prioritize its hover cursor + // else, reset to default + let mut cursor_state = GlobalCursorState::Nothing; + for (input_state, hover_cursor, buffer_entity, is_editor) in editors.iter() { + match *input_state { + InputState::Hovering => { + cursor_state.account_for_hovered_buffer(hover_cursor.0.clone()); + } + InputState::Idle => {} + InputState::Dragging { .. } => { + if is_editor && focused_widget.0 == Some(buffer_entity) { + cursor_state.account_for_dragging_focussed_editor(); + } + // only a readonly non-editor buffer could be dragged, + // this ignores such a case + } + } + } + + match cursor_state.decide_on_icon() { + Some(icon) => cursor_icon.set_cursor(icon), + None => cursor_icon.reset_cursor(), + } +} diff --git a/src/input/cursor_visibility.rs b/src/input/cursor_visibility.rs new file mode 100644 index 0000000..ee23f30 --- /dev/null +++ b/src/input/cursor_visibility.rs @@ -0,0 +1,48 @@ +//! Manages the OS-level cursor aka mouse pointer visibility + +use bevy::input::mouse::MouseMotion; +use bevy::{ecs::system::SystemParam, window::PrimaryWindow}; + +use crate::prelude::*; + +use crate::input::CosmicTextChanged; + +// if text changedd +// if !evr_text_changed.is_empty() { +// window.cursor_options.visible = false; +// } + +// if pressing or moving mouse +// if mouse_buttons.get_just_pressed().len() != 0 || !evr_mouse_motion.is_empty() { +// window.cursor_options.visible = true; +// } +// + +#[derive(SystemParam)] +pub(crate) struct CursorVisibility<'w> { + window: Single<'w, &'static mut Window, With<PrimaryWindow>>, +} + +impl CursorVisibility<'_> { + pub fn set_cursor_visibility(&mut self, visible: bool) { + self.window.cursor_options.visible = visible; + } +} + +pub(super) fn update_cursor_visibility( + editors_text_changed: EventReader<CosmicTextChanged>, + mouse_moved: EventReader<MouseMotion>, + mouse_clicked: Res<ButtonInput<MouseButton>>, + mut cursor_visibility: CursorVisibility, +) { + let text_changed_at_all = !editors_text_changed.is_empty(); + if text_changed_at_all { + cursor_visibility.set_cursor_visibility(false); + } + + let mouse_moved_at_all = !mouse_moved.is_empty(); + let mouse_clicked_at_all = mouse_clicked.get_just_pressed().len() != 0; + if mouse_moved_at_all || mouse_clicked_at_all { + cursor_visibility.set_cursor_visibility(true); + } +} diff --git a/src/input/drag.rs b/src/input/drag.rs new file mode 100644 index 0000000..0256a68 --- /dev/null +++ b/src/input/drag.rs @@ -0,0 +1,148 @@ +use crate::prelude::*; + +use super::{warn_no_editor_on_picking_event, InputState}; +use cosmic_text::Action; +use render_implementations::RelativeQuery; + +impl InputState { + pub fn is_dragging(&self) -> bool { + matches!(self, InputState::Dragging { .. }) + } + + /// Handler for [`DragStart`] event + pub fn start_dragging(&mut self, initial_buffer_coord: Vec2) { + trace!("Starting a drag"); + match self { + InputState::Idle | InputState::Hovering => { + *self = InputState::Dragging { + initial_buffer_coord, + }; + } + InputState::Dragging { .. } => { + // warn!( + // message = "Somehow, a `DragStart` event was received before a previous `DragStart` event was ended with a `DragEnd`", + // note = "Ignoring", + // ); + } + } + } + + /// Handler for [`Move`] + pub fn continue_dragging(&self) { + match self { + InputState::Dragging { .. } => {} + InputState::Idle | InputState::Hovering => { + // warn!( + // message = "Somehow, a `Move` event was received before a previous `DragStart` event was received", + // note = "Ignoring", + // ); + } + } + } + + /// Handler for [`Out`] event + pub fn end_dragging(&mut self) { + trace!("Ending drag"); + match self { + InputState::Dragging { .. } => { + *self = InputState::Idle; + } + InputState::Idle | InputState::Hovering => { + // warn!( + // message = "Somehow, a `DragEnd` event was received before a previous `DragStart` event was received", + // note = "Ignoring", + // ); + } + } + } +} + +pub(super) fn handle_dragstart( + trigger: Trigger<Pointer<DragStart>>, + mut editor: Query<(&mut InputState, &mut CosmicEditor, RelativeQuery), With<CosmicEditBuffer>>, + mut font_system: ResMut<CosmicFontSystem>, +) -> render_implementations::Result<()> { + let font_system = &mut font_system.0; + let event = trigger.event(); + let Ok((mut input_state, mut editor, sprite_relative)) = editor.get_mut(trigger.target) else { + warn_no_editor_on_picking_event("handling cursor `DragStart` event"); + return Ok(()); + }; + let buffer_size = editor.with_buffer_mut(|b| b.borrow_with(font_system).expected_size()); + let buffer_coord = sprite_relative.compute_buffer_coord(&event.hit, buffer_size)?; + let mut editor = editor.borrow_with(font_system); + + if event.button != PointerButton::Primary { + return Ok(()); + } + + input_state.start_dragging(buffer_coord); + + if input_state.is_dragging() { + editor.action(Action::Click { + x: buffer_coord.x as i32, + y: buffer_coord.y as i32, + }); + editor.action(Action::Drag { + x: buffer_coord.x as i32, + y: buffer_coord.y as i32, + }); + } + + Ok(()) +} + +pub(super) fn handle_drag_continue( + trigger: Trigger<Pointer<Drag>>, + mut editor: Query<(&InputState, &mut CosmicEditor)>, + mut font_system: ResMut<CosmicFontSystem>, +) { + let font_system = &mut font_system.0; + let event = &trigger.event; + let entity = trigger.target; + + if event.button != PointerButton::Primary { + return; + } + + let Ok((input_state, mut editor)) = editor.get_mut(entity) else { + warn_no_editor_on_picking_event("handling cursor `Drag` event"); + return; + }; + + input_state.continue_dragging(); + + if let InputState::Dragging { + initial_buffer_coord, + } = *input_state + { + let new_buffer_coord = initial_buffer_coord + event.distance; + editor.action( + font_system, + Action::Drag { + x: new_buffer_coord.x as i32, + y: new_buffer_coord.y as i32, + }, + ); + } +} + +pub(super) fn handle_dragend( + trigger: Trigger<Pointer<DragEnd>>, + mut editor: Query<&mut InputState, With<CosmicEditBuffer>>, +) { + let event = &trigger.event; + let entity = trigger.target; + + if event.button != PointerButton::Primary { + return; + } + + let Ok(entity_mut) = editor.get_mut(entity) else { + warn_no_editor_on_picking_event("handling cursor `DragEnd` event"); + return; + }; + let input_state = entity_mut.into_inner(); + + input_state.end_dragging(); +} diff --git a/src/input/hover.rs b/src/input/hover.rs new file mode 100644 index 0000000..44b9788 --- /dev/null +++ b/src/input/hover.rs @@ -0,0 +1,106 @@ +use bevy::{window::SystemCursorIcon, winit::cursor::CursorIcon}; + +use crate::prelude::*; + +use super::{warn_no_editor_on_picking_event, InputState}; + +/// Whenever a pointer enters a widget +#[derive(Event, Debug, Reflect)] +pub struct TextHoverIn; + +/// Whenever a pointer exits a widget +#[derive(Event, Debug, Reflect)] +pub struct TextHoverOut; + +/// What cursor icon to show when hovering over a widget +/// +/// By default is [`CursorIcon::System(SystemCursorIcon::Text)`] +#[derive(Component, Reflect, Deref)] +pub struct HoverCursor(pub CursorIcon); + +impl Default for HoverCursor { + fn default() -> Self { + Self(CursorIcon::System(SystemCursorIcon::Text)) + } +} + +impl InputState { + /// `Over` event handler + pub fn start_hovering(&mut self) { + trace!("Starting hover"); + match self { + InputState::Idle => *self = InputState::Hovering, + InputState::Hovering | InputState::Dragging { .. } => {} + } + } + + pub fn is_hovering(&self) -> bool { + matches!(self, InputState::Hovering) + } + + /// Handler for [`Move`] event + pub fn continue_hovering(&mut self) { + match self { + InputState::Hovering | InputState::Dragging { .. } => {} + InputState::Idle => { + // handles that case that a drag is finished + *self = InputState::Hovering; + } + } + } + + /// `Out` event handler + pub fn end_hovering(&mut self) { + trace!("Ending hoverr"); + match self { + InputState::Hovering => *self = InputState::Idle, + InputState::Idle | InputState::Dragging { .. } => {} + } + } +} + +pub(super) fn handle_hover_start( + trigger: Trigger<Pointer<Over>>, + mut editor: Query<&mut InputState, With<CosmicEditBuffer>>, + mut hover_in_evw: EventWriter<TextHoverIn>, +) { + let Ok(mut input_state) = editor.get_mut(trigger.target) else { + warn_no_editor_on_picking_event("handling cursor `Over` event"); + return; + }; + + input_state.start_hovering(); + + if input_state.is_hovering() { + hover_in_evw.send(TextHoverIn); + } +} + +pub(super) fn handle_hover_continue( + trigger: Trigger<Pointer<Move>>, + mut editor: Query<&mut InputState, With<CosmicEditBuffer>>, +) { + let Ok(mut input_state) = editor.get_mut(trigger.target) else { + warn_no_editor_on_picking_event("handling cursor `Move` event"); + return; + }; + + input_state.continue_hovering(); +} + +pub(super) fn handle_hover_end( + trigger: Trigger<Pointer<Out>>, + mut editor: Query<&mut InputState, With<CosmicEditBuffer>>, + mut hover_out_evw: EventWriter<TextHoverOut>, +) { + let Ok(mut input_state) = editor.get_mut(trigger.target) else { + warn_no_editor_on_picking_event("handling cursor `Out` event"); + return; + }; + + input_state.end_hovering(); + + if !input_state.is_hovering() { + hover_out_evw.send(TextHoverOut); + } +} diff --git a/src/input/keyboard.rs b/src/input/keyboard.rs new file mode 100644 index 0000000..87bd065 --- /dev/null +++ b/src/input/keyboard.rs @@ -0,0 +1,293 @@ +use bevy::input::keyboard::{Key, KeyboardInput}; +use cosmic_text::{Action, Cursor, Motion, Selection}; + +use crate::{input::CosmicTextChanged, prelude::*, MaxChars, MaxLines}; + +pub(super) fn keypress_command(keys: &ButtonInput<KeyCode>) -> bool { + #[cfg(target_os = "macos")] + let command = keys.any_pressed([KeyCode::SuperLeft, KeyCode::SuperRight]); + + #[cfg(not(target_os = "macos"))] + let command = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]); + + #[cfg(target_arch = "wasm32")] + let command = if web_sys::window() + .unwrap() + .navigator() + .user_agent() + .unwrap_or("NoUA".into()) + .contains("Macintosh") + { + keys.any_pressed([KeyCode::SuperLeft, KeyCode::SuperRight]) + } else { + command + }; + + command +} + +pub(crate) fn kb_move_cursor( + active_editor: Res<FocusedWidget>, + keys: Res<ButtonInput<KeyCode>>, + mut cosmic_edit_query: Query<(&mut CosmicEditor,)>, + mut font_system: ResMut<CosmicFontSystem>, +) { + let Some(active_editor_entity) = active_editor.0 else { + return; + }; + if let Ok((mut editor,)) = cosmic_edit_query.get_mut(active_editor_entity) { + if keys.get_just_pressed().len() != 0 { + editor.cursor_visible = true; + editor.cursor_timer.reset(); + } + + let command = keypress_command(&keys); + + #[cfg(target_arch = "wasm32")] + let command = if web_sys::window() + .unwrap() + .navigator() + .user_agent() + .unwrap_or("NoUA".into()) + .contains("Macintosh") + { + keys.any_pressed([KeyCode::SuperLeft, KeyCode::SuperRight]) + } else { + command + }; + + #[cfg(target_os = "macos")] + let option = keys.any_pressed([KeyCode::AltLeft, KeyCode::AltRight]); + + let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]); + + // if shift key is pressed + let already_has_selection = editor.selection() != Selection::None; + if shift && !already_has_selection { + let cursor = editor.cursor(); + editor.set_selection(Selection::Normal(cursor)); + } + + #[cfg(target_os = "macos")] + let should_jump = command && option; + #[cfg(not(target_os = "macos"))] + let should_jump = command; + + if should_jump && keys.just_pressed(KeyCode::ArrowLeft) { + editor.action(&mut font_system.0, Action::Motion(Motion::PreviousWord)); + if !shift { + editor.set_selection(Selection::None); + } + return; + } + if should_jump && keys.just_pressed(KeyCode::ArrowRight) { + editor.action(&mut font_system.0, Action::Motion(Motion::NextWord)); + if !shift { + editor.set_selection(Selection::None); + } + return; + } + if should_jump && keys.just_pressed(KeyCode::Home) { + editor.action(&mut font_system.0, Action::Motion(Motion::BufferStart)); + if !shift { + editor.set_selection(Selection::None); + } + return; + } + if should_jump && keys.just_pressed(KeyCode::End) { + editor.action(&mut font_system.0, Action::Motion(Motion::BufferEnd)); + if !shift { + editor.set_selection(Selection::None); + } + return; + } + + if keys.just_pressed(KeyCode::ArrowLeft) { + editor.action(&mut font_system.0, Action::Motion(Motion::Left)); + if !shift { + editor.set_selection(Selection::None); + } + return; + } + if keys.just_pressed(KeyCode::ArrowRight) { + editor.action(&mut font_system.0, Action::Motion(Motion::Right)); + if !shift { + editor.set_selection(Selection::None); + } + return; + } + if keys.just_pressed(KeyCode::ArrowUp) { + editor.action(&mut font_system.0, Action::Motion(Motion::Up)); + if !shift { + editor.set_selection(Selection::None); + } + return; + } + if keys.just_pressed(KeyCode::ArrowDown) { + editor.action(&mut font_system.0, Action::Motion(Motion::Down)); + if !shift { + editor.set_selection(Selection::None); + } + return; + } + if keys.just_pressed(KeyCode::Escape) { + editor.action(&mut font_system.0, Action::Escape); + } + if command && keys.just_pressed(KeyCode::KeyA) { + editor.action(&mut font_system.0, Action::Motion(Motion::BufferEnd)); + let current_cursor = editor.cursor(); + editor.set_selection(Selection::Normal(Cursor { + line: 0, + index: 0, + affinity: current_cursor.affinity, + })); + return; + } + if keys.just_pressed(KeyCode::Home) { + editor.action(&mut font_system.0, Action::Motion(Motion::Home)); + if !shift { + editor.set_selection(Selection::None); + } + return; + } + if keys.just_pressed(KeyCode::End) { + editor.action(&mut font_system.0, Action::Motion(Motion::End)); + if !shift { + editor.set_selection(Selection::None); + } + return; + } + if keys.just_pressed(KeyCode::PageUp) { + editor.action(&mut font_system.0, Action::Motion(Motion::PageUp)); + if !shift { + editor.set_selection(Selection::None); + } + return; + } + if keys.just_pressed(KeyCode::PageDown) { + editor.action(&mut font_system.0, Action::Motion(Motion::PageDown)); + if !shift { + editor.set_selection(Selection::None); + } + } + } +} + +pub(crate) fn kb_input_text( + active_editor: Res<FocusedWidget>, + keys: Res<ButtonInput<KeyCode>>, + mut char_evr: EventReader<KeyboardInput>, + mut cosmic_edit_query: Query<( + &mut CosmicEditor, + &MaxLines, + &MaxChars, + Entity, + Option<&ReadOnly>, + )>, + mut evw_changed: EventWriter<CosmicTextChanged>, + mut font_system: ResMut<CosmicFontSystem>, + mut is_deleting: Local<bool>, +) { + let Some(active_editor_entity) = active_editor.0 else { + return; + }; + + if let Ok((mut editor, max_lines, max_chars, entity, readonly_opt)) = + cosmic_edit_query.get_mut(active_editor_entity) + { + let command = keypress_command(&keys); + if keys.get_just_pressed().len() != 0 { + editor.cursor_visible = true; + editor.cursor_timer.reset(); + } + let readonly = readonly_opt.is_some(); + + if keys.just_pressed(KeyCode::Backspace) & !readonly { + // fix for issue #8 + let select = editor.selection(); + match select { + Selection::Line(cursor) => { + if editor.cursor().line == cursor.line && editor.cursor().index == cursor.index + { + editor.set_selection(Selection::None); + } + } + Selection::Normal(cursor) => { + if editor.cursor().line == cursor.line && editor.cursor().index == cursor.index + { + editor.set_selection(Selection::None); + } + } + Selection::Word(cursor) => { + if editor.cursor().line == cursor.line && editor.cursor().index == cursor.index + { + editor.set_selection(Selection::None); + } + } + Selection::None => {} + } + + *is_deleting = true; + } + + if keys.just_released(KeyCode::Backspace) { + *is_deleting = false; + } + if keys.just_pressed(KeyCode::Delete) && !readonly { + editor.action(&mut font_system.0, Action::Delete); + editor.with_buffer_mut(|b| b.set_redraw(true)); + } + + if readonly { + return; + } + + let mut is_edit = false; + let mut is_return = false; + if keys.just_pressed(KeyCode::Enter) { + is_return = true; + if (max_lines.0 == 0 || editor.with_buffer(|b| b.lines.len()) < max_lines.0) + && (max_chars.0 == 0 || editor.get_text().len() < max_chars.0) + { + // to have new line on wasm rather than E + is_edit = true; + editor.action(&mut font_system.0, Action::Insert('\n')); + } + } + + if !is_return { + for char_ev in char_evr.read() { + is_edit = true; + if *is_deleting { + editor.action(&mut font_system.0, Action::Backspace); + } else if !command + && (max_chars.0 == 0 || editor.get_text().len() < max_chars.0) + && matches!(char_ev.state, bevy::input::ButtonState::Pressed) + { + match &char_ev.logical_key { + Key::Character(char) => { + let b = char.as_bytes(); + for c in b { + let c: char = (*c).into(); + editor.action(&mut font_system.0, Action::Insert(c)); + } + } + Key::Space => { + editor.action(&mut font_system.0, Action::Insert(' ')); + } + _ => (), + } + } + } + } + + if !is_edit { + return; + } + + evw_changed.send(CosmicTextChanged(( + entity, + editor.with_buffer_mut(|b| b.get_text()), + ))); + } +} diff --git a/src/input/scroll.rs b/src/input/scroll.rs new file mode 100644 index 0000000..5d77adf --- /dev/null +++ b/src/input/scroll.rs @@ -0,0 +1,35 @@ +use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; +use cosmic_text::Action; + +use crate::{prelude::*, ScrollEnabled}; + +pub(crate) fn scroll( + mut editor: Query<(&mut CosmicEditor, &ScrollEnabled)>, + mut font_system: ResMut<CosmicFontSystem>, + mut scroll_evr: EventReader<MouseWheel>, +) { + let font_system = &mut font_system.0; + for (mut editor, scroll_enabled) in editor.iter_mut() { + let mut editor = editor.borrow_with(font_system); + + if scroll_enabled.should_scroll() { + for ev in scroll_evr.read() { + match ev.unit { + MouseScrollUnit::Line => { + // trace!(?ev, "Line"); + editor.action(Action::Scroll { + lines: -ev.y as i32, + }); + } + MouseScrollUnit::Pixel => { + // trace!(?ev, "Pixel"); + let line_height = editor.with_buffer(|b| b.metrics().line_height); + editor.action(Action::Scroll { + lines: -(ev.y / line_height) as i32, + }); + } + } + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 8052432..3af3c29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,71 +43,73 @@ //! ## Feature flags #![doc = document_features::document_features!()] //! +//! ## Implementation details +//! +//! See [render_implementations](crate::render_implementations) +//! //! ## License //! //! MIT or Apache-2.0 #![allow(clippy::type_complexity)] +pub use bevy::text::cosmic_text; +pub use primary::*; +/// Contains the library global important types you probably want to explore first +mod primary; + pub mod prelude { - // external re-exports + // non-pub external re-exports pub(crate) use bevy::prelude::*; pub(crate) use bevy::text::SwashCache; - #[cfg_attr(not(doc), allow(unused_imports))] pub(crate) use cosmic_text::Buffer; + pub(crate) use cosmic_text::Edit as _; + #[allow(unused_imports)] + pub(crate) use std::ops::{Deref as _, DerefMut as _}; - // internal re-exports - pub(crate) use crate::buffer::BufferExtras as _; + // non-pub internal re-exports + pub(crate) use crate::buffer::{BufferMutExtras as _, BufferRefExtras as _}; pub(crate) use crate::cosmic_text; pub(crate) use crate::primary::CosmicRenderOutput; + pub(crate) use crate::render_implementations; pub(crate) use crate::utils::*; // public internal re-exports pub use crate::buffer::CosmicEditBuffer; // todo: migrate to builtin bevy CosmicBuffer pub use crate::cosmic_edit::CosmicFontSystem; // todo: migrate to using builtin bevy cosmic font system - pub use crate::cosmic_edit::{CosmicEditor, DefaultAttrs, ReadOnly}; + pub use crate::cosmic_edit::{CosmicWrap, DefaultAttrs, ReadOnly}; + pub use crate::cosmic_text::{Color as CosmicColor, Style as FontStyle, Weight as FontWeight}; + pub use crate::editor::CosmicEditor; + pub use crate::editor_buffer::EditorBuffer; pub use crate::focus::FocusedWidget; - pub use crate::primary::{CosmicEditPlugin, CosmicFontConfig, CosmicPrimaryCamera}; - pub use crate::render_targets::{TextEdit, TextEdit2d}; - pub use crate::utils::{ - change_active_editor_sprite, change_active_editor_ui, deselect_editor_on_esc, - print_editor_text, ColorExtras as _, - }; - #[doc(no_inline)] - pub use bevy::text::cosmic_text::{ - Color as CosmicColor, Style as FontStyle, Weight as FontWeight, - }; + pub use crate::input::click::focus_on_click; + pub use crate::primary::{CosmicEditPlugin, CosmicFontConfig}; + pub use crate::render_implementations::{TextEdit, TextEdit2d}; + pub use crate::utils::{deselect_editor_on_esc, print_editor_text, ColorExtras as _}; } -pub use bevy::text::cosmic_text; - -pub use primary::{CosmicEditPlugin, CosmicFontConfig, CosmicPrimaryCamera}; -/// Contains the library global important types you probably want to explore first -mod primary; - -pub use buffer::CosmicEditBuffer; -mod buffer; -pub use cosmic_edit::{ - CosmicBackgroundColor, CosmicBackgroundImage, CosmicEditor, CosmicFontSystem, CosmicTextAlign, - CosmicWrap, CursorColor, DefaultAttrs, MaxChars, MaxLines, ReadOnly, ScrollEnabled, - SelectedTextColor, SelectionColor, -}; +// required modules +// non-pub required +pub use buffer::*; +pub use cosmic_edit::*; +pub use editor_buffer::*; +pub use editor_buffer::{buffer, editor}; +pub use focus::*; mod cosmic_edit; -pub use cursor::{CursorPluginDisabled, HoverCursor, TextHoverIn, TextHoverOut}; -mod cursor; -pub use events::CosmicTextChanged; -mod events; -pub use focus::FocusedWidget; -mod focus; -pub use input::InputSet; -mod input; -pub use password::Password; -mod password; -pub use placeholder::Placeholder; -mod placeholder; +mod double_click; +mod editor_buffer; +pub mod focus; mod render; -pub use user_select::UserSelectNone; -mod user_select; + +// pub required +pub use input::hover::HoverCursor; +pub mod input; +pub mod render_implementations; pub mod utils; -mod widget; -pub(crate) use render_targets::{ChangedCosmicWidgetSize, CosmicWidgetSize}; -pub mod render_targets; + +// extra modules +pub mod password; +pub mod placeholder; +pub mod user_select; + +#[cfg(feature = "internal-debugging")] +mod debug; diff --git a/src/password.rs b/src/password.rs index 5cd1966..7aec85d 100644 --- a/src/password.rs +++ b/src/password.rs @@ -15,13 +15,13 @@ impl Plugin for PasswordPlugin { fn build(&self, app: &mut App) { app.add_systems( PreUpdate, - (hide_password_text.before(crate::input::input_mouse),), + (hide_password_text.before(crate::input::InputSet),), ) .add_systems( Update, (restore_password_text - .before(crate::input::kb_input_text) - .after(crate::input::kb_move_cursor),), + .before(crate::input::keyboard::kb_input_text) + .after(crate::input::keyboard::kb_move_cursor),), ) .add_systems( PostUpdate, @@ -38,11 +38,14 @@ impl Plugin for PasswordPlugin { /// /// ``` /// # use bevy::prelude::*; -/// # use bevy_cosmic_edit::*; +/// # use bevy_cosmic_edit::prelude::*; +/// use bevy_cosmic_edit::password::Password; +/// /// # /// # fn setup(mut commands: Commands) { /// // Create a new cosmic bundle /// commands.spawn(( +/// TextEdit2d, /// CosmicEditBuffer::default(), /// Sprite { /// custom_size: Some(Vec2::new(300.0, 40.0)), @@ -84,79 +87,79 @@ impl Password { fn hide_password_text( mut q: Query<( &mut Password, - &mut CosmicEditBuffer, + EditorBuffer, &DefaultAttrs, - Option<&mut CosmicEditor>, Option<&Placeholder>, )>, mut font_system: ResMut<CosmicFontSystem>, ) { - for (mut password, mut buffer, attrs, editor_opt, placeholder_opt) in q.iter_mut() { + for (mut password, mut editor, attrs, placeholder_opt) in q.iter_mut() { if let Some(placeholder) = placeholder_opt { if placeholder.is_active() { // doesn't override placeholder continue; } } - if let Some(mut editor) = editor_opt { - let mut cursor = editor.cursor(); - let mut selection = editor.selection(); - - editor.with_buffer_mut(|buffer| { - let text = buffer.get_text(); - - // Translate cursor to correct position for blocker glyphs - let translate_cursor = |c: &mut Cursor| { - let (pre, _post) = text.split_at(c.index); - let graphemes = pre.graphemes(true).count(); - c.index = graphemes * password.glyph.len_utf8(); - }; - - translate_cursor(&mut cursor); - - // Translate selection cursor - match selection { - Selection::None => {} - Selection::Line(ref mut c) => { - translate_cursor(c); - } - Selection::Word(ref mut c) => { - translate_cursor(c); + match editor.editor() { + Some(editor) => { + let mut cursor = editor.cursor(); + let mut selection = editor.selection(); + + editor.with_buffer_mut(|buffer| { + let text = buffer.get_text(); + + // Translate cursor to correct position for blocker glyphs + let translate_cursor = |c: &mut Cursor| { + let (pre, _post) = text.split_at(c.index); + let graphemes = pre.graphemes(true).count(); + c.index = graphemes * password.glyph.len_utf8(); + }; + + translate_cursor(&mut cursor); + + // Translate selection cursor + match selection { + Selection::None => {} + Selection::Line(ref mut c) => { + translate_cursor(c); + } + Selection::Word(ref mut c) => { + translate_cursor(c); + } + Selection::Normal(ref mut c) => { + translate_cursor(c); + } } - Selection::Normal(ref mut c) => { - translate_cursor(c); - } - } - // Update text to blockers - buffer.set_text( + // Update text to blockers + buffer.set_text( + &mut font_system, + password + .glyph + .to_string() + .repeat(text.graphemes(true).count()) + .as_str(), + attrs.as_attrs(), + Shaping::Advanced, + ); + + password.real_text = text; + }); + + editor.set_cursor(cursor); + editor.set_selection(selection); + } + None => { + let text = editor.get_text(); + + editor.set_text( &mut font_system, - password - .glyph - .to_string() - .repeat(text.graphemes(true).count()) - .as_str(), + password.glyph.to_string().repeat(text.len()).as_str(), attrs.as_attrs(), - Shaping::Advanced, ); - password.real_text = text; - }); - - editor.set_cursor(cursor); - editor.set_selection(selection); - - continue; + } } - - let text = buffer.get_text(); - - buffer.set_text( - &mut font_system, - password.glyph.to_string().repeat(text.len()).as_str(), - attrs.as_attrs(), - ); - password.real_text = text; } } diff --git a/src/placeholder.rs b/src/placeholder.rs index 55d6489..2aca341 100644 --- a/src/placeholder.rs +++ b/src/placeholder.rs @@ -1,5 +1,5 @@ use crate::{ - cosmic_edit::DefaultAttrs, events::CosmicTextChanged, input::InputSet, prelude::*, + cosmic_edit::DefaultAttrs, input::CosmicTextChanged, input::InputSet, prelude::*, render::RenderSet, }; use cosmic_text::{Attrs, Edit}; @@ -9,7 +9,8 @@ use cosmic_text::{Attrs, Edit}; /// ``` /// # use bevy::prelude::*; /// # use bevy_cosmic_edit::prelude::*; -/// use bevy_cosmic_edit::Placeholder; +/// # use bevy_cosmic_edit::cosmic_text::Attrs; +/// use bevy_cosmic_edit::placeholder::Placeholder; /// /// # fn setup(mut commands: Commands) { /// commands.spawn(( @@ -71,7 +72,7 @@ impl Plugin for PlaceholderPlugin { } fn add_placeholder_to_buffer( - mut q: Query<(&mut CosmicEditBuffer, &mut Placeholder)>, + mut q: Query<(EditorBuffer, &mut Placeholder)>, mut font_system: ResMut<CosmicFontSystem>, ) { for (mut buffer, mut placeholder) in q.iter_mut() { diff --git a/src/primary.rs b/src/primary.rs index e2e41f1..502591f 100644 --- a/src/primary.rs +++ b/src/primary.rs @@ -1,5 +1,7 @@ use std::path::PathBuf; +use bevy::ecs::world::DeferredWorld; + use crate::prelude::*; /// Plugin struct that adds systems and initializes resources related to cosmic edit functionality. @@ -15,62 +17,26 @@ impl Plugin for CosmicEditPlugin { app.add_plugins(( crate::cosmic_edit::plugin, - crate::buffer::BufferPlugin, + // crate::buffer::BufferPlugin, + crate::editor_buffer::EditorBufferPlugin, crate::render::RenderPlugin, - crate::widget::WidgetPlugin, crate::input::InputPlugin, crate::focus::FocusPlugin, - crate::cursor::CursorPlugin, crate::placeholder::PlaceholderPlugin, crate::password::PasswordPlugin, - crate::events::EventsPlugin, crate::user_select::UserSelectPlugin, + crate::double_click::plugin, )) // TODO: Use the builtin bevy CosmicFontSystem .insert_resource(crate::cosmic_edit::CosmicFontSystem(font_system)); app.register_type::<CosmicRenderOutput>(); + + #[cfg(feature = "internal-debugging")] + app.add_plugins(crate::debug::plugin); } } -/// Attach to primary camera, and enable the `multicam` feature to use multiple cameras. -/// Will panic if no Camera's without this component exist and the `multicam` feature is enabled. -/// -/// A very basic example which doesn't panic: -/// ```rust,no_run -/// use bevy::prelude::*; -/// use bevy_cosmic_edit::prelude::*; -/// -/// fn main() { -/// App::new() -/// .add_plugins(( -/// DefaultPlugins, -/// CosmicEditPlugin::default(), -/// )) -/// .add_systems(Startup, setup) -/// .run(); -/// } -/// -/// fn setup(mut commands: Commands) { -/// commands.spawn((Camera3d::default(), CosmicPrimaryCamera)); -/// commands.spawn(( -/// Camera3d::default(), -/// Camera { -/// order: 2, -/// ..default() -/// }, -/// )); -/// } -/// ``` -#[derive(Component, Debug, Default)] -pub struct CosmicPrimaryCamera; - -#[cfg(feature = "multicam")] -pub(crate) type CameraFilter = With<CosmicPrimaryCamera>; - -#[cfg(not(feature = "multicam"))] -pub(crate) type CameraFilter = (); - /// Resource struct that holds configuration options for cosmic fonts. #[derive(Resource, Clone)] pub struct CosmicFontConfig { @@ -93,9 +59,25 @@ impl Default for CosmicFontConfig { } /// Used to ferry data from a [`CosmicEditBuffer`] -#[derive(Component, Reflect, Default, Debug, Deref)] +#[derive(Component, Default, Reflect, Debug, Deref)] +#[component(on_add = new_image_from_default)] pub(crate) struct CosmicRenderOutput(pub(crate) Handle<Image>); +/// Without this, multiple buffers will show the same image +/// as the focussed editor. IDK why +fn new_image_from_default( + mut world: DeferredWorld, + entity: Entity, + _: bevy::ecs::component::ComponentId, +) { + let mut images = world.resource_mut::<Assets<Image>>(); + let default_image = images.add(Image::default()); + *world + .entity_mut(entity) + .get_mut::<CosmicRenderOutput>() + .unwrap() = CosmicRenderOutput(default_image); +} + fn create_cosmic_font_system(cosmic_font_config: CosmicFontConfig) -> cosmic_text::FontSystem { let locale = sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")); let mut db = cosmic_text::fontdb::Database::new(); @@ -131,6 +113,7 @@ mod tests { } #[test] + #[ignore] // would need to support MinimalPlugins as well as DefaultPlugins fn test_spawn_cosmic_edit() { let mut app = App::new(); app.add_plugins(TaskPoolPlugin::default()); @@ -154,6 +137,7 @@ mod tests { let mut text_nodes_query = app.world_mut().query::<&CosmicEditBuffer>(); for cosmic_editor in text_nodes_query.iter(app.world()) { insta::assert_debug_snapshot!(cosmic_editor + .inner() .lines .iter() .map(|line| line.text()) diff --git a/src/render.rs b/src/render.rs index ecfe228..9bb245d 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,9 +1,8 @@ -use crate::widget::CosmicPadding; -use crate::{cosmic_edit::ReadOnly, prelude::*, widget::WidgetSet}; -use crate::{cosmic_edit::*, CosmicWidgetSize}; +use crate::{cosmic_edit::ReadOnly, prelude::*}; +use crate::{cosmic_edit::*, BufferMutExtras}; use bevy::render::render_resource::Extent3d; -use cosmic_text::{Color, Edit}; use image::{imageops::FilterType, GenericImageView}; +use render_implementations::CosmicWidgetSize; /// System set for cosmic text rendering systems. Runs in [`PostUpdate`] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] @@ -16,26 +15,41 @@ impl Plugin for RenderPlugin { if !app.world().contains_resource::<SwashCache>() { app.insert_resource(SwashCache::default()); } else { - debug!("Skipping inserting `SwashCache` resource"); + debug!( + "Skipping inserting `SwashCache` resource as bevy has already inserted it for us" + ); } - app.add_systems(Update, blink_cursor).add_systems( - PostUpdate, - (render_texture,).in_set(RenderSet).after(WidgetSet), - ); + app.add_systems( + First, + update_internal_target_handles.pipe(render_implementations::debug_error), + ) + .add_systems(PostUpdate, (render_texture,).in_set(RenderSet)); } } -pub(crate) fn blink_cursor(mut q: Query<&mut CosmicEditor, Without<ReadOnly>>, time: Res<Time>) { - for mut e in q.iter_mut() { - e.cursor_timer.tick(time.delta()); - if e.cursor_timer.just_finished() { - e.cursor_visible = !e.cursor_visible; - e.set_redraw(true); - } +/// Every frame updates the output (in [`CosmicRenderOutput`]) to its receiver +/// on the same entity, e.g. [`Sprite`] +fn update_internal_target_handles( + mut buffers_q: Query< + (&CosmicRenderOutput, render_implementations::OutputToEntity), + With<CosmicEditBuffer>, + >, +) -> render_implementations::Result<()> { + for (CosmicRenderOutput(output_data), mut output_components) in buffers_q.iter_mut() { + output_components.write_image_data(output_data)?; } + + Ok(()) } -fn draw_pixel(buffer: &mut [u8], width: i32, height: i32, x: i32, y: i32, color: Color) { +fn draw_pixel( + buffer: &mut [u8], + width: i32, + height: i32, + x: i32, + y: i32, + color: cosmic_text::Color, +) { let a_a = color.a() as u32; if a_a == 0 { // Do not draw if alpha is zero @@ -69,18 +83,63 @@ fn draw_pixel(buffer: &mut [u8], width: i32, height: i32, x: i32, y: i32, color: let out = premul + (bg.to_srgba() * (1.0 - fg.alpha)); - buffer[offset + 2] = (out.blue * 255.0) as u8; - buffer[offset + 1] = (out.green * 255.0) as u8; buffer[offset] = (out.red * 255.0) as u8; + buffer[offset + 1] = (out.green * 255.0) as u8; + buffer[offset + 2] = (out.blue * 255.0) as u8; buffer[offset + 3] = (out.alpha * 255.0) as u8; } +pub(crate) struct WidgetBufferCoordTransformation { + /// Padding between the top of the render target and the + /// top of the buffer + top_padding: f32, + + render_target_size: Vec2, +} + +impl WidgetBufferCoordTransformation { + pub fn new(vertical_align: VerticalAlign, render_target_size: Vec2, buffer_size: Vec2) -> Self { + let top_padding = match vertical_align { + VerticalAlign::Top => 0.0, + VerticalAlign::Bottom => (render_target_size.y - buffer_size.y).max(0.0), + VerticalAlign::Center => ((render_target_size.y - buffer_size.y) / 2.0).max(0.0), + }; + // debug!(?top_padding, ?render_target_height, ?buffer_height); + Self { + top_padding, + render_target_size, + } + } + + /// If you have the buffer coord, used for rendering + // Confusing ngl, but it works + pub fn buffer_to_widget(&self, buffer: Vec2) -> Vec2 { + Vec2::new(buffer.x, buffer.y + self.top_padding) + } + + /// If you have the relative widget coord centered (0, 0) in the middle of the widget, + /// returns the buffer coord starting (0, 0) top left and working downward + pub fn widget_origined_to_buffer_topleft(&self, widget: Vec2) -> Vec2 { + Vec2::new( + widget.x + self.render_target_size.x / 2., + -widget.y + self.render_target_size.y / 2. - self.top_padding, + ) + } + + pub fn widget_topleft_to_buffer_topleft(&self, widget: Vec2) -> Vec2 { + Vec2::new(widget.x, widget.y - self.top_padding) + } + + #[allow(dead_code)] + pub(crate) fn debug_top_padding(&self) { + debug!(?self.top_padding); + } +} + /// Renders to the [CosmicRenderOutput] -#[allow(unused_mut)] // for .set_redraw(false) commented out fn render_texture( mut query: Query<( - Option<&mut CosmicEditor>, - &mut CosmicEditBuffer, + EditorBuffer, &DefaultAttrs, &CosmicBackgroundImage, &CosmicBackgroundColor, @@ -89,18 +148,16 @@ fn render_texture( Option<&SelectedTextColor>, &CosmicRenderOutput, CosmicWidgetSize, - &CosmicPadding, - &XOffset, Option<&ReadOnly>, &CosmicTextAlign, + &CosmicWrap, )>, mut font_system: ResMut<CosmicFontSystem>, mut images: ResMut<Assets<Image>>, mut swash_cache_state: ResMut<SwashCache>, ) { for ( - editor, - mut buffer, + mut editor, attrs, background_image, fill_color, @@ -109,18 +166,18 @@ fn render_texture( selected_text_color_option, canvas, size, - padding, - x_offset, readonly_opt, - position, + text_align, + wrap, ) in query.iter_mut() { - let Ok(size) = size.logical_size() else { + let font_system = &mut font_system.0; + let Ok(render_target_size) = size.logical_size() else { continue; }; // avoids a panic - if size.x == 0. || size.y == 0. { + if render_target_size.x == 0. || render_target_size.y == 0. { debug!( message = "Size of buffer is zero, skipping", // once = "This log only appears once" @@ -129,14 +186,14 @@ fn render_texture( } // Draw background - let mut pixels = vec![0; size.x as usize * size.y as usize * 4]; + let mut pixels = vec![0; render_target_size.x as usize * render_target_size.y as usize * 4]; if let Some(bg_image) = background_image.0.clone() { if let Some(image) = images.get(&bg_image) { let mut dynamic_image = image.clone().try_into_dynamic().unwrap(); - if image.size() != size.as_uvec2() { + if image.size() != render_target_size.as_uvec2() { dynamic_image = dynamic_image.resize_to_fill( - size.x as u32, - size.y as u32, + render_target_size.x as u32, + render_target_size.y as u32, FilterType::Triangle, ); } @@ -164,29 +221,60 @@ fn render_texture( .color_opt .unwrap_or(cosmic_text::Color::rgb(0, 0, 0)); - let min_pad = match position { - CosmicTextAlign::Center { padding } => *padding as f32, - CosmicTextAlign::TopLeft { padding } => *padding as f32, - CosmicTextAlign::Left { padding } => *padding as f32, - }; + // compute y-offset + let buffer_size = editor.borrow_with(font_system).expected_size(); + let transformation = WidgetBufferCoordTransformation::new( + text_align.vertical, + render_target_size, + buffer_size, + ); + // let mut actually_rendered_max = IVec2::ZERO; + // let mut actually_rendered_min = IVec2::new(i32::MAX, i32::MAX); let draw_closure = |x, y, w, h, color| { for row in 0..h as i32 { for col in 0..w as i32 { + let buffer_coord = IVec2::new(x + col, y + row); + // actually_rendered_max = actually_rendered_max.max(buffer_coord); + // actually_rendered_min = actually_rendered_min.min(buffer_coord); + + // compute padding_top + let widget_coord = transformation + .buffer_to_widget(buffer_coord.as_vec2()) + .as_ivec2(); + + // actually draw pixel draw_pixel( &mut pixels, - size.x as i32, - size.y as i32, - x + col + padding.x.max(min_pad) as i32 - x_offset.left as i32, - y + row + padding.y as i32, + render_target_size.x as i32, + render_target_size.y as i32, + widget_coord.x, + widget_coord.y, color, ); } } }; + editor.set_size( + font_system, + Some(match wrap { + CosmicWrap::Wrap => render_target_size.x, + // probably high enough + CosmicWrap::InfiniteLine => f32::MAX / 10f32.powi(3), + }), + Some(render_target_size.y), + ); + if let Some(alignment) = text_align.horizontal { + for line in &mut editor.lines { + line.set_align(Some(alignment.into())); + } + } + // Draw glyphs - if let Some(mut editor) = editor { + if let Some(editor) = editor.editor() { + // todo: optimizations (see below comments) + editor.set_redraw(true); if !editor.redraw() { continue; } @@ -206,8 +294,24 @@ fn render_texture( .map(|selected_text_color| selected_text_color.0.to_cosmic()) .unwrap_or(font_color); + // try to fix annoying scroll behaviour + // by only allowing vertical scrolling if the buffer is actually larger than the canvas + // let mut scroll = editor.with_buffer(|b| b.scroll()); + // if buffer_size.y + 10.0 < render_target_size.y { + // trace!( + // message = "Ignoring vertical scroll as buffer is smaller than canvas", + // ?buffer_size.y, + // ?render_target_size.y + // ); + // scroll.vertical = 0.0; + // } + // editor.with_buffer_mut(|b| b.set_scroll(scroll)); + + // let new_buffer_size = editor.expected_size(); + + let mut editor = editor.borrow_with(font_system); + editor.shape_as_needed(false); editor.draw( - &mut font_system.0, &mut swash_cache_state.0, font_color, cursor_color, @@ -215,19 +319,46 @@ fn render_texture( selected_text_color, draw_closure, ); + + // if coord calculations seem to be buggy, this code may help you to debug + // let actually_rendered_buffer_size = actually_rendered_max - actually_rendered_min; + // trace!( + // ?buffer_size, + // ?new_buffer_size, + // ?actually_rendered_buffer_size + // ); + // transformation.debug_top_padding(); + // debug check only + // if (new_buffer_size.as_ivec2() - actually_rendered_buffer_size) + // .as_vec2() + // .length() + // > 5.0 + // { + // warn_once!( + // message = "Calculations of buffer sizes are off by a significant amount", + // note = "This is likely an internal bug with bevy_cosmic_edit" + // ); + // } + // TODO: Performance optimization, read all possible render-input // changes and only redraw if necessary // editor.set_redraw(false); } else { - if !buffer.redraw() { + // todo: performance optimizations (see comments above/below) + editor.set_redraw(true); + if !editor.redraw() { continue; } - buffer.draw( - &mut font_system.0, + + // editor.borrow_with(font_system).compute_everything(); + editor.shape_until_scroll(font_system, false); + editor.draw( + font_system, &mut swash_cache_state.0, font_color, draw_closure, ); + // TODO: Performance optimization, read all possible render-input // changes and only redraw if necessary // buffer.set_redraw(false); @@ -238,8 +369,8 @@ fn render_texture( // Updates the stored asset image with the computed pixels prev_image.data.extend_from_slice(pixels.as_slice()); prev_image.resize(Extent3d { - width: size.x as u32, - height: size.y as u32, + width: render_target_size.x as u32, + height: render_target_size.y as u32, depth_or_array_layers: 1, }); } diff --git a/src/render_implementations.rs b/src/render_implementations.rs new file mode 100644 index 0000000..76642f6 --- /dev/null +++ b/src/render_implementations.rs @@ -0,0 +1,97 @@ +//! Generalizes over render target implementations. All code that +//! depends on the specific render target implementation should +//! live in this module. +//! +//! All implementations should use [`bevy::picking`] for interactions, +//! even [`SourceType::Ui`], for consistency. +//! +//! ## Sprite: [`TextEdit2d`] +//! Requires [`Sprite`] component and requires [`Sprite.custom_size`] to be Some( non-zero ) +//! +//! ## UI: [`TextEdit`] +//! Requires [`ImageNode`] for rendering +// TODO: Remove `CosmicWidgetSize`? + +mod prelude { + pub(super) use super::error::Result; + pub(super) use super::{RenderTargetError, SourceType}; + pub(super) use super::{RenderTypeScan, RenderTypeScanItem}; +} + +pub use error::*; +mod error { + pub type Error = crate::render_implementations::RenderTargetError; + pub type Result<T> = core::result::Result<T, RenderTargetError>; + + #[derive(Debug)] + pub enum RenderTargetError { + /// When no recognized [`SourceType`] could be found + NoTargetsAvailable, + + /// When more than one [`SourceType`] was detected. + /// + /// This will always be thrown if more than one target type is available, + /// there is no propritisation procedure as this should be considered a + /// logic error. + MoreThanOneTargetAvailable, + + /// When a [`RenderTypeScan`] was successfully conducted yet the expected + /// [required component/s](https://docs.rs/bevy/latest/bevy/ecs/prelude/trait.Component.html#required-components) + /// were not found + RequiredComponentNotAvailable { + debug_name: String, + }, + + /// When using [`SourceType::Sprite`], you must set [`Sprite.custom_size`] + SpriteCustomSizeNotSet, + + SpriteUnexpectedNormal, + + SpriteExpectedHitdataPosition, + + UiExpectedCursorPosition, + } + + impl RenderTargetError { + pub fn required_component_missing<C: bevy::prelude::Component>() -> Self { + Self::RequiredComponentNotAvailable { + debug_name: format!("{:?}", core::any::type_name::<C>()), + } + } + } +} + +pub(crate) use coords::*; +mod coords; +pub(crate) use output::*; +mod output; +pub(crate) use widget_size::*; +mod widget_size; +pub(crate) use scan::*; +mod scan; + +use crate::prelude::*; + +/// The top level UI text edit component +/// +/// Adding [`TextEdit`] will pull in the required components for setting up +/// a text edit widget, notably [`CosmicEditBuffer`] +/// +/// Hopefully this API will eventually mirror [`bevy::prelude::Text`]. +/// See [`CosmicEditBuffer`] for more information. +#[derive(Component)] +#[require(ImageNode, Button, bevy::ui::RelativeCursorPosition, CosmicEditBuffer)] +pub struct TextEdit; + +/// The top-level 2D text edit component +/// +/// Adding [`TextEdit2d`] will pull in the required components for setting up +/// a 2D text editor using a [`Sprite`] with [`Sprite.custom_size`] set, +/// to set the size of the text editor add a [`Sprite`] component with +/// [`Sprite.custom_size`] set. +/// +/// Hopefully this API will eventually mirror [`bevy::prelude::Text2d`]. +/// See [`CosmicEditBuffer`] for more information. +#[derive(Component)] +#[require(Sprite, CosmicEditBuffer)] +pub struct TextEdit2d; diff --git a/src/render_implementations/coords.rs b/src/render_implementations/coords.rs new file mode 100644 index 0000000..eef85e1 --- /dev/null +++ b/src/render_implementations/coords.rs @@ -0,0 +1,94 @@ +use bevy::ecs::query::QueryData; +use bevy::picking::backend::HitData; +use bevy::ui::RelativeCursorPosition; +use render_implementations::prelude::*; + +use crate::render::WidgetBufferCoordTransformation; +use crate::render_implementations::CosmicWidgetSize; +use crate::{prelude::*, CosmicTextAlign}; + +/// Responsible for translating a world coordinate to a buffer coordinate +#[derive(QueryData)] +pub(crate) struct RelativeQuery { + widget_size: CosmicWidgetSize, + text_align: &'static CosmicTextAlign, + + sprite_global_transform: &'static GlobalTransform, + ui_cursor_position: Option<&'static RelativeCursorPosition>, +} + +impl<'s> std::ops::Deref for RelativeQueryItem<'s> { + type Target = render_implementations::RenderTypeScanItem<'s>; + + fn deref(&self) -> &Self::Target { + self.widget_size.deref() + } +} + +impl RelativeQueryItem<'_> { + pub fn compute_buffer_coord(&self, hit_data: &HitData, buffer_size: Vec2) -> Result<Vec2> { + match self.scan()? { + SourceType::Sprite => { + if hit_data.normal != Some(Vec3::Z) { + warn!(?hit_data, "Normal is not out of screen, skipping"); + return Err(RenderTargetError::SpriteUnexpectedNormal); + } + + let world_position = hit_data + .position + .ok_or(RenderTargetError::SpriteExpectedHitdataPosition)?; + let RelativeQueryItem { + sprite_global_transform, + text_align, + widget_size, + .. + } = self; + + let position_transform = + GlobalTransform::from(Transform::from_translation(world_position)); + let relative_transform = position_transform.reparented_to(sprite_global_transform); + let relative_position = relative_transform.translation.xy(); + + let render_target_size = widget_size.logical_size()?; + let transformation = WidgetBufferCoordTransformation::new( + text_align.vertical, + render_target_size, + buffer_size, + ); + // .xy swizzle depends on normal vector being perfectly out of screen + let buffer_coord = + transformation.widget_origined_to_buffer_topleft(relative_position); + + Ok(buffer_coord) + } + SourceType::Ui => { + let RelativeQueryItem { + widget_size, + text_align, + ui_cursor_position, + .. + } = self; + let cursor_position_normalized = ui_cursor_position + .ok_or(RenderTargetError::required_component_missing::< + RelativeCursorPosition, + >())? + .normalized + .ok_or(RenderTargetError::UiExpectedCursorPosition)?; + + let widget_size = widget_size.logical_size()?; + let relative_position = cursor_position_normalized * widget_size; + + let transformation = WidgetBufferCoordTransformation::new( + text_align.vertical, + widget_size, + buffer_size, + ); + + let buffer_coord = + transformation.widget_topleft_to_buffer_topleft(relative_position); + + Ok(buffer_coord) + } + } + } +} diff --git a/src/render_implementations/output.rs b/src/render_implementations/output.rs new file mode 100644 index 0000000..7c1d51d --- /dev/null +++ b/src/render_implementations/output.rs @@ -0,0 +1,46 @@ +use bevy::ecs::query::QueryData; +use render_implementations::prelude::*; + +use crate::prelude::*; + +/// Will attempt to find a place on the receiving entity to place +/// a [`Handle<Image>`] +#[derive(QueryData)] +#[query_data(mutable)] +pub(crate) struct OutputToEntity { + scan: RenderTypeScan, + + sprite_target: Option<&'static mut Sprite>, + image_node_target: Option<&'static mut ImageNode>, +} + +impl<'s> std::ops::Deref for OutputToEntityItem<'s> { + type Target = RenderTypeScanItem<'s>; + + fn deref(&self) -> &Self::Target { + &self.scan + } +} + +impl OutputToEntityItem<'_> { + pub fn write_image_data(&mut self, image: &Handle<Image>) -> Result<()> { + match self.scan()? { + SourceType::Sprite => { + let sprite = self + .sprite_target + .as_mut() + .ok_or(RenderTargetError::required_component_missing::<Sprite>())?; + sprite.image = image.clone_weak(); + Ok(()) + } + SourceType::Ui => { + let image_node = self + .image_node_target + .as_mut() + .ok_or(RenderTargetError::required_component_missing::<ImageNode>())?; + image_node.image = image.clone_weak(); + Ok(()) + } + } + } +} diff --git a/src/render_implementations/scan.rs b/src/render_implementations/scan.rs new file mode 100644 index 0000000..e5c0887 --- /dev/null +++ b/src/render_implementations/scan.rs @@ -0,0 +1,43 @@ +use bevy::ecs::query::QueryData; + +use crate::prelude::*; +use crate::render_implementations::prelude::*; + +/// TODO: Generalize implementations depending on this +/// and add 3D +#[non_exhaustive] +pub(in crate::render_implementations) enum SourceType { + Ui, + Sprite, +} + +#[derive(QueryData)] +pub struct RenderTypeScan { + is_sprite: Has<TextEdit2d>, + is_ui: Has<TextEdit>, +} + +impl RenderTypeScanItem<'_> { + pub fn confirm_conformance(&self) -> Result<()> { + match self.scan() { + Ok(_) => Ok(()), + Err(err) => Err(err), + } + } + + pub(in crate::render_implementations) fn scan(&self) -> Result<SourceType> { + match (self.is_sprite, self.is_ui) { + (true, false) => Ok(SourceType::Sprite), + (false, true) => Ok(SourceType::Ui), + (true, true) => Err(RenderTargetError::MoreThanOneTargetAvailable), + (false, false) => Err(RenderTargetError::NoTargetsAvailable), + } + } +} + +pub(crate) fn debug_error<T>(In(result): In<Result<T>>) { + match result { + Ok(_) => {} + Err(err) => debug!(message = "Error in render target", ?err), + } +} diff --git a/src/render_implementations/widget_size.rs b/src/render_implementations/widget_size.rs new file mode 100644 index 0000000..8cf0f2e --- /dev/null +++ b/src/render_implementations/widget_size.rs @@ -0,0 +1,71 @@ +use bevy::ecs::query::{QueryData, QueryFilter}; +use render_implementations::{RenderTypeScan, RenderTypeScanItem}; + +use crate::prelude::*; +use render_implementations::prelude::*; + +/// Query the (logical) size of a widget +#[derive(QueryData)] +pub struct CosmicWidgetSize { + scan: RenderTypeScan, + + sprite: Option<&'static Sprite>, + ui: Option<&'static ComputedNode>, +} + +/// Allows `.scan()` to be called on a [`CosmicWidgetSize`] through deref +impl<'s> std::ops::Deref for CosmicWidgetSizeItem<'s> { + type Target = RenderTypeScanItem<'s>; + + fn deref(&self) -> &Self::Target { + &self.scan + } +} + +/// An optimization [`QueryFilter`](bevy::ecs::query::QueryFilter) +#[derive(QueryFilter)] +pub(crate) struct ChangedCosmicWidgetSize { + sprite: Changed<Sprite>, + ui: Changed<ComputedNode>, +} + +trait NodeSizeExt { + fn logical_size(&self) -> Vec2; +} + +impl NodeSizeExt for ComputedNode { + fn logical_size(&self) -> Vec2 { + self.size() * self.inverse_scale_factor() + } +} + +impl CosmicWidgetSizeItem<'_> { + /// Automatically logs any errors + pub fn logical_size(&self) -> Result<Vec2> { + let ret = self._logical_size(); + if let Err(err) = &ret { + debug!(message = "Finding the size of a widget failed", ?err); + } + ret + } + + fn _logical_size(&self) -> Result<Vec2> { + let source_type = self.scan.scan()?; + match source_type { + SourceType::Ui => { + let ui = self + .ui + .ok_or(RenderTargetError::required_component_missing::<ComputedNode>())?; + Ok(ui.logical_size()) + } + SourceType::Sprite => { + let sprite = self + .sprite + .ok_or(RenderTargetError::required_component_missing::<Sprite>())?; + Ok(sprite + .custom_size + .ok_or(RenderTargetError::SpriteCustomSizeNotSet)?) + } + } + } +} diff --git a/src/render_targets.rs b/src/render_targets.rs deleted file mode 100644 index d7ddca2..0000000 --- a/src/render_targets.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! Generalizes over render target implementations. -//! -//! ## Sprite: -//! Requires [`Sprite`] component and requires [`Sprite.custom_size`] to be Some( non-zero ) -//! -//! ## UI: -//! Requires [`ImageNode`] for rendering and [`Button`] for [`Interaction`]s -// TODO: Remove `CosmicWidgetSize`? - -use bevy::{ - ecs::query::{QueryData, QueryFilter}, - window::SystemCursorIcon, - winit::cursor::CursorIcon, -}; - -use crate::{prelude::*, primary::CameraFilter, HoverCursor, TextHoverIn, TextHoverOut}; - -/// The top level UI text edit component -/// -/// Adding [`TextEdit`] will pull in the required components for setting up -/// a text edit widget, notably [`CosmicEditBuffer`] -/// -/// Hopefully this API will eventually mirror [`bevy::prelude::Text`]. -/// See [`CosmicEditBuffer`] for more information. -#[derive(Component)] -#[require(ImageNode, Button, CosmicEditBuffer)] -pub struct TextEdit; - -/// The top-level 2D text edit component -/// -/// Adding [`TextEdit2d`] will pull in the required components for setting up -/// a 2D text editor using a [`Sprite`] with [`Sprite.custom_size`] set, -/// to set the size of the text editor add a [`Sprite`] component with -/// [`Sprite.custom_size`] set. -/// -/// Hopefully this API will eventually mirror [`bevy::prelude::Text2d`]. -/// See [`CosmicEditBuffer`] for more information. -#[derive(Component)] -#[require(Sprite, CosmicEditBuffer)] -pub struct TextEdit2d; - -/// TODO: Generalize implementations depending on this -/// and add 3D -pub(crate) enum SourceType { - Ui, - Sprite, -} - -#[derive(Debug)] -pub(crate) enum RenderTargetError { - /// When no recognized [`SourceType`] could be found - NoTargetsAvailable, - - /// When more than one [`SourceType`] was detected. - /// - /// This will always be thrown if more than one target type is available, - /// there is no propritisation procedure as this should be considered a - /// logic error. - MoreThanOneTargetAvailable, - - /// When a [`RenderTypeScan`] was successfully conducted yet the expected - /// [required component/s](https://docs.rs/bevy/latest/bevy/ecs/prelude/trait.Component.html#required-components) - /// were not found - RequiredComponentNotAvailable, - - /// When using [`SourceType::Sprite`], you must set [`Sprite.custom_size`] - SpriteCustomSizeNotSet, -} - -type Result<T> = core::result::Result<T, RenderTargetError>; - -#[derive(QueryData)] -pub(crate) struct RenderTypeScan { - is_sprite: Has<TextEdit2d>, - is_ui: Has<TextEdit>, -} - -impl RenderTypeScanItem<'_> { - pub fn scan(&self) -> Result<SourceType> { - match (self.is_sprite, self.is_ui) { - (true, false) => Ok(SourceType::Sprite), - (false, true) => Ok(SourceType::Ui), - (true, true) => Err(RenderTargetError::MoreThanOneTargetAvailable), - (false, false) => Err(RenderTargetError::NoTargetsAvailable), - } - } -} - -/// Query the size of a widget using any [`SourceType`] -#[derive(QueryData)] -pub(crate) struct CosmicWidgetSize { - scan: RenderTypeScan, - sprite: Option<&'static Sprite>, - ui: Option<&'static ComputedNode>, -} - -/// Allows `.scan()` to be called on a [`CosmicWidgetSize`] through deref -impl<'s> std::ops::Deref for CosmicWidgetSizeItem<'s> { - type Target = RenderTypeScanItem<'s>; - - fn deref(&self) -> &Self::Target { - &self.scan - } -} - -/// An optimization [`QueryFilter`](bevy::ecs::query::QueryFilter) -#[derive(QueryFilter)] -pub(crate) struct ChangedCosmicWidgetSize { - sprite: Changed<Sprite>, - ui: Changed<ComputedNode>, -} - -pub(crate) trait NodeSizeExt { - fn logical_size(&self) -> Vec2; -} - -impl NodeSizeExt for ComputedNode { - fn logical_size(&self) -> Vec2 { - self.size() * self.inverse_scale_factor() - } -} - -impl CosmicWidgetSizeItem<'_> { - pub fn logical_size(&self) -> Result<Vec2> { - let source_type = self.scan.scan()?; - match source_type { - SourceType::Ui => { - let ui = self - .ui - .ok_or(RenderTargetError::RequiredComponentNotAvailable)?; - Ok(ui.logical_size()) - } - SourceType::Sprite => { - let sprite = self - .sprite - .ok_or(RenderTargetError::RequiredComponentNotAvailable)?; - Ok(sprite - .custom_size - .ok_or(RenderTargetError::SpriteCustomSizeNotSet)?) - } - } - } -} - -/// Function to find the location of the mouse cursor in a cosmic widget. -/// Returns in logical pixels -// TODO: Change this to use builtin `bevy::picking` instead -pub(crate) fn get_node_cursor_pos( - window: &Window, - node_transform: &GlobalTransform, - size: Vec2, - source_type: SourceType, - camera: &Camera, - camera_transform: &GlobalTransform, -) -> Option<Vec2> { - let node_translation = node_transform.affine().translation; - let node_bounds = Rect::new( - node_translation.x - size.x / 2., - node_translation.y - size.y / 2., - node_translation.x + size.x / 2., - node_translation.y + size.y / 2., - ); - - window.cursor_position().and_then(|pos| match source_type { - SourceType::Ui => { - if node_bounds.contains(pos) { - Some(Vec2::new( - pos.x - node_bounds.min.x, - pos.y - node_bounds.min.y, - )) - } else { - None - } - } - SourceType::Sprite => camera - .viewport_to_world_2d(camera_transform, pos) - .ok() - .and_then(|pos| { - if node_bounds.contains(pos) { - Some(Vec2::new( - pos.x - node_bounds.min.x, - node_bounds.max.y - pos.y, - )) - } else { - None - } - }), - }) -} - -pub(crate) fn hover_sprites( - windows: Query<&Window, With<bevy::window::PrimaryWindow>>, - mut cosmic_edit_query: Query< - (&mut Sprite, &Visibility, &GlobalTransform, &HoverCursor), - With<CosmicEditBuffer>, - >, - camera_q: Query<(&Camera, &GlobalTransform), CameraFilter>, - mut hovered: Local<bool>, - mut last_hovered: Local<bool>, - mut evw_hover_in: EventWriter<TextHoverIn>, - mut evw_hover_out: EventWriter<TextHoverOut>, -) { - *hovered = false; - if windows.iter().len() == 0 { - return; - } - let window = windows.single(); - let (camera, camera_transform) = camera_q.single(); - - let mut icon = CursorIcon::System(SystemCursorIcon::Default); - - for (sprite, visibility, node_transform, hover) in &mut cosmic_edit_query.iter_mut() { - if visibility == Visibility::Hidden { - continue; - } - - let size = sprite.custom_size.unwrap_or(Vec2::ONE); - if crate::render_targets::get_node_cursor_pos( - window, - node_transform, - size, - SourceType::Sprite, - camera, - camera_transform, - ) - .is_some() - { - *hovered = true; - icon = hover.0.clone(); - } - } - - if *last_hovered != *hovered { - if *hovered { - evw_hover_in.send(TextHoverIn(icon)); - } else { - evw_hover_out.send(TextHoverOut); - } - } - - *last_hovered = *hovered; -} diff --git a/src/utils.rs b/src/utils.rs index 12e9a4b..440407c 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,9 +1,9 @@ // Common functions for examples use crate::{ - cosmic_edit::ReadOnly, prelude::*, primary::CameraFilter, ChangedCosmicWidgetSize, - CosmicWidgetSize, + prelude::*, + render_implementations::{ChangedCosmicWidgetSize, CosmicWidgetSize}, }; -use bevy::{ecs::query::QueryData, window::PrimaryWindow}; +use bevy::ecs::query::QueryData; use cosmic_text::Edit; /// Trait for adding color conversion from [`bevy::prelude::Color`] to [`cosmic_text::Color`] @@ -35,59 +35,6 @@ pub fn deselect_editor_on_esc(i: Res<ButtonInput<KeyCode>>, mut focus: ResMut<Fo } } -/// System to allow focus on click for sprite widgets -pub fn change_active_editor_sprite( - mut commands: Commands, - windows: Query<&Window, With<PrimaryWindow>>, - buttons: Res<ButtonInput<MouseButton>>, - mut cosmic_edit_query: Query< - (&mut Sprite, &GlobalTransform, &Visibility, Entity), - (With<CosmicEditBuffer>, Without<ReadOnly>), - >, - camera_q: Query<(&Camera, &GlobalTransform), CameraFilter>, -) { - let window = windows.single(); - let (camera, camera_transform) = camera_q.single(); - if buttons.just_pressed(MouseButton::Left) { - for (sprite, node_transform, visibility, entity) in &mut cosmic_edit_query.iter_mut() { - if visibility == Visibility::Hidden { - continue; - } - let size = sprite.custom_size.unwrap_or(Vec2::ONE); - let x_min = node_transform.affine().translation.x - size.x / 2.; - let y_min = node_transform.affine().translation.y - size.y / 2.; - let x_max = node_transform.affine().translation.x + size.x / 2.; - let y_max = node_transform.affine().translation.y + size.y / 2.; - if let Some(pos) = window.cursor_position() { - if let Ok(pos) = camera.viewport_to_world_2d(camera_transform, pos) { - if x_min < pos.x && pos.x < x_max && y_min < pos.y && pos.y < y_max { - commands.insert_resource(FocusedWidget(Some(entity))) - }; - } - }; - } - } -} - -/// System to allow focus on click for UI widgets -pub fn change_active_editor_ui( - mut interaction_query: Query< - (&Interaction, Entity), - ( - Changed<Interaction>, - Without<ReadOnly>, - With<CosmicEditBuffer>, - ), - >, - mut focussed_widget: ResMut<FocusedWidget>, -) { - for (interaction, entity) in interaction_query.iter_mut() { - if let Interaction::Pressed = interaction { - *focussed_widget = FocusedWidget(Some(entity)); - } - } -} - /// System to print editor text content on change pub fn print_editor_text( text_inputs_q: Query<&CosmicEditor>, @@ -137,6 +84,7 @@ pub fn print_editor_sizes( } /// Calls javascript to get the current timestamp +#[allow(dead_code)] // idk why this isn't used #[cfg(target_arch = "wasm32")] pub(crate) fn get_timestamp() -> f64 { js_sys::Date::now() diff --git a/src/widget.rs b/src/widget.rs deleted file mode 100644 index 1e2d1d3..0000000 --- a/src/widget.rs +++ /dev/null @@ -1,215 +0,0 @@ -use crate::buffer::get_x_offset_center; -use crate::buffer::get_y_offset_center; -use crate::cosmic_edit::CosmicTextAlign; -use crate::cosmic_edit::CosmicWrap; -use crate::cosmic_edit::XOffset; -use crate::input::InputSet; -use crate::prelude::*; -use crate::ChangedCosmicWidgetSize; -use crate::CosmicWidgetSize; -use cosmic_text::Affinity; -use cosmic_text::Edit; - -/// System set for cosmic text layout systems. Runs in [`PostUpdate`] -#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] -pub(crate) struct WidgetSet; - -pub(crate) struct WidgetPlugin; - -impl Plugin for WidgetPlugin { - fn build(&self, app: &mut App) { - app.add_systems(Update, reshape.in_set(WidgetSet).after(InputSet)) - .add_systems( - PostUpdate, - ( - new_image_from_default, - set_buffer_size, - set_padding, - set_x_offset, - ) - .chain() - .in_set(WidgetSet) - .after(TransformSystem::TransformPropagate), - ) - .register_type::<CosmicPadding>(); - } -} - -/// Wrapper for a [`Vec2`] describing the horizontal and vertical padding of a widget. -/// This is set programatically, not for user modification. -/// To set a widget's padding, use [`CosmicTextAlign`] -#[derive(Component, Reflect, Default, Deref, DerefMut, Debug)] -pub(crate) struct CosmicPadding(pub(crate) Vec2); - -// /// Wrapper for a [`Vec2`] describing the horizontal and vertical size of a widget. -// /// This is set programatically, not for user modification. -// /// To set a widget's size, use either it's [`Sprite`] dimensions or modify the target UI element's -// /// size. -// #[derive(Component, Reflect, Default, Deref, DerefMut)] -// pub(crate) struct CosmicWidgetSize(pub(crate) Vec2); - -/// Reshapes text in a [`CosmicEditor`] -fn reshape(mut query: Query<&mut CosmicEditor>, mut font_system: ResMut<CosmicFontSystem>) { - for mut cosmic_editor in query.iter_mut() { - cosmic_editor.shape_as_needed(&mut font_system.0, false); - } -} - -/// Programatically sets the [`CosmicPadding`] of a widget based on it's [`CosmicTextAlign`] -fn set_padding( - mut query: Query< - ( - &mut CosmicPadding, - &CosmicTextAlign, - &CosmicEditBuffer, - CosmicWidgetSize, - Option<&CosmicEditor>, - ), - Or<( - With<CosmicEditor>, - Changed<CosmicTextAlign>, - Changed<CosmicEditBuffer>, - ChangedCosmicWidgetSize, - )>, - >, -) { - for (mut padding, position, buffer, size, editor_opt) in query.iter_mut() { - let Ok(size) = size.logical_size() else { - continue; - }; - - // TODO: At least one of these clones is uneccessary - let mut buffer = buffer.0.clone(); - - if let Some(editor) = editor_opt { - buffer = editor.with_buffer(|b| b.clone()); - } - - if !buffer.redraw() { - continue; - } - - padding.0 = match position { - CosmicTextAlign::Center { padding: _ } => Vec2::new( - get_x_offset_center(size.x, &buffer) as f32, - get_y_offset_center(size.y, &buffer) as f32, - ), - CosmicTextAlign::TopLeft { padding } => Vec2::new(*padding as f32, *padding as f32), - CosmicTextAlign::Left { padding } => { - Vec2::new(*padding as f32, get_y_offset_center(size.y, &buffer) as f32) - } - } - } -} - -/// Sets the internal [`Buffer`]'s size according to the [`CosmicWidgetSize`] and [`CosmicTextAlign`] -fn set_buffer_size( - mut query: Query< - ( - &mut CosmicEditBuffer, - &CosmicWrap, - CosmicWidgetSize, - &CosmicTextAlign, - ), - Or<( - Changed<CosmicWrap>, - ChangedCosmicWidgetSize, - Changed<CosmicTextAlign>, - )>, - >, - mut font_system: ResMut<CosmicFontSystem>, -) { - for (mut buffer, mode, size, position) in query.iter_mut() { - let Ok(size) = size.logical_size() else { - continue; - }; - let padding_x = match position { - CosmicTextAlign::Center { padding: _ } => 0., - CosmicTextAlign::TopLeft { padding } => *padding as f32, - CosmicTextAlign::Left { padding } => *padding as f32, - }; - - let (buffer_width, buffer_height) = match mode { - CosmicWrap::InfiniteLine => (f32::MAX, size.y), - CosmicWrap::Wrap => (size.x - padding_x, size.y), - }; - - buffer.set_size(&mut font_system.0, Some(buffer_width), Some(buffer_height)); - } -} - -/// Instantiates a new image for a [`CosmicEditBuffer`] -fn new_image_from_default( - mut query: Query<&mut CosmicRenderOutput, Added<CosmicEditBuffer>>, - mut images: ResMut<Assets<Image>>, -) { - for mut canvas in query.iter_mut() { - debug!(message = "Initializing a new canvas"); - *canvas = CosmicRenderOutput(images.add(Image::default())); - } -} - -fn set_x_offset( - mut query: Query<( - &mut XOffset, - &CosmicWrap, - &CosmicEditor, - CosmicWidgetSize, - &CosmicTextAlign, - )>, -) { - for (mut x_offset, mode, editor, size, position) in query.iter_mut() { - let Ok(size) = size.logical_size() else { - continue; - }; - if mode != &CosmicWrap::InfiniteLine { - continue; // this used to be `return` though I feel like it should be continue - } - - let mut cursor_x = 0.; - let cursor = editor.cursor(); - - // counts the width of the glyphs up to the cursor in `cursor_x` - if let Some(line) = editor.with_buffer(|b| b.clone()).layout_runs().next() { - for (idx, glyph) in line.glyphs.iter().enumerate() { - match cursor.affinity { - Affinity::Before => { - if idx <= cursor.index { - cursor_x += glyph.w; - } else { - break; - } - } - Affinity::After => { - if idx < cursor.index { - cursor_x += glyph.w; - } else { - break; - } - } - } - } - } - - let padding_x = match position { - CosmicTextAlign::Center { padding } => *padding as f32, - CosmicTextAlign::TopLeft { padding } => *padding as f32, - CosmicTextAlign::Left { padding } => *padding as f32, - }; - - if x_offset.width.is_none() { - x_offset.width = Some(size.x - padding_x * 2.); - } - - let right = x_offset.width.unwrap() + x_offset.left; - - if cursor_x > right { - let diff = cursor_x - right; - x_offset.left += diff; - } - if cursor_x < x_offset.left { - let diff = x_offset.left - cursor_x; - x_offset.left -= diff; - } - } -} -- GitLab