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>
This commit is contained in:
Steve Biedermann 2026-10-21 20:22:32 +02:00
parent 43051c9f5d
commit 4d7d777e86
6 changed files with 782 additions and 11 deletions

View File

@ -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`.

View File

@ -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) |

View File

@ -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 }

View File

@ -37,17 +37,21 @@ impl Plugin for HudPlugin {
})
.add_observer(on_action)
.add_observer(
|_: On<SessionEnded>, mut log: ResMut<GameLogBuffer>, mut ui: ResMut<UiState>| {
|_: On<SessionEnded>,
mut log: ResMut<GameLogBuffer>,
mut ui: ResMut<UiState>,
mut history: ResMut<ui::chat::ChatHistory>| {
*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<UiFocus>,
mut opened: Local<bool>,
inputs: Query<(Entity, &ui::TextInput)>,
mut native_focus: ResMut<bevy::input_focus::InputFocus>,
) {
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<State<AppState>>,
mut ui: ResMut<UiState>,
mut form: ResMut<UiForm>,
mut history: ResMut<ui::chat::ChatHistory>,
machine: Res<OpenMachine>,
local: Res<LocalCharacter>,
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 });
}
}

View File

@ -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<String>,
cursor: Option<usize>,
draft: String,
completion: Option<Completion>,
}
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<String> {
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<String>,
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<KeyboardInput>,
modifiers: Res<ButtonInput<Key>>,
mut focus: ResMut<UiFocus>,
mut native_focus: ResMut<InputFocus>,
mut form: ResMut<UiForm>,
mut history: ResMut<ChatHistory>,
mut inputs: Query<(Entity, &TextInput, &mut EditableText, &mut ChatEditor)>,
mut fonts: ResMut<FontCx>,
mut layouts: ResMut<LayoutCx>,
mut clipboard: ResMut<Clipboard>,
session: Option<Res<SessionInfo>>,
request: Option<Res<SessionRequest>>,
) {
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::<FontCx>()
.init_resource::<LayoutCx>()
.init_resource::<Clipboard>()
.init_resource::<UiScale>()
.init_resource::<UiFocus>()
.init_resource::<UiForm>()
.init_resource::<ChatHistory>()
.add_message::<Ime>()
.add_message::<Pointer<Release>>()
.add_systems(Update, edit);
let mut fonts = app.world_mut().resource_mut::<FontCx>();
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::<UiFocus>().input = Some(input);
app.world_mut()
.resource_mut::<InputFocus>()
.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::<EditableText>(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::<ButtonInput<Key>>()
.press(Key::Control);
key(&mut app, window, Key::ArrowLeft, None);
assert_eq!(
app.world()
.get::<EditableText>(input)
.unwrap()
.editor
.raw_selection()
.focus()
.index(),
6
);
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.press(Key::Shift);
key(&mut app, window, Key::ArrowRight, None);
assert_eq!(
app.world()
.get::<EditableText>(input)
.unwrap()
.editor
.raw_selection()
.text_range(),
6..11
);
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.release(Key::Control);
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.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::<ChatHistory>()
.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::<UiForm>().get("chat"), "hello caf");
assert!(value(&app, input).is_empty());
assert!(app.world().resource::<UiFocus>().input.is_none());
assert!(app.world().resource::<InputFocus>().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::<UiForm>()
.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::<UiForm>().get("chat"), "/export");
key(&mut app, window, Key::Escape, None);
assert!(app.world().resource::<UiFocus>().input.is_none());
}
#[test]
fn native_chat_enter_confirms_ime_before_submitting() {
let (mut app, window, input) = app();
app.world_mut()
.get_mut::<EditableText>(input)
.unwrap()
.queue_edit(TextEdit::ImeSetCompose {
value: "é".into(),
cursor: None,
});
app.update();
assert!(
app.world()
.get::<EditableText>(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::<UiFocus>().input, Some(input));
key(&mut app, window, Key::Enter, None);
assert!(value(&app, input).is_empty());
assert_eq!(app.world().resource::<UiForm>().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::<EditableText>(input)
.unwrap()
.pending_edits
.is_empty()
);
app.update();
assert_eq!(
app.world()
.get::<EditableText>(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::<EditableText>(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::<Clipboard>()
.set_text("paste me")
.unwrap();
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.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::<Clipboard>()
.fetch_text()
.poll_result()
.unwrap()
.unwrap(),
"paste me"
);
key(&mut app, window, Key::Character("v".into()), None);
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.release(Key::Control);
key(&mut app, window, Key::Character("!".into()), Some("!"));
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.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::<Clipboard>()
.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))
);
}
}

View File

@ -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::<chat::ChatHistory>()
.init_resource::<UiBindings>()
.init_resource::<UiForm>()
.init_resource::<UiFocus>()
@ -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<KeyboardInput>,
mut focus: ResMut<UiFocus>,
mut form: ResMut<UiForm>,
inputs: Query<(Entity, &TextInput, &InheritedVisibility, &ComputedNode)>,
inputs: Query<(
Entity,
&TextInput,
&InheritedVisibility,
&ComputedNode,
Has<EditableText>,
)>,
) {
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<Entity> = 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<Time>,
form: Res<UiForm>,
focus: Res<UiFocus>,
mut inputs: Query<(Entity, &TextInput, &Children, &mut BorderColor)>,
mut inputs: Query<(
Entity,
&TextInput,
Option<&Children>,
&mut BorderColor,
Has<EditableText>,
)>,
mut texts: Query<(&mut Text, &mut TextColor)>,
) {
let caret = ((time.elapsed_secs() * 2.0) as u32).is_multiple_of(2);
for (entity, input, children, mut border) in &mut inputs {
for (entity, input, children, mut border, native) in &mut inputs {
let focused = focus.input == Some(entity);
let value = form.get(&input.id);
let (mut shown, color) = if value.is_empty() && !focused {
@ -663,7 +706,12 @@ fn input_visuals(
if *border != border_color {
*border = border_color;
}
if let Some(Ok((mut text, mut text_color))) = children.first().map(|c| texts.get_mut(*c)) {
if native {
continue;
}
if let Some(Ok((mut text, mut text_color))) =
children.and_then(|c| c.first()).map(|c| texts.get_mut(*c))
{
if text.0 != shown {
text.0 = shown;
}