From 4d7d777e86ded46f20b7f42833dec626d2fe4e97 Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Wed, 21 Oct 2026 20:22:32 +0200 Subject: [PATCH] feat(chat): add native text editing, history and command completion Use Bevy editable text with word navigation, keyboard and pointer selection, system clipboard integration and IME-aware submission. Add bounded history with draft restoration and permission-aware Tab completion. Cover native keyboard, mouse and clipboard event handling in tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 + README.md | 10 + crates/game_client/Cargo.toml | 2 +- crates/game_client/src/hud.rs | 22 +- crates/game_client/src/ui/chat.rs | 693 ++++++++++++++++++++++++++++++ crates/game_client/src/ui/mod.rs | 64 ++- 6 files changed, 782 insertions(+), 11 deletions(-) create mode 100644 crates/game_client/src/ui/chat.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 822aadc..28365a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Admin `export [FILE]` and `import [FILE]` commands for editable Tiled snapshots of terrain, buildings and ground items, including health, script variables, machine contents and processing state. Imports replace the map while preserving characters. +- Native chat text editing with word navigation, keyboard/mouse selection, system + clipboard copy/cut/paste, bounded sent-message history and role-aware command completion. - Persistent-world station game with singleplayer, hosting (`--host`) and joining (`--connect`), plus a dedicated `game-server`. - Data-driven items, recipes, buildings and skills in `assets/data/*.rsn`. diff --git a/README.md b/README.md index 8841f23..89307c3 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ game-server --database postgres://station:secret@db/station --world europa The schema is created and migrated automatically. To run the PostgreSQL tests, set `STATION_TEST_POSTGRES_URL` to a database URL; without it they are skipped. +The native chat clipboard test can be enabled with `STATION_TEST_CLIPBOARD=1` +when running tests in a desktop session with an available system clipboard. ### Dedicated server configuration @@ -146,6 +148,14 @@ in-game chat. Admin commands change `server.toml` (or `host.toml`) immediately. ## Controls +Chat supports cursor navigation (arrows, Home/End, Ctrl+Left/Right), Shift selection, +Ctrl+A, mouse positioning/drag selection, double-click word selection, and system +clipboard copy/cut/paste (Ctrl+C/X/V). On macOS, use the usual Command shortcuts and +Option for word navigation. Up/Down recall the last 100 sent messages and commands; +Down past the newest entry restores the unsent draft. Tab completes command names +and subcommands allowed for your role; repeated Tab cycles matches, and Shift+Tab +cycles backwards. Escape closes chat without sending. + | Key | Action | |---------------------|------------------------------------------------------| | WASD / arrows | Move freely, also diagonally (you face that way) | diff --git a/crates/game_client/Cargo.toml b/crates/game_client/Cargo.toml index 849cab6..27ffb5d 100644 --- a/crates/game_client/Cargo.toml +++ b/crates/game_client/Cargo.toml @@ -10,7 +10,7 @@ path = "src/main.rs" [dependencies] game_core = { workspace = true } game_server = { workspace = true } -bevy = { workspace = true, default-features = true } +bevy = { workspace = true, default-features = true, features = ["bevy_ui_widgets", "system_clipboard"] } bevy_replicon = { workspace = true } bevy_replicon_renet = { workspace = true } directories = { workspace = true } diff --git a/crates/game_client/src/hud.rs b/crates/game_client/src/hud.rs index 01d3d21..bcd9dc7 100644 --- a/crates/game_client/src/hud.rs +++ b/crates/game_client/src/hud.rs @@ -37,17 +37,21 @@ impl Plugin for HudPlugin { }) .add_observer(on_action) .add_observer( - |_: On, mut log: ResMut, mut ui: ResMut| { + |_: On, + mut log: ResMut, + mut ui: ResMut, + mut history: ResMut| { *log = default(); *ui = default(); + *history = default(); }, ) .add_systems(OnEnter(AppState::InGame), spawn_hud) + .add_systems(PostUpdate, focus_chat.run_if(in_state(AppState::InGame))) .add_systems( Update, ( hud_bindings, - focus_chat, inventory_widget, skills_widget, log_widget, @@ -172,13 +176,25 @@ fn focus_chat( mut focus: ResMut, mut opened: Local, inputs: Query<(Entity, &ui::TextInput)>, + mut native_focus: ResMut, ) { let chat = inputs.iter().find(|(_, i)| i.id == "chat").map(|(e, _)| e); if ui.chat_open && !*opened { focus.input = chat; + if let Some(chat) = chat { + native_focus.set(chat, bevy::input_focus::FocusCause::Navigated); + } } else if ui.chat_open && focus.input != chat { ui.chat_open = false; } + if !ui.chat_open { + if native_focus.get() == chat { + native_focus.clear(); + } + if focus.input == chat { + focus.input = None; + } + } *opened = ui.chat_open; } @@ -593,6 +609,7 @@ fn on_action( state: Res>, mut ui: ResMut, mut form: ResMut, + mut history: ResMut, machine: Res, local: Res, characters: Query<(&GridPos, &Facing, &Inventory)>, @@ -642,6 +659,7 @@ fn on_action( form.0.insert("chat".into(), String::new()); ui.chat_open = false; if !text.is_empty() { + history.remember(&text); commands.client_trigger(Chat { text }); } } diff --git a/crates/game_client/src/ui/chat.rs b/crates/game_client/src/ui/chat.rs new file mode 100644 index 0000000..bcb88fe --- /dev/null +++ b/crates/game_client/src/ui/chat.rs @@ -0,0 +1,693 @@ +//! Chat-specific history and completion on top of Bevy's native text editor. + +use std::collections::{BTreeSet, VecDeque}; + +use bevy::{ + clipboard::Clipboard, + input::{ + ButtonState, + keyboard::{Key, KeyboardInput}, + }, + input_focus::InputFocus, + prelude::*, + text::{EditableText, FontCx, LayoutCx, TextEdit}, +}; +use bevy_replicon::prelude::ClientId; +use game_server::commands::{CommandSource, help}; + +use super::{TextInput, UiAction, UiFocus, UiForm}; +use crate::session::{SessionInfo, SessionRequest, SessionTarget}; + +const HISTORY_LIMIT: usize = 100; + +#[derive(Resource, Default)] +pub struct ChatHistory { + entries: VecDeque, + cursor: Option, + draft: String, + completion: Option, +} + +impl ChatHistory { + pub fn remember(&mut self, text: &str) { + if self.entries.back().is_none_or(|last| last != text) { + self.entries.push_back(text.to_owned()); + if self.entries.len() > HISTORY_LIMIT { + self.entries.pop_front(); + } + } + self.reset_navigation(); + } + + fn reset_navigation(&mut self) { + self.cursor = None; + self.draft.clear(); + self.completion = None; + } + + fn navigate(&mut self, current: &str, older: bool) -> Option { + if self.entries.is_empty() { + return None; + } + let next = match (self.cursor, older) { + (None, true) => { + self.draft = current.to_owned(); + Some(self.entries.len() - 1) + } + (Some(i), true) => Some(i.saturating_sub(1)), + (Some(i), false) if i + 1 < self.entries.len() => Some(i + 1), + (Some(_), false) => None, + (None, false) => return None, + }; + self.cursor = next; + self.completion = None; + Some(next.map_or_else(|| self.draft.clone(), |i| self.entries[i].clone())) + } + + fn complete( + &mut self, + value: &str, + caret: usize, + source: CommandSource, + backwards: bool, + ) -> Option<(String, usize)> { + if let Some(cycle) = &mut self.completion + && cycle.last_value == value + && cycle.last_caret == caret + && cycle.source == source + { + cycle.index = if backwards { + (cycle.index + cycle.candidates.len() - 1) % cycle.candidates.len() + } else { + (cycle.index + 1) % cycle.candidates.len() + }; + return Some(cycle.result()); + } + let before = value.get(..caret)?.strip_prefix('/')?; + let (head, prefix) = before + .rsplit_once(' ') + .map_or(("", before), |(h, p)| (h, p)); + let word = before.split(' ').count() - 1; + let mut candidates = BTreeSet::new(); + for (usage, _) in help(source) { + let parts: Vec<_> = usage.split(' ').collect(); + let previous: Vec<_> = head.split(' ').filter(|s| !s.is_empty()).collect(); + if previous.iter().enumerate().any(|(i, token)| { + parts + .get(i) + .is_none_or(|part| !part.split('|').any(|p| p == *token)) + }) { + continue; + } + if let Some(part) = parts.get(word) { + candidates.extend( + part.split('|') + .filter(|p| { + p.chars().all(|c| c.is_ascii_lowercase()) && p.starts_with(prefix) + }) + .map(str::to_owned), + ); + } + } + if candidates.is_empty() { + self.completion = None; + return None; + } + let candidates: Vec<_> = candidates.into_iter().collect(); + let index = if backwards { candidates.len() - 1 } else { 0 }; + let mut cycle = Completion { + start: caret - prefix.len(), + suffix: value[caret..] + .find(char::is_whitespace) + .map_or("", |end| &value[caret + end..]) + .to_owned(), + leading: value[..caret - prefix.len()].to_owned(), + source, + candidates, + index, + last_value: String::new(), + last_caret: 0, + }; + let result = cycle.result(); + self.completion = Some(cycle); + Some(result) + } +} + +struct Completion { + start: usize, + leading: String, + suffix: String, + source: CommandSource, + candidates: Vec, + index: usize, + last_value: String, + last_caret: usize, +} + +impl Completion { + fn result(&mut self) -> (String, usize) { + let candidate = &self.candidates[self.index]; + self.last_value = format!("{}{}{}", self.leading, candidate, self.suffix); + self.last_caret = self.start + candidate.len(); + (self.last_value.clone(), self.last_caret) + } +} + +#[derive(Component)] +pub struct ChatEditor { + synced: String, +} + +impl ChatEditor { + pub fn new(value: String) -> Self { + Self { synced: value } + } +} + +pub fn edit( + mut commands: Commands, + mut keys: MessageReader, + modifiers: Res>, + mut focus: ResMut, + mut native_focus: ResMut, + mut form: ResMut, + mut history: ResMut, + mut inputs: Query<(Entity, &TextInput, &mut EditableText, &mut ChatEditor)>, + mut fonts: ResMut, + mut layouts: ResMut, + mut clipboard: ResMut, + session: Option>, + request: Option>, +) { + let Some((entity, input, mut text, mut editor)) = inputs.iter_mut().next() else { + keys.clear(); + return; + }; + if form.get(&input.id) != editor.synced { + let value = form.get(&input.id).to_owned(); + replace(&mut text, &value, value.len(), &mut fonts, &mut layouts); + editor.synced = value; + } + let was_composing = text.is_composing(); + text.apply_pending_edits(&mut fonts, &mut layouts.0, &mut clipboard, |c| { + !c.is_control() + }); + let mut value = text.value().to_string(); + if focus.input != Some(entity) { + keys.clear(); + return; + } + let source = CommandSource::Player { + client: ClientId::Server, + admin: session.is_some_and(|s| s.admin), + owner: request.is_some_and(|r| matches!(r.target, SessionTarget::Local { .. })), + }; + let shift = modifiers.pressed(Key::Shift); + let shortcut = modifiers.any_pressed([Key::Control, Key::Alt, Key::Super]); + for key in keys.read().filter(|k| k.state == ButtonState::Pressed) { + focus.consumed = true; + if was_composing || text.is_composing() { + continue; + } + match key.logical_key { + Key::Enter => { + form.0.insert(input.id.clone(), value.clone()); + if let Some(action) = &input.submit { + commands.trigger(UiAction(action.clone())); + } + text.clear(); + editor.synced = value.clone(); + focus.input = None; + native_focus.clear(); + break; + } + Key::Escape => { + history.reset_navigation(); + focus.input = None; + native_focus.clear(); + break; + } + Key::ArrowUp | Key::ArrowDown if !shift && !shortcut => { + if let Some(recalled) = history.navigate(&value, key.logical_key == Key::ArrowUp) { + replace( + &mut text, + &recalled, + recalled.len(), + &mut fonts, + &mut layouts, + ); + value = recalled; + } + } + Key::Tab if !shortcut => { + let selection = text.editor.raw_selection(); + let caret = selection.focus().index(); + if selection.is_collapsed() + && let Some((completed, caret)) = history.complete(&value, caret, source, shift) + && completed.chars().count() <= input.max_len + { + replace(&mut text, &completed, caret, &mut fonts, &mut layouts); + value = completed; + } + } + Key::Shift | Key::Control | Key::Alt | Key::Super => {} + _ => history.completion = None, + } + } + if focus.input == Some(entity) && editor.synced != value { + form.0.insert(input.id.clone(), value.clone()); + editor.synced = value; + } +} + +fn replace( + text: &mut EditableText, + value: &str, + caret: usize, + fonts: &mut FontCx, + layouts: &mut LayoutCx, +) { + text.clear(); + text.editor.set_text(value); + text.editor + .driver(fonts, &mut layouts.0) + .move_to_byte(caret); + text.queue_edit(TextEdit::CollapseSelection); +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy::{ + camera::RenderTarget, + input::{InputPlugin, keyboard::NativeKeyCode}, + input_focus::{InputDispatchPlugin, InputFocusPlugin}, + picking::{ + backend::HitData, + events::{Drag, Pointer, Press, Release}, + pointer::{Location, PointerButton, PointerId}, + }, + text::DEFAULT_FONT_DATA, + ui_widgets::EditableTextInputPlugin, + window::{Ime, PrimaryWindow, WindowRef}, + }; + + fn app() -> (App, Entity, Entity) { + let mut app = App::new(); + app.add_plugins(( + MinimalPlugins, + InputPlugin, + InputFocusPlugin, + InputDispatchPlugin, + EditableTextInputPlugin, + )) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_message::() + .add_message::>() + .add_systems(Update, edit); + let mut fonts = app.world_mut().resource_mut::(); + fonts + .collection + .register_fonts(DEFAULT_FONT_DATA.to_vec().into(), None); + fonts.set_sans_serif_family("Fira Mono").unwrap(); + let window = app + .world_mut() + .spawn((Window::default(), PrimaryWindow)) + .id(); + let input = app + .world_mut() + .spawn(( + TextInput { + id: "chat".into(), + placeholder: String::new(), + password: false, + max_len: 200, + submit: Some("chat_send".into()), + }, + EditableText { + allow_newlines: false, + max_characters: Some(200), + ..default() + }, + ChatEditor::new(String::new()), + )) + .id(); + app.world_mut().resource_mut::().input = Some(input); + app.world_mut() + .resource_mut::() + .set(input, bevy::input_focus::FocusCause::Navigated); + app.finish(); + app.cleanup(); + app.update(); + (app, window, input) + } + + fn key(app: &mut App, window: Entity, logical_key: Key, text: Option<&str>) { + for state in [ButtonState::Pressed, ButtonState::Released] { + app.world_mut().write_message(KeyboardInput { + key_code: KeyCode::Unidentified(NativeKeyCode::Unidentified), + logical_key: logical_key.clone(), + state, + text: text.map(Into::into), + repeat: false, + window, + }); + app.update(); + } + } + + fn value(app: &App, input: Entity) -> String { + app.world() + .get::(input) + .unwrap() + .value() + .to_string() + } + + #[test] + fn native_chat_keyboard_navigation_selection_history_and_submission() { + let (mut app, window, input) = app(); + key( + &mut app, + window, + Key::Character("hello world".into()), + Some("hello world"), + ); + assert_eq!(value(&app, input), "hello world"); + app.world_mut() + .resource_mut::>() + .press(Key::Control); + key(&mut app, window, Key::ArrowLeft, None); + assert_eq!( + app.world() + .get::(input) + .unwrap() + .editor + .raw_selection() + .focus() + .index(), + 6 + ); + app.world_mut() + .resource_mut::>() + .press(Key::Shift); + key(&mut app, window, Key::ArrowRight, None); + assert_eq!( + app.world() + .get::(input) + .unwrap() + .editor + .raw_selection() + .text_range(), + 6..11 + ); + app.world_mut() + .resource_mut::>() + .release(Key::Control); + app.world_mut() + .resource_mut::>() + .release(Key::Shift); + key( + &mut app, + window, + Key::Character("café".into()), + Some("café"), + ); + assert_eq!(value(&app, input), "hello café"); + key(&mut app, window, Key::ArrowLeft, None); + key(&mut app, window, Key::Delete, None); + assert_eq!(value(&app, input), "hello caf"); + app.world_mut() + .resource_mut::() + .remember("sent"); + key(&mut app, window, Key::ArrowUp, None); + assert_eq!(value(&app, input), "sent"); + key(&mut app, window, Key::ArrowDown, None); + assert_eq!(value(&app, input), "hello caf"); + key(&mut app, window, Key::Enter, None); + assert_eq!(app.world().resource::().get("chat"), "hello caf"); + assert!(value(&app, input).is_empty()); + assert!(app.world().resource::().input.is_none()); + assert!(app.world().resource::().get() != Some(input)); + } + + #[test] + fn native_chat_completion_and_external_drafts() { + let (mut app, window, input) = app(); + app.insert_resource(SessionInfo { + admin: true, + ..default() + }); + app.world_mut() + .resource_mut::() + .0 + .insert("chat".into(), "/exp".into()); + app.update(); + key(&mut app, window, Key::Tab, None); + assert_eq!(value(&app, input), "/export"); + assert_eq!(app.world().resource::().get("chat"), "/export"); + key(&mut app, window, Key::Escape, None); + assert!(app.world().resource::().input.is_none()); + } + + #[test] + fn native_chat_enter_confirms_ime_before_submitting() { + let (mut app, window, input) = app(); + app.world_mut() + .get_mut::(input) + .unwrap() + .queue_edit(TextEdit::ImeSetCompose { + value: "é".into(), + cursor: None, + }); + app.update(); + assert!( + app.world() + .get::(input) + .unwrap() + .is_composing() + ); + app.world_mut().write_message(Ime::Commit { + window, + value: "é".into(), + }); + key(&mut app, window, Key::Enter, None); + assert_eq!(value(&app, input), "é"); + assert_eq!(app.world().resource::().input, Some(input)); + key(&mut app, window, Key::Enter, None); + assert!(value(&app, input).is_empty()); + assert_eq!(app.world().resource::().get("chat"), "é"); + } + + #[test] + fn native_chat_mouse_selection_and_length_limits() { + let (mut app, window, input) = app(); + key( + &mut app, + window, + Key::Character("hello world".into()), + Some("hello world"), + ); + let target = RenderTarget::Window(WindowRef::Entity(window)) + .normalize(Some(window)) + .unwrap(); + app.world_mut().trigger(Pointer::new( + PointerId::Mouse, + Location { + target: target.clone(), + position: Vec2::ZERO, + }, + Press { + button: PointerButton::Primary, + hit: HitData::new(window, 0.0, None, None), + count: 1, + }, + input, + )); + assert!( + !app.world() + .get::(input) + .unwrap() + .pending_edits + .is_empty() + ); + app.update(); + assert_eq!( + app.world() + .get::(input) + .unwrap() + .editor + .raw_selection() + .focus() + .index(), + 0 + ); + app.world_mut().trigger(Pointer::new( + PointerId::Mouse, + Location { + target, + position: Vec2::splat(10_000.0), + }, + Drag { + button: PointerButton::Primary, + distance: Vec2::splat(10_000.0), + delta: Vec2::splat(10_000.0), + }, + input, + )); + app.update(); + assert_eq!( + app.world() + .get::(input) + .unwrap() + .editor + .raw_selection() + .text_range(), + 0..11 + ); + key( + &mut app, + window, + Key::Character("replacement".into()), + Some("replacement"), + ); + assert_eq!(value(&app, input), "replacement"); + let too_long = "x".repeat(201); + key( + &mut app, + window, + Key::Character(too_long.clone().into()), + Some(&too_long), + ); + assert_eq!(value(&app, input), "replacement"); + } + + #[test] + fn native_chat_system_clipboard() { + if std::env::var("STATION_TEST_CLIPBOARD").as_deref() != Ok("1") { + eprintln!("STATION_TEST_CLIPBOARD not set; skipping system clipboard test"); + return; + } + let (mut app, window, input) = app(); + app.world_mut() + .resource_mut::() + .set_text("paste me") + .unwrap(); + app.world_mut() + .resource_mut::>() + .press(Key::Control); + key(&mut app, window, Key::Character("v".into()), None); + assert_eq!(value(&app, input), "paste me"); + key(&mut app, window, Key::Character("a".into()), None); + key(&mut app, window, Key::Character("x".into()), None); + assert!(value(&app, input).is_empty()); + assert_eq!( + app.world_mut() + .resource_mut::() + .fetch_text() + .poll_result() + .unwrap() + .unwrap(), + "paste me" + ); + key(&mut app, window, Key::Character("v".into()), None); + app.world_mut() + .resource_mut::>() + .release(Key::Control); + key(&mut app, window, Key::Character("!".into()), Some("!")); + app.world_mut() + .resource_mut::>() + .press(Key::Control); + key(&mut app, window, Key::Character("a".into()), None); + key(&mut app, window, Key::Character("c".into()), None); + assert_eq!( + app.world_mut() + .resource_mut::() + .fetch_text() + .poll_result() + .unwrap() + .unwrap(), + "paste me!" + ); + } + + #[test] + fn history_preserves_draft_and_is_bounded() { + let mut history = ChatHistory::default(); + history.remember("first"); + history.remember("second"); + history.remember("second"); + assert_eq!(history.entries.len(), 2); + assert_eq!(history.navigate("draft", true).as_deref(), Some("second")); + assert_eq!(history.navigate("edited", true).as_deref(), Some("first")); + assert_eq!(history.navigate("", true).as_deref(), Some("first")); + assert_eq!(history.navigate("", false).as_deref(), Some("second")); + assert_eq!(history.navigate("", false).as_deref(), Some("draft")); + assert_eq!(history.navigate("", false), None); + for i in 0..HISTORY_LIMIT + 1 { + history.remember(&i.to_string()); + } + assert_eq!(history.entries.len(), HISTORY_LIMIT); + assert_eq!(history.entries.front().map(String::as_str), Some("1")); + } + + fn player(admin: bool) -> CommandSource { + CommandSource::Player { + client: ClientId::Server, + admin, + owner: false, + } + } + + #[test] + fn completion_uses_permissions_and_supports_subcommands_and_suffixes() { + let mut history = ChatHistory::default(); + assert_eq!(history.complete("/exp", 4, player(false), false), None); + assert_eq!( + history.complete("/exp", 4, player(true), false), + Some(("/export".into(), 7)) + ); + assert_eq!( + history.complete("/imp file.tmx", 4, player(true), false), + Some(("/import file.tmx".into(), 7)) + ); + assert_eq!( + history.complete("/export file.tmx", 4, player(true), false), + Some(("/export file.tmx".into(), 7)) + ); + assert_eq!( + history.complete("/export file.tmx", 7, player(false), false), + None + ); + assert_eq!( + history.complete("/whitelist a", 12, player(true), false), + Some(("/whitelist add".into(), 14)) + ); + assert_eq!(history.complete("hello", 5, player(true), false), None); + assert_eq!(history.complete("/export ", 8, player(true), false), None); + } + + #[test] + fn completion_cycles_in_both_directions() { + let mut history = ChatHistory::default(); + assert_eq!( + history.complete("/s", 2, CommandSource::Console, false), + Some(("/save".into(), 5)) + ); + assert_eq!( + history.complete("/save", 5, CommandSource::Console, false), + Some(("/say".into(), 4)) + ); + assert_eq!( + history.complete("/say", 4, CommandSource::Console, true), + Some(("/save".into(), 5)) + ); + } +} diff --git a/crates/game_client/src/ui/mod.rs b/crates/game_client/src/ui/mod.rs index f15d876..5dab646 100644 --- a/crates/game_client/src/ui/mod.rs +++ b/crates/game_client/src/ui/mod.rs @@ -3,6 +3,7 @@ //! to [`UiBindings`], inputs and checkboxes write to [`UiForm`] and anything //! with a [`Tooltip`] shows it on hover. +pub mod chat; pub mod layout; use std::{ @@ -17,6 +18,7 @@ use bevy::{ keyboard::{Key, KeyboardInput}, }, prelude::*, + text::{EditableText, EditableTextFilter, TextCursorStyle}, ui::FocusPolicy, window::PrimaryWindow, }; @@ -28,6 +30,7 @@ pub struct UiFrameworkPlugin; impl Plugin for UiFrameworkPlugin { fn build(&self, app: &mut App) { app.insert_resource(layout::load_theme()) + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -37,6 +40,7 @@ impl Plugin for UiFrameworkPlugin { Update, ( text_editing.in_set(TextEditSystems), + chat::edit.after(text_editing).in_set(TextEditSystems), ( clicks, button_visuals, @@ -314,7 +318,7 @@ pub fn spawn_node(commands: &mut Commands, ctx: &mut UiContext, parent: Entity, } UiNode::Input(i) => { let value = ctx.bindings.resolve(&i.value); - ctx.form.0.insert(i.id.clone(), value); + ctx.form.0.insert(i.id.clone(), value.clone()); let mut entity = commands.spawn(( ChildOf(parent), TextInput { @@ -335,8 +339,31 @@ pub fn spawn_node(commands: &mut Commands, ctx: &mut UiContext, parent: Entity, }, BackgroundColor(theme.color("input")), BorderColor::all(theme.color("border")), - children![ctx.text("", theme.font_size, "text")], )); + if i.id == "chat" { + entity.insert(( + EditableText { + max_characters: Some(i.max_len), + allow_newlines: false, + visible_lines: Some(1.0), + ..EditableText::new(&value) + }, + EditableTextFilter::new(|c| !c.is_control()), + TextLayout::no_wrap(), + TextFont::from_font_size(theme.font_size), + TextColor(theme.color("text")), + TextCursorStyle { + color: theme.color("text"), + selection_color: theme.color("accent").with_alpha(0.5), + ..default() + }, + chat::ChatEditor::new(value), + )); + } else { + entity.with_children(|parent| { + parent.spawn(ctx.text("", theme.font_size, "text")); + }); + } common(&mut entity, &i.tooltip, &i.visible); } UiNode::Checkbox(c) => { @@ -578,18 +605,28 @@ fn text_editing( mut keys: MessageReader, mut focus: ResMut, mut form: ResMut, - inputs: Query<(Entity, &TextInput, &InheritedVisibility, &ComputedNode)>, + inputs: Query<( + Entity, + &TextInput, + &InheritedVisibility, + &ComputedNode, + Has, + )>, ) { focus.consumed = false; let Some(entity) = focus.input else { keys.clear(); return; }; - let Ok((_, input, ..)) = inputs.get(entity) else { + let Ok((_, input, _, _, native)) = inputs.get(entity) else { focus.input = None; keys.clear(); return; }; + if native { + focus.consumed = keys.read().any(|key| key.state == ButtonState::Pressed); + return; + } for key in keys.read() { if key.state != ButtonState::Pressed { continue; @@ -615,7 +652,7 @@ fn text_editing( // Next visible input in spawn order, wrapping around. let mut visible: Vec = inputs .iter() - .filter(|(_, _, v, node)| v.get() && node.size() != Vec2::ZERO) + .filter(|(_, _, v, node, _)| v.get() && node.size() != Vec2::ZERO) .map(|(e, ..)| e) .collect(); visible.sort(); @@ -642,11 +679,17 @@ fn input_visuals( time: Res